feat: json format, et al.

This commit is contained in:
Subarashimo
2025-12-03 14:55:30 +01:00
parent 56349f30e6
commit 0f7fdfcef1
28 changed files with 5692 additions and 237 deletions
+32 -1
View File
@@ -35,6 +35,7 @@ import {
setUserStatsContainer,
setInfoBoxContainer,
setThoughtsContainer,
setSkillsContainer,
setInventoryContainer,
setQuestsContainer
} from './src/core/state.js';
@@ -49,7 +50,7 @@ import {
generateRPGPromptText,
generateSeparateUpdatePrompt
} from './src/systems/generation/promptBuilder.js';
import { parseResponse, parseUserStats } from './src/systems/generation/parser.js';
import { parseResponse, parseUserStats, parseSkills } from './src/systems/generation/parser.js';
import { updateRPGData } from './src/systems/generation/apiClient.js';
import { onGenerationStarted } from './src/systems/generation/injector.js';
@@ -65,6 +66,7 @@ import {
} from './src/systems/rendering/thoughts.js';
import { renderInventory } from './src/systems/rendering/inventory.js';
import { renderQuests } from './src/systems/rendering/quests.js';
import { renderSkills } from './src/systems/rendering/skills.js';
// Interaction modules
import { initInventoryEventListeners } from './src/systems/interaction/inventoryActions.js';
@@ -258,6 +260,7 @@ async function initUI() {
setUserStatsContainer($('#rpg-user-stats'));
setInfoBoxContainer($('#rpg-info-box'));
setThoughtsContainer($('#rpg-thoughts'));
setSkillsContainer($('#rpg-skills'));
setInventoryContainer($('#rpg-inventory'));
setQuestsContainer($('#rpg-quests'));
@@ -348,6 +351,30 @@ async function initUI() {
}
});
$('#rpg-toggle-skills').on('change', function() {
extensionSettings.showSkills = $(this).prop('checked');
saveSettings();
updateSectionVisibility();
renderSkills(); // Render skills section
// Re-setup desktop tabs to show/hide skills tab
if (window.innerWidth > 1000) {
removeDesktopTabs();
setupDesktopTabs();
}
});
$('#rpg-toggle-item-skill-links').on('change', function() {
extensionSettings.enableItemSkillLinks = $(this).prop('checked');
saveSettings();
// Re-render skills to show/hide link badges
renderSkills();
});
$('#rpg-toggle-delete-skill-with-item').on('change', function() {
extensionSettings.deleteSkillWithItem = $(this).prop('checked');
saveSettings();
});
$('#rpg-toggle-thoughts-in-chat').on('change', function() {
extensionSettings.showThoughtsInChat = $(this).prop('checked');
// console.log('[RPG Companion] Toggle showThoughtsInChat changed to:', extensionSettings.showThoughtsInChat);
@@ -497,6 +524,9 @@ async function initUI() {
$('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts);
$('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory);
$('#rpg-toggle-simplified-inventory').prop('checked', extensionSettings.useSimplifiedInventory);
$('#rpg-toggle-skills').prop('checked', extensionSettings.showSkills);
$('#rpg-toggle-item-skill-links').prop('checked', extensionSettings.enableItemSkillLinks);
$('#rpg-toggle-delete-skill-with-item').prop('checked', extensionSettings.deleteSkillWithItem);
$('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat);
$('#rpg-toggle-always-show-bubble').prop('checked', extensionSettings.alwaysShowThoughtBubble);
@@ -543,6 +573,7 @@ async function initUI() {
renderUserStats();
renderInfoBox();
renderThoughts();
renderSkills();
renderInventory();
renderQuests();
updateDiceDisplay();
+3 -2
View File
@@ -87,8 +87,9 @@ class Internationalization {
});
}
getTranslation(key) {
return this.translations[key] || null;
getTranslation(key, fallback = null) {
// Return translation, or fallback, or the key itself (prevents "null" from showing)
return this.translations[key] || fallback || key;
}
async setLanguage(lang) {
+47 -4
View File
@@ -140,6 +140,13 @@ export function saveChatData() {
quests: extensionSettings.quests,
lastGeneratedData: lastGeneratedData,
committedTrackerData: committedTrackerData,
// Structured data (JSON format)
inventoryV3: extensionSettings.inventoryV3,
skillsV2: extensionSettings.skillsV2,
skillAbilityLinks: extensionSettings.skillAbilityLinks,
infoBoxData: extensionSettings.infoBoxData,
charactersData: extensionSettings.charactersData,
questsV2: extensionSettings.questsV2,
timestamp: Date.now()
};
@@ -247,14 +254,50 @@ export function loadChatData() {
};
}
// Restore last generated data
// Restore last generated data (sanitize null values in infoBox)
if (savedData.lastGeneratedData) {
setLastGeneratedData({ ...savedData.lastGeneratedData });
const sanitizedData = { ...savedData.lastGeneratedData };
if (sanitizedData.infoBox && typeof sanitizedData.infoBox === 'string') {
// Remove lines that contain "null" values
sanitizedData.infoBox = sanitizedData.infoBox
.split('\n')
.filter(line => !line.match(/:\s*null\s*$/i) && !line.match(/:\s*undefined\s*$/i))
.join('\n');
}
setLastGeneratedData(sanitizedData);
}
// Restore committed tracker data
// Restore committed tracker data (sanitize null values in infoBox)
if (savedData.committedTrackerData) {
setCommittedTrackerData({ ...savedData.committedTrackerData });
const sanitizedData = { ...savedData.committedTrackerData };
if (sanitizedData.infoBox && typeof sanitizedData.infoBox === 'string') {
// Remove lines that contain "null" values
sanitizedData.infoBox = sanitizedData.infoBox
.split('\n')
.filter(line => !line.match(/:\s*null\s*$/i) && !line.match(/:\s*undefined\s*$/i))
.join('\n');
}
setCommittedTrackerData(sanitizedData);
}
// Restore structured data (JSON format)
if (savedData.inventoryV3) {
extensionSettings.inventoryV3 = savedData.inventoryV3;
}
if (savedData.skillsV2) {
extensionSettings.skillsV2 = savedData.skillsV2;
}
if (savedData.skillAbilityLinks) {
extensionSettings.skillAbilityLinks = savedData.skillAbilityLinks;
}
if (savedData.infoBoxData) {
extensionSettings.infoBoxData = savedData.infoBoxData;
}
if (savedData.charactersData) {
extensionSettings.charactersData = savedData.charactersData;
}
if (savedData.questsV2) {
extensionSettings.questsV2 = savedData.questsV2;
}
// Migrate inventory in chat data if feature flag enabled
+48 -2
View File
@@ -20,6 +20,9 @@ export let extensionSettings = {
showCharacterThoughts: true,
showInventory: true, // Show inventory section (v2 system)
useSimplifiedInventory: false, // Use simplified single-list inventory instead of categorized (On Person/Stored/Assets)
showSkills: false, // Show skills as separate section (moves skills from Status to own tab)
enableItemSkillLinks: false, // Enable linking items to skills (item grants skill, removing item removes skill)
deleteSkillWithItem: false, // When true, deleting an item also deletes linked skills. When false (default), just unlinks.
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
@@ -50,14 +53,52 @@ export let extensionSettings = {
arousal: 0,
mood: '😐',
conditions: 'None',
/** @type {InventoryV2} */
/** @type {InventoryV2} Legacy string-based inventory */
inventory: {
version: 2,
onPerson: "None",
stored: {},
assets: "None"
}
},
skills: "None" // Legacy single-string skills (for Status section)
},
/**
* Structured inventory v3 - items as objects with name, description, and skill links
* @type {{onPerson: Array<{name: string, description: string, grantsSkill?: string}>, stored: Object<string, Array>, assets: Array}}
*/
inventoryV3: {
onPerson: [], // Array of { name, description, grantsSkill? }
stored: {}, // { locationName: [{ name, description, grantsSkill? }] }
assets: [] // Array of { name, description }
},
/**
* Structured skills v2 - abilities as objects with name, description, and item links
* Key is the skill category name from config
* @type {Object<string, Array<{name: string, description: string, grantedBy?: string}>>}
*/
skillsV2: {
// Example: "Combat": [{ name: "Sword Fighting", description: "Blade proficiency", grantedBy: "Iron Sword" }]
},
/**
* Structured info box data (from JSON parsing)
*/
infoBoxData: {},
/**
* Structured characters data (from JSON parsing)
*/
charactersData: [],
/**
* Structured quests v2 (from JSON parsing)
*/
questsV2: {
main: null,
optional: []
},
// Legacy fields kept for backwards compatibility
skills: { list: [], categories: {} },
itemSkillLinks: {},
skillAbilityLinks: {},
skillsData: {},
statNames: {
health: 'Health',
satiety: 'Satiety',
@@ -250,6 +291,7 @@ export let $userStatsContainer = null;
export let $infoBoxContainer = null;
export let $thoughtsContainer = null;
export let $inventoryContainer = null;
export let $skillsContainer = null;
export let $questsContainer = null;
/**
@@ -319,6 +361,10 @@ export function setInventoryContainer($element) {
$inventoryContainer = $element;
}
export function setSkillsContainer($element) {
$skillsContainer = $element;
}
export function setQuestsContainer($element) {
$questsContainer = $element;
}
+30
View File
@@ -32,6 +32,12 @@
"template.settingsModal.display.showInventory": "Show Inventory",
"template.settingsModal.display.useSimplifiedInventory": "Use Simplified Inventory",
"template.settingsModal.display.useSimplifiedInventoryNote": "Single flat list instead of On Person / Stored / Assets categories",
"template.settingsModal.display.showSkills": "Show Skills Section",
"template.settingsModal.display.showSkillsNote": "Displays skills as a separate tab instead of within Status. Configure skills in Edit Trackers.",
"template.settingsModal.display.enableItemSkillLinks": "Enable Item-Skill Links",
"template.settingsModal.display.enableItemSkillLinksNote": "Items can grant skills. Removing an item unlinks or removes the skill.",
"template.settingsModal.display.deleteSkillWithItem": "Delete skill when item removed",
"template.settingsModal.display.deleteSkillWithItemNote": "When disabled (default), removing an item just unlinks the skill. When enabled, the skill is deleted.",
"template.settingsModal.display.showQuests": "Show Quests",
"template.settingsModal.display.showThoughtsInChat": "Show Thoughts in Chat",
"template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages",
@@ -88,6 +94,7 @@
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "Status Fields (comma-separated):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "Skills Section",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "Enable Skills Section",
"template.trackerEditorModal.userStatsTab.skillsInSeparateTabNote": "Skills are displayed in a separate tab. This toggle only affects the Status section.",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "Skills Label:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "Skills List (comma-separated):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "Widgets",
@@ -122,15 +129,23 @@
"global.none": "None",
"global.add": "Add",
"global.cancel": "Cancel",
"global.clickToEdit": "Click to edit",
"global.listView": "List view",
"global.gridView": "Grid view",
"global.save": "Save",
"global.status":"Status",
"global.skills":"Skills",
"global.inventory":"Inventory",
"global.quests":"Quests",
"global.info":"Info",
"infobox.noData.title": "No data yet",
"infobox.noData.instruction": "Generate a new response in the roleplay or switch to \"Separate Generation\" in Settings to access and click the \"Refresh RPG Info\" button",
"infobox.date": "Date",
"infobox.time": "Time",
"infobox.weather": "Weather",
"infobox.temperature": "Temperature",
"infobox.location": "Location",
"infobox.recentEvents": "Recent Events",
"infobox.recentEvents.title": "Recent Events",
"infobox.recentEvents.addEventPlaceholder": "Add event...",
"inventory.section.onPerson": "On Person",
@@ -161,6 +176,21 @@
"inventory.simplified.addItemButton": "Add Item",
"inventory.simplified.addItemPlaceholder": "Enter item name...",
"inventory.simplified.removeTitle": "Remove item",
"skills.title": "Skills",
"skills.empty": "No skills configured",
"skills.emptyNote": "Configure skills in Edit Trackers → User Stats → Skills Section",
"skills.noAbilities": "No abilities yet",
"skills.addAbility": "Add ability",
"skills.addAbilityButton": "Add",
"skills.addAbilityPlaceholder": "Enter ability name...",
"skills.removeAbility": "Remove ability",
"skills.linkToItem": "Link to inventory item",
"skills.unlinkItem": "Remove item link",
"skills.gotoItem": "Go to item",
"skills.selectItemToLink": "Select item to link",
"skills.noItemsToLink": "No items in inventory to link",
"skills.linkCreated": "Skill linked to item",
"inventory.gotoLinkedSkills": "Go to linked skills",
"quests.section.main": "Main Quest",
"quests.section.optional": "Optional Quests",
"quests.main.title": "Main Quests",
+31 -1
View File
@@ -32,6 +32,12 @@
"template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.useSimplifiedInventory": "使用簡化物品欄",
"template.settingsModal.display.useSimplifiedInventoryNote": "使用單一列表取代分類(隨身/倉庫/資產)",
"template.settingsModal.display.showSkills": "顯示技能區塊",
"template.settingsModal.display.showSkillsNote": "將技能顯示為獨立分頁,而非在狀態欄內。請在編輯追蹤器中配置技能。",
"template.settingsModal.display.enableItemSkillLinks": "啟用物品-技能連結",
"template.settingsModal.display.enableItemSkillLinksNote": "物品可以授予技能。移除物品會取消連結或刪除技能。",
"template.settingsModal.display.deleteSkillWithItem": "移除物品時刪除技能",
"template.settingsModal.display.deleteSkillWithItemNote": "禁用時(預設),移除物品只會取消連結。啟用時,技能會被刪除。",
"template.settingsModal.display.showQuests": "顯示任務",
"template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法",
"template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡",
@@ -88,6 +94,7 @@
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "狀態欄欄位(以逗號分隔):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "技能欄",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "啟用技能欄",
"template.trackerEditorModal.userStatsTab.skillsInSeparateTabNote": "技能已顯示在獨立分頁中。此開關僅影響狀態欄。",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "技能欄標籤:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": " 技能列表(以逗號分隔):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "小工具",
@@ -122,15 +129,23 @@
"global.none": "None",
"global.add": "添加",
"global.cancel": "取消",
"global.clickToEdit": "點擊編輯",
"global.save": "保存",
"global.listView": "清單檢視",
"global.gridView": "格子檢視",
"global.status": "狀態欄",
"global.skills": "技能",
"global.inventory": "物品欄",
"global.quests": "任務",
"global.info":"資訊",
"infobox.noData.title": "無資訊可顯示",
"infobox.noData.instruction": "在RP中產生新的回复,或在設定中切換到“單獨生成”,然後點擊“刷新資訊”按鈕。",
"infobox.noData.instruction": "在RP中產生新的回复,或在設定中切換到"",然後點擊""按鈕。",
"infobox.date": "日期",
"infobox.time": "時間",
"infobox.weather": "天氣",
"infobox.temperature": "溫度",
"infobox.location": "位置",
"infobox.recentEvents": "近期事件",
"infobox.recentEvents.title": "近期事件",
"infobox.recentEvents.addEventPlaceholder": "添加事件...",
"inventory.section.onPerson": "隨身物品",
@@ -161,6 +176,21 @@
"inventory.simplified.addItemButton": "添加物品",
"inventory.simplified.addItemPlaceholder": "輸入物品名稱...",
"inventory.simplified.removeTitle": "移除物品",
"skills.title": "技能",
"skills.empty": "未配置技能",
"skills.emptyNote": "請在編輯追蹤器 → 使用者狀態 → 技能區塊中配置技能",
"skills.noAbilities": "尚無能力",
"skills.addAbility": "新增能力",
"skills.addAbilityButton": "新增",
"skills.addAbilityPlaceholder": "輸入能力名稱...",
"skills.removeAbility": "移除能力",
"skills.linkToItem": "連結到物品欄物品",
"skills.unlinkItem": "移除物品連結",
"skills.gotoItem": "前往物品",
"skills.selectItemToLink": "選擇要連結的物品",
"skills.noItemsToLink": "物品欄中沒有可連結的物品",
"skills.linkCreated": "技能已連結到物品",
"inventory.gotoLinkedSkills": "前往已連結的技能",
"quests.section.main": "主線任務",
"quests.section.optional": "支線任務",
"quests.main.title": "主線任務",
+22 -1
View File
@@ -16,7 +16,7 @@ import {
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { generateSeparateUpdatePrompt } from './promptBuilder.js';
import { parseResponse, parseUserStats } from './parser.js';
import { parseResponse, parseUserStats, parseSkills, tryParseJSONResponse } from './parser.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts } from '../rendering/thoughts.js';
@@ -133,6 +133,23 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
if (response) {
// console.log('[RPG Companion] Raw AI response:', response);
// Try JSON parsing first if structured data mode is enabled
const jsonParsed = tryParseJSONResponse(response);
if (jsonParsed) {
// JSON parsing succeeded - render all sections
console.log('[RPG Companion] JSON parsing successful');
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
if (typeof renderSkills === 'function') renderSkills();
saveChatData();
} else {
// JSON parsing failed - try legacy text-based parsing as fallback
console.warn('[RPG Companion] JSON parsing failed, attempting legacy text parsing...');
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');
@@ -166,6 +183,9 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
}
if (parsedData.skills) {
parseSkills(parsedData.skills);
}
if (parsedData.infoBox) {
lastGeneratedData.infoBox = parsedData.infoBox;
}
@@ -215,6 +235,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
// Save to chat metadata
saveChatData();
}
}
} catch (error) {
console.error('[RPG Companion] Error updating RPG data:', error);
+13 -4
View File
@@ -16,12 +16,21 @@ import {
import { evaluateSuppression } from './suppression.js';
import { parseUserStats } from './parser.js';
import {
generateTrackerExample,
generateTrackerInstructions,
generateJSONTrackerInstructions,
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.
@@ -167,9 +176,9 @@ export function onGenerationStarted(type, data) {
if (extensionSettings.generationMode === 'together') {
// console.log('[RPG Companion] In together mode, generating prompts...');
const example = generateTrackerExample();
const example = ''; // JSON format includes schema in instructions, no separate example needed
// Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes
const instructions = generateTrackerInstructions(false, true);
const instructions = getTrackerInstructions(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);
+413 -4
View File
@@ -1,11 +1,14 @@
/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new JSON format
*/
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { validateTrackerData, mergeTrackerData } from '../../types/trackerData.js';
import { handleItemRemoved } from '../rendering/skills.js';
/**
* Helper to separate emoji from text in a string
@@ -133,19 +136,296 @@ 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 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) {
// Store custom status fields
extensionSettings.userStats.conditions = Object.values(jsonData.status.fields).join(', ') || 'None';
debugLog('[RPG Parser] Status fields:', jsonData.status.fields);
}
}
// 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));
}
// Parse characters - store for UI rendering
if (jsonData.characters && Array.isArray(jsonData.characters)) {
extensionSettings.charactersData = jsonData.characters;
debugLog('[RPG Parser] Characters:', jsonData.characters.length);
}
// 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);
extensionSettings.inventoryV3 = {
onPerson: normalizeArray(jsonData.inventory.onPerson || jsonData.inventory.items),
stored: jsonData.inventory.stored && typeof jsonData.inventory.stored === 'object'
? jsonData.inventory.stored : {},
assets: normalizeArray(jsonData.inventory.assets)
};
debugLog('[RPG Parser] Inventory parsed - onPerson:', extensionSettings.inventoryV3.onPerson.length);
// 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 => 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)
};
}
// 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] = [];
}
}
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';
}
}
// 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}} Parsed tracker data
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null, skills: string|null}} Parsed tracker data
*/
export function parseResponse(responseText) {
const result = {
userStats: null,
infoBox: null,
characterThoughts: null
characterThoughts: null,
skills: null
};
// DEBUG: Log full response for troubleshooting
@@ -213,7 +493,12 @@ export function parseResponse(responseText) {
(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(/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
// Match Info Box section - flexible patterns
const isInfoBox =
@@ -234,6 +519,9 @@ 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');
@@ -249,12 +537,14 @@ 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] =======================================================');
@@ -425,6 +715,125 @@ 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 = {};
}
// Get configured skill categories
const configuredCategories = extensionSettings.trackerConfig?.userStats?.skillsSection?.customFields || [];
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
+328 -59
View File
@@ -7,9 +7,11 @@ import { getContext } from '../../../../../../extensions.js';
import { chat, getCurrentChatDetails, characters, this_chid } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, getGroupChat } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js';
import { generateSchemaExample } from '../../types/trackerData.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
/** @typedef {import('../../types/trackerData.js').TrackerData} TrackerData */
/**
* Default HTML prompt text
@@ -17,11 +19,17 @@ import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../co
export const DEFAULT_HTML_PROMPT = `If appropriate, include inline HTML, CSS, and JS segments whenever they enhance visual storytelling (e.g., for in-world screens, posters, books, letters, signs, crests, labels, etc.). Style them to match the setting's theme (e.g., fantasy, sci-fi), keep the text readable, and embed all assets directly (using inline SVGs only with no external scripts, libraries, or fonts). Use these elements freely and naturally within the narrative as characters would encounter them, including animations, 3D effects, pop-ups, dropdowns, websites, and so on. Do not wrap the HTML/CSS/JS in code fences!`;
/**
* Default tracker instruction prompt text
* Default tracker instruction prompt text (legacy text format)
* Use {{user}} as placeholder for the user's name (will be replaced at runtime)
*/
export const DEFAULT_TRACKER_PROMPT = `At the start of every reply, you must attach an update to the trackers in EXACTLY the same format as below, enclosed in separate Markdown code fences. Replace X with actual numbers (e.g., 69) and replace all [placeholders] with concrete in-world details that {{user}} perceives about the current scene and the present characters. Do NOT keep the brackets or placeholder text in your response. For example: [Location] becomes Forest Clearing, [Mood Emoji] becomes 😊. Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user's actions, the passage of time, and logical consequences (0% if the time progressed only by a few minutes, 1-5% normally, and above 5% only if a major time-skip/event occurs).`;
/**
* Default JSON tracker instruction prompt text
* Use {{user}} as placeholder for the user's name (will be replaced at runtime)
*/
export const DEFAULT_JSON_TRACKER_PROMPT = `At the start of every reply, output a JSON object inside a markdown code fence (with \`\`\`json). This tracks {{user}}'s stats, inventory, skills, and scene information. Follow the exact schema shown below. Use concrete values - no placeholders or brackets. Update stats realistically based on actions and time (0% change for minutes, 1-5% normally, 5%+ only for major events). Items and skills have "name" and "description" fields. Items can grant skills via "grantsSkill", and skills show their source via "grantedBy".`;
/**
* Gets character card information for current chat (handles both single and group chats)
* @returns {string} Formatted character information
@@ -173,6 +181,9 @@ function buildAttributesString() {
}
/**
* @deprecated Use generateJSONTrackerInstructions instead. This legacy text format
* is kept for backwards compatibility with older LLM responses.
*
* Generates an example block showing current tracker states in markdown code blocks.
* Uses COMMITTED data (not displayed data) for generation context.
*
@@ -240,6 +251,9 @@ export function generateTrackerExample() {
}
/**
* @deprecated Use generateJSONTrackerInstructions instead. This legacy text format
* is kept for backwards compatibility with older LLM responses.
*
* Generates the instruction portion - format specifications and guidelines.
*
* @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt (true for main generation, false for separate tracker generation)
@@ -253,10 +267,10 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
const trackerConfig = extensionSettings.trackerConfig;
let instructions = '';
// Check if any trackers are enabled (including inventory and quests as independent sections)
// Check if any trackers are enabled (including inventory, skills and quests as independent sections)
const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox ||
extensionSettings.showCharacterThoughts || extensionSettings.showInventory ||
extensionSettings.showQuests;
extensionSettings.showCharacterThoughts || extensionSettings.showSkills ||
extensionSettings.showInventory || extensionSettings.showQuests;
// Only add tracker instructions if at least one tracker is enabled
if (hasAnyTrackers) {
@@ -295,8 +309,9 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
}
}
// Add skills section if enabled
if (userStatsConfig?.skillsSection?.enabled) {
// Add skills section if enabled in config AND NOT shown as separate section
// When showSkills is true, skills are in their own tab and have their own code block
if (userStatsConfig?.skillsSection?.enabled && !extensionSettings.showSkills) {
const skillFields = userStatsConfig.skillsSection.customFields || [];
const skillFieldsText = skillFields.map(f => `[${f}]`).join(', ');
instructions += `Skills: [${skillFieldsText || 'Skill1, Skill2, etc.'}]\n`;
@@ -329,6 +344,33 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
instructions += '```\n\n';
}
// Add separate skills section when showSkills is enabled
if (extensionSettings.showSkills) {
const skillsConfig = trackerConfig?.userStats?.skillsSection;
const skillFields = skillsConfig?.customFields || [];
if (skillFields.length > 0) {
instructions += '```\n';
instructions += 'Skills\n';
instructions += '---\n';
// Each skill category contains a list of abilities
for (const skillName of skillFields) {
if (extensionSettings.enableItemSkillLinks) {
instructions += `${skillName}: [Abilities in this category, e.g. "Sword Fighting (Iron Sword), Parry" or "None"]\n`;
} else {
instructions += `${skillName}: [Abilities in this category, e.g. "Lockpicking, Sneaking" or "None"]\n`;
}
}
if (extensionSettings.enableItemSkillLinks) {
instructions += '\n(Abilities from items use parentheses: "Skill (Item)". Remove if item is removed or unequipped.)\n';
}
instructions += '```\n\n';
}
}
if (extensionSettings.showInfoBox) {
const infoBoxConfig = trackerConfig?.infoBox;
const widgets = infoBoxConfig?.widgets || {};
@@ -459,6 +501,220 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
return instructions;
}
/**
* Generates JSON-based tracker instructions.
* Creates a prompt asking the LLM to output structured JSON data.
*
* @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt
* @param {boolean} includeContinuation - Whether to include continuation instruction
* @param {boolean} includeAttributes - Whether to include RPG attributes
* @returns {string} Formatted JSON instruction text for the AI
*/
export function generateJSONTrackerInstructions(includeHtmlPrompt = true, includeContinuation = true, includeAttributes = true) {
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig;
let instructions = '';
// Check which sections are enabled
const showStats = extensionSettings.showUserStats;
const showInfoBox = extensionSettings.showInfoBox;
const showCharacters = extensionSettings.showCharacterThoughts;
const showInventory = extensionSettings.showInventory;
const showSkills = extensionSettings.showSkills;
const showQuests = extensionSettings.showQuests;
const enableItemSkillLinks = extensionSettings.enableItemSkillLinks;
const hasAnyTrackers = showStats || showInfoBox || showCharacters || showInventory || showSkills || showQuests;
if (!hasAnyTrackers) {
return instructions;
}
// JSON instruction header
const jsonPrompt = (extensionSettings.customTrackerPrompt || DEFAULT_JSON_TRACKER_PROMPT).replace(/\{\{user\}\}/g, userName);
instructions += `\n${jsonPrompt}\n\n`;
// Build the JSON schema example based on enabled sections
instructions += '```json\n';
instructions += '{\n';
let sections = [];
// Stats section
if (showStats) {
const enabledStats = trackerConfig?.userStats?.customStats?.filter(s => s?.enabled && s?.name) || [];
if (enabledStats.length > 0) {
let statsJson = ' "stats": {\n';
statsJson += enabledStats.map(s => ` "${s.name}": 75`).join(',\n');
statsJson += '\n }';
sections.push(statsJson);
}
// Status section
const statusConfig = trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
let statusJson = ' "status": {\n';
const statusParts = [];
if (statusConfig.showMoodEmoji) {
statusParts.push(' "mood": "😊"');
}
const customFields = statusConfig.customFields || [];
if (customFields.length > 0) {
const fieldsJson = customFields.map(f => ` "${f}": "[${f} description]"`).join(',\n');
statusParts.push(` "fields": {\n${fieldsJson}\n }`);
}
statusJson += statusParts.join(',\n');
statusJson += '\n }';
sections.push(statusJson);
}
}
// Info Box section
if (showInfoBox) {
const widgets = trackerConfig?.infoBox?.widgets || {};
const infoParts = [];
if (widgets.date?.enabled) infoParts.push(' "date": "Monday, March 15, 1242"');
if (widgets.time?.enabled) infoParts.push(' "time": "14:00 → 15:30"');
if (widgets.weather?.enabled) infoParts.push(' "weather": "☀️ Sunny"');
if (widgets.temperature?.enabled) {
const unit = widgets.temperature.unit === 'F' ? '°F' : '°C';
infoParts.push(` "temperature": "22${unit}"`);
}
if (widgets.location?.enabled) infoParts.push(' "location": "Forest Clearing"');
if (widgets.recentEvents?.enabled) infoParts.push(' "recentEvents": "Brief summary of recent events"');
if (infoParts.length > 0) {
sections.push(' "infoBox": {\n' + infoParts.join(',\n') + '\n }');
}
}
// Characters section
if (showCharacters) {
const charConfig = trackerConfig?.presentCharacters || {};
let charExample = ' {\n "name": "Character Name"';
if (charConfig.relationshipFields?.length > 0) {
charExample += `,\n "relationship": "${charConfig.relationshipFields[0]}"`;
}
const enabledFields = charConfig.customFields?.filter(f => f.enabled) || [];
if (enabledFields.length > 0) {
const fieldsJson = enabledFields.map(f => ` "${f.name}": "[${f.description || f.name}]"`).join(',\n');
charExample += `,\n "fields": {\n${fieldsJson}\n }`;
}
if (charConfig.thoughts?.enabled) {
charExample += ',\n "thoughts": "Character\'s inner thoughts in first person..."';
}
charExample += '\n }';
sections.push(' "characters": [\n' + charExample + '\n ]');
}
// Inventory section
if (showInventory) {
let invSection = ' "inventory": {\n';
if (extensionSettings.useSimplifiedInventory) {
// Simplified: single list
let itemExample = '{ "name": "Item Name", "description": "What it is" }';
if (enableItemSkillLinks) {
itemExample = '{ "name": "Iron Sword", "description": "A sturdy blade", "grantsSkill": "Sword Fighting" }';
}
invSection += ` "items": [${itemExample}]\n`;
} else {
// Full categorized inventory
let itemExample = '{ "name": "Item", "description": "Description" }';
if (enableItemSkillLinks) {
itemExample = '{ "name": "Iron Sword", "description": "A sturdy blade", "grantsSkill": "Sword Fighting" }';
}
invSection += ` "onPerson": [${itemExample}],\n`;
invSection += ' "stored": { "Location Name": [{ "name": "Stored Item", "description": "Description" }] },\n';
invSection += ' "assets": [{ "name": "Property/Vehicle", "description": "Description" }]\n';
}
invSection += ' }';
sections.push(invSection);
}
// Skills section
if (showSkills) {
const skillCategories = trackerConfig?.userStats?.skillsSection?.customFields || [];
if (skillCategories.length > 0) {
let skillsSection = ' "skills": {\n';
const categoryExamples = skillCategories.map(cat => {
let skillExample = '{ "name": "Ability Name", "description": "What this ability does" }';
if (enableItemSkillLinks) {
skillExample = '{ "name": "Ability", "description": "Description", "grantedBy": "Item Name" }';
}
return ` "${cat}": [${skillExample}]`;
});
skillsSection += categoryExamples.join(',\n');
skillsSection += '\n }';
sections.push(skillsSection);
}
}
// Quests section
if (showQuests) {
let questsSection = ' "quests": {\n';
questsSection += ' "main": { "name": "Main Quest Title", "description": "Primary objective" },\n';
questsSection += ' "optional": [{ "name": "Side Quest", "description": "Optional objective" }]\n';
questsSection += ' }';
sections.push(questsSection);
}
instructions += sections.join(',\n');
instructions += '\n}\n```\n\n';
// Add notes about the format
instructions += 'Important:\n';
instructions += '- Output ONLY valid JSON inside the code fence\n';
instructions += '- Use actual values, not placeholders like [Location]\n';
instructions += '- Stats are percentages (0-100)\n';
instructions += '- Empty arrays [] for sections with no items\n';
instructions += '- null for main quest if none active\n';
if (enableItemSkillLinks) {
instructions += '- Items can grant skills: add "grantsSkill": "Skill Name" to the item\n';
instructions += '- Skills from items: add "grantedBy": "Item Name" to the skill\n';
instructions += '- If an item is removed/lost, remove its linked skill too\n';
}
instructions += '\n';
// Continuation instruction
if (includeContinuation) {
instructions += `After the JSON block, continue the story naturally from where the last message left off. The tracker data should reflect and influence the narrative - fatigue affects performance, mood colors dialogue, etc.\n\n`;
}
// Attributes
if (includeAttributes) {
const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes;
const shouldSendAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll;
if (shouldSendAttributes) {
const attributesString = buildAttributesString();
instructions += `${userName}'s attributes: ${attributesString}\n`;
if (extensionSettings.lastDiceRoll) {
const roll = extensionSettings.lastDiceRoll;
instructions += `${userName} rolled ${roll.total} on ${roll.formula}. Determine success/failure based on attributes.\n\n`;
} else {
instructions += '\n';
}
}
}
// HTML prompt
if (extensionSettings.enableHtmlPrompt && includeHtmlPrompt) {
const htmlPrompt = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
instructions += htmlPrompt;
}
return instructions;
}
/**
* Generates a formatted contextual summary for SEPARATE mode injection.
* Includes the full tracker data in original format (without code fences and separators).
@@ -561,75 +817,88 @@ export function generateContextualSummary() {
}
/**
* Generates the RPG tracking prompt text (for backward compatibility with separate mode).
* Uses COMMITTED data (not displayed data) for generation context.
* Generates the RPG tracking prompt text for separate mode.
* Shows previous data in JSON format and requests JSON response.
*
* @returns {string} Full prompt text for separate tracker generation
*/
export function generateRPGPromptText() {
// Use COMMITTED data for generation context, not displayed data
const userName = getContext().name1;
let promptText = '';
promptText += `Here are the previous trackers in the roleplay that you should consider when responding:\n`;
promptText += `Here are the previous trackers in JSON format that you should consider when responding:\n`;
promptText += `<previous>\n`;
// Build previous state as JSON
const previousState = {};
// Stats
if (extensionSettings.showUserStats) {
if (committedTrackerData.userStats) {
promptText += `Last ${userName}'s Stats:\n${committedTrackerData.userStats}\n\n`;
const customStats = extensionSettings.trackerConfig?.userStats?.customStats?.filter(s => s?.enabled) || [];
if (customStats.length > 0) {
previousState.stats = {};
for (const stat of customStats) {
previousState.stats[stat.name] = extensionSettings.userStats[stat.id] || 100;
}
}
// Status
const statusConfig = extensionSettings.trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
previousState.status = {
mood: extensionSettings.userStats.mood || '😐',
fields: {}
};
const customFields = statusConfig.customFields || [];
for (const field of customFields) {
previousState.status.fields[field] = extensionSettings.userStats.conditions || 'None';
}
}
}
// InfoBox
if (extensionSettings.showInfoBox && extensionSettings.infoBoxData) {
previousState.infoBox = extensionSettings.infoBoxData;
}
// Characters
if (extensionSettings.showCharacterThoughts && extensionSettings.charactersData?.length > 0) {
previousState.characters = extensionSettings.charactersData;
}
// Inventory
if (extensionSettings.showInventory) {
if (extensionSettings.inventoryV3 && (extensionSettings.inventoryV3.onPerson?.length > 0 ||
Object.keys(extensionSettings.inventoryV3.stored || {}).length > 0 ||
extensionSettings.inventoryV3.assets?.length > 0)) {
previousState.inventory = extensionSettings.inventoryV3;
}
}
// Skills
if (extensionSettings.showSkills && extensionSettings.skillsV2) {
previousState.skills = extensionSettings.skillsV2;
}
// Quests
if (extensionSettings.showQuests && extensionSettings.questsV2) {
previousState.quests = extensionSettings.questsV2;
}
// Output as JSON if we have any data, otherwise indicate first update
if (Object.keys(previousState).length > 0) {
promptText += '```json\n';
promptText += JSON.stringify(previousState, null, 2);
promptText += '\n```\n';
} else {
promptText += `Last ${userName}'s Stats:\nNone - this is the first update.\n\n`;
}
// Add current skills to the previous data context
const skillsSection = extensionSettings.trackerConfig?.userStats?.skillsSection;
if (skillsSection?.enabled && skillsSection.customFields && skillsSection.customFields.length > 0) {
const skillsList = skillsSection.customFields.join(', ');
promptText += `Skills: ${skillsList}\n\n`;
}
}
// Add current inventory to the previous data context - independent of showUserStats
if (extensionSettings.showInventory && extensionSettings.userStats?.inventory) {
const inventorySummary = buildInventorySummary(extensionSettings.userStats.inventory);
if (inventorySummary && inventorySummary !== 'None') {
promptText += `Last Inventory:\n${inventorySummary}\n\n`;
}
}
// Add current quests to the previous data context - independent of showUserStats
if (extensionSettings.showQuests && extensionSettings.quests) {
if (extensionSettings.quests.main && extensionSettings.quests.main !== 'None') {
promptText += `Main Quests: ${extensionSettings.quests.main}\n`;
}
if (extensionSettings.quests.optional && extensionSettings.quests.optional.length > 0) {
const optionalQuests = extensionSettings.quests.optional.filter(q => q && q !== 'None').join(', ');
promptText += `Optional Quests: ${optionalQuests || 'None'}\n`;
}
promptText += `\n`;
}
if (extensionSettings.showInfoBox) {
if (committedTrackerData.infoBox) {
promptText += `Last Info Box:\n${committedTrackerData.infoBox}\n\n`;
} else {
promptText += `Last Info Box:\nNone - this is the first update.\n\n`;
}
}
if (extensionSettings.showCharacterThoughts) {
if (committedTrackerData.characterThoughts) {
promptText += `Last Present Characters:\n${committedTrackerData.characterThoughts}\n`;
} else {
promptText += `Last Present Characters:\nNone - this is the first update.\n`;
}
promptText += 'None - this is the first update.\n';
}
promptText += `</previous>\n`;
// Don't include HTML prompt, continuation instruction, or attributes for separate tracker generation
promptText += generateTrackerInstructions(false, false, false);
// Add JSON format instructions
promptText += generateJSONTrackerInstructions(false, false, false);
return promptText;
}
+24 -1
View File
@@ -21,13 +21,14 @@ import {
import { saveChatData, loadChatData } from '../../core/persistence.js';
// Generation & Parsing
import { parseResponse, parseUserStats } from '../generation/parser.js';
import { parseResponse, parseUserStats, parseSkills, tryParseJSONResponse } from '../generation/parser.js';
import { updateRPGData } from '../generation/apiClient.js';
// Rendering
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderSkills } from '../rendering/skills.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderQuests } from '../rendering/quests.js';
@@ -114,6 +115,25 @@ export async function onMessageReceived(data) {
const responseText = lastMessage.mes;
// console.log('[RPG Companion] Parsing together mode response:', responseText);
// Try JSON parsing first if structured data mode is enabled
const jsonParsed = tryParseJSONResponse(responseText);
if (jsonParsed) {
console.log('[RPG Companion] JSON parsing successful in together mode');
// JSON data is already applied to extensionSettings by the parser
// Just need to render and save
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
if (typeof renderSkills === 'function') renderSkills();
saveChatData();
return; // Skip legacy text parsing
}
// JSON parsing failed - fall back to legacy text-based parsing
console.warn('[RPG Companion] JSON parsing failed in together mode, attempting legacy text parsing...');
const parsedData = parseResponse(responseText);
// console.log('[RPG Companion] Parsed data:', parsedData);
@@ -122,6 +142,9 @@ export async function onMessageReceived(data) {
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
}
if (parsedData.skills) {
parseSkills(parsedData.skills);
}
if (parsedData.infoBox) {
lastGeneratedData.infoBox = parsedData.infoBox;
}
+79 -13
View File
@@ -10,6 +10,7 @@ import { buildUserStatsText } from '../rendering/userStats.js';
import { renderInventory, getLocationId } from '../rendering/inventory.js';
import { parseItems, serializeItems } from '../../utils/itemParser.js';
import { sanitizeLocationName, sanitizeItemName } from '../../utils/security.js';
import { handleItemRemoved, navigateToLinkedSkills } from '../rendering/skills.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -134,15 +135,20 @@ export function hideAddItemForm(field, location) {
export function saveAddItem(field, location) {
const inventory = extensionSettings.userStats.inventory;
let inputId;
let descInputId;
if (field === 'stored') {
inputId = `.rpg-location-item-input[data-location="${location}"]`;
descInputId = `.rpg-location-item-desc-input[data-location="${location}"]`;
} else {
inputId = `#rpg-new-item-${field}`;
descInputId = `#rpg-new-item-desc-${field}`;
}
const input = $(inputId);
const descInput = $(descInputId);
const rawItemName = input.val().trim();
const itemDescription = descInput?.val()?.trim() || '';
if (!rawItemName) {
hideAddItemForm(field, location);
@@ -157,10 +163,39 @@ export function saveAddItem(field, location) {
return;
}
// Get current items, add new one, serialize back
// Update structured inventory (inventoryV3) if it exists
if (extensionSettings.inventoryV3) {
const newItem = { name: itemName, description: itemDescription };
if (field === 'simplified') {
if (!extensionSettings.inventoryV3.simplified) {
extensionSettings.inventoryV3.simplified = [];
}
extensionSettings.inventoryV3.simplified.push(newItem);
} else if (field === 'stored') {
if (!extensionSettings.inventoryV3.stored) {
extensionSettings.inventoryV3.stored = {};
}
if (!extensionSettings.inventoryV3.stored[location]) {
extensionSettings.inventoryV3.stored[location] = [];
}
extensionSettings.inventoryV3.stored[location].push(newItem);
} else if (field === 'onPerson') {
if (!extensionSettings.inventoryV3.onPerson) {
extensionSettings.inventoryV3.onPerson = [];
}
extensionSettings.inventoryV3.onPerson.push(newItem);
} else if (field === 'assets') {
if (!extensionSettings.inventoryV3.assets) {
extensionSettings.inventoryV3.assets = [];
}
extensionSettings.inventoryV3.assets.push(newItem);
}
}
// Also update legacy inventory format for backwards compatibility
let currentString;
if (field === 'simplified') {
// For simplified inventory, use items field or fall back to onPerson
currentString = inventory.items || inventory.onPerson || 'None';
} else if (field === 'stored') {
currentString = inventory.stored[location] || 'None';
@@ -174,7 +209,6 @@ export function saveAddItem(field, location) {
// Save back to inventory
if (field === 'simplified') {
// Update both items and onPerson for simplified mode
inventory.items = newString;
inventory.onPerson = newString;
} else if (field === 'stored') {
@@ -201,13 +235,31 @@ export function saveAddItem(field, location) {
*/
export function removeItem(field, itemIndex, location) {
const inventory = extensionSettings.userStats.inventory;
let removedItemName = null;
// console.log('[RPG Companion] DEBUG removeItem called:', { field, itemIndex, location });
// Remove from structured inventory (inventoryV3) if it exists
if (extensionSettings.inventoryV3) {
let structuredArray = null;
// Get current items, remove the one at index, serialize back
if (field === 'simplified' && extensionSettings.inventoryV3.simplified) {
structuredArray = extensionSettings.inventoryV3.simplified;
} else if (field === 'stored' && extensionSettings.inventoryV3.stored?.[location]) {
structuredArray = extensionSettings.inventoryV3.stored[location];
} else if (field === 'onPerson' && extensionSettings.inventoryV3.onPerson) {
structuredArray = extensionSettings.inventoryV3.onPerson;
} else if (field === 'assets' && extensionSettings.inventoryV3.assets) {
structuredArray = extensionSettings.inventoryV3.assets;
}
if (structuredArray && structuredArray[itemIndex]) {
removedItemName = structuredArray[itemIndex].name;
structuredArray.splice(itemIndex, 1);
}
}
// Get current items from legacy format
let currentString;
if (field === 'simplified') {
// For simplified inventory, use items field or fall back to onPerson
currentString = inventory.items || inventory.onPerson || 'None';
} else if (field === 'stored') {
currentString = inventory.stored[location] || 'None';
@@ -215,20 +267,24 @@ export function removeItem(field, itemIndex, location) {
currentString = inventory[field] || 'None';
}
// console.log('[RPG Companion] DEBUG currentString before removal:', currentString);
const items = parseItems(currentString);
// console.log('[RPG Companion] DEBUG items array before removal:', items);
// Get the item name before removing (for item-skill link check) - use structured name if available
if (!removedItemName) {
removedItemName = items[itemIndex];
}
items.splice(itemIndex, 1); // Remove item at index
// console.log('[RPG Companion] DEBUG items array after removal:', items);
// Check if this item was linked to a skill and handle removal
if (removedItemName && extensionSettings.enableItemSkillLinks) {
handleItemRemoved(removedItemName);
}
const newString = serializeItems(items);
// console.log('[RPG Companion] DEBUG newString after removal:', newString);
// Save back to inventory
// Save back to legacy inventory
if (field === 'simplified') {
// Update both items and onPerson for simplified mode
inventory.items = newString;
inventory.onPerson = newString;
} else if (field === 'stored') {
@@ -487,6 +543,16 @@ export function initInventoryEventListeners() {
removeItem(field, itemIndex, location);
});
// Go to linked skills button (on inventory items)
$(document).on('click', '.rpg-item-skill-link[data-action="goto-linked-skills"]', function(e) {
e.preventDefault();
e.stopPropagation();
const itemName = $(this).data('item');
if (itemName) {
navigateToLinkedSkills(itemName);
}
});
// Add location button - shows inline form
$(document).on('click', '.rpg-inventory-add-btn[data-action="add-location"]', function(e) {
e.preventDefault();
+63 -4
View File
@@ -31,10 +31,36 @@ export function updateInventoryItem(field, index, newName, location) {
return;
}
// Get current items for the field
// Get the OLD item name before updating (for skill link updates)
let oldItemName = null;
if (extensionSettings.inventoryV3) {
let structuredArray = null;
if (field === 'simplified' && extensionSettings.inventoryV3.simplified) {
structuredArray = extensionSettings.inventoryV3.simplified;
} else if (field === 'stored' && extensionSettings.inventoryV3.stored?.[location]) {
structuredArray = extensionSettings.inventoryV3.stored[location];
} else if (field === 'onPerson' && extensionSettings.inventoryV3.onPerson) {
structuredArray = extensionSettings.inventoryV3.onPerson;
} else if (field === 'assets' && extensionSettings.inventoryV3.assets) {
structuredArray = extensionSettings.inventoryV3.assets;
}
if (structuredArray && structuredArray[index]) {
const item = structuredArray[index];
oldItemName = typeof item === 'string' ? item : item.name;
// Update the structured item
if (typeof item === 'object') {
item.name = sanitizedName;
} else {
structuredArray[index] = sanitizedName;
}
}
}
// Get current items for the legacy format
let currentString;
if (field === 'simplified') {
// For simplified inventory, use items field or fall back to onPerson
currentString = inventory.items || inventory.onPerson || 'None';
} else if (field === 'stored') {
if (!location) {
@@ -55,15 +81,19 @@ export function updateInventoryItem(field, index, newName, location) {
return;
}
// Get old name from legacy format if not found in structured format
if (!oldItemName) {
oldItemName = items[index];
}
// Update the item at this index
items[index] = sanitizedName;
// Serialize back to string
const newItemString = serializeItems(items);
// Update the inventory
// Update the legacy inventory
if (field === 'simplified') {
// Update both items and onPerson for simplified mode
inventory.items = newItemString;
inventory.onPerson = newItemString;
} else if (field === 'stored') {
@@ -72,6 +102,11 @@ export function updateInventoryItem(field, index, newName, location) {
inventory[field] = newItemString;
}
// Update skill links if the item name changed
if (oldItemName && oldItemName !== sanitizedName && extensionSettings.skillAbilityLinks) {
updateSkillLinksForRenamedItem(oldItemName, sanitizedName);
}
// Update lastGeneratedData and committedTrackerData with new inventory
updateLastGeneratedDataInventory();
@@ -84,6 +119,30 @@ export function updateInventoryItem(field, index, newName, location) {
renderInventory();
}
/**
* Updates skill-ability links when an inventory item is renamed
* @param {string} oldName - The old item name
* @param {string} newName - The new item name
*/
function updateSkillLinksForRenamedItem(oldName, newName) {
if (!extensionSettings.skillAbilityLinks) return;
const oldNameLower = oldName.toLowerCase().trim();
let updated = false;
for (const [key, linkedItem] of Object.entries(extensionSettings.skillAbilityLinks)) {
// Case-insensitive comparison to match the linking logic
if (linkedItem && linkedItem.toLowerCase().trim() === oldNameLower) {
extensionSettings.skillAbilityLinks[key] = newName;
updated = true;
}
}
if (updated) {
console.log(`[RPG Companion] Updated skill links: "${oldName}" -> "${newName}"`);
}
}
/**
* Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include
* current inventory in text format.
+234 -4
View File
@@ -51,6 +51,175 @@ function separateEmojiFromText(str) {
return { emoji: '', text: str };
}
/**
* Checks if a value is valid (not null, undefined, or the string "null")
*/
function isValidValue(val) {
return val !== null && val !== undefined && val !== 'null' && val !== '';
}
/**
* Checks if we have valid structured infoBox data
* @param {Object} data - The infoBoxData object
* @returns {boolean}
*/
function hasStructuredInfoBoxData(data) {
if (!data) return false;
// Handle recentEvents as either string or array
const hasEvents = data.recentEvents && (
(Array.isArray(data.recentEvents) && data.recentEvents.length > 0) ||
(typeof data.recentEvents === 'string' && data.recentEvents.length > 0 && data.recentEvents !== 'null')
);
return isValidValue(data.date) || isValidValue(data.weather) || isValidValue(data.temperature) ||
isValidValue(data.time) || isValidValue(data.location) || hasEvents;
}
/**
* Renders the info box using structured JSON data
* @param {Object} data - Structured infoBox data
*/
function renderStructuredInfoBox(data) {
const config = extensionSettings.trackerConfig?.infoBox;
const widgets = config?.widgets || {};
// Build widgets HTML
let widgetsHtml = '';
let widgetCount = 0;
// Date widget - skip null values
if (widgets.date?.enabled && isValidValue(data.date)) {
widgetCount++;
widgetsHtml += `
<div class="rpg-dashboard-widget rpg-calendar-widget">
<i class="fa-solid fa-calendar"></i>
<div class="rpg-widget-content">
<span class="rpg-widget-label" data-i18n-key="infobox.date">${i18n.getTranslation('infobox.date')}</span>
<span class="rpg-widget-value rpg-editable" contenteditable="true" data-field="date">${data.date}</span>
</div>
</div>`;
}
// Weather widget - skip null values
if (widgets.weather?.enabled && isValidValue(data.weather)) {
widgetCount++;
const { emoji, text } = separateEmojiFromText(data.weather);
widgetsHtml += `
<div class="rpg-dashboard-widget rpg-weather-widget">
<span class="rpg-weather-emoji rpg-editable" contenteditable="true" data-field="weatherEmoji">${emoji || '🌤️'}</span>
<div class="rpg-widget-content">
<span class="rpg-widget-label" data-i18n-key="infobox.weather">${i18n.getTranslation('infobox.weather')}</span>
<span class="rpg-widget-value rpg-editable" contenteditable="true" data-field="weather">${text}</span>
</div>
</div>`;
}
// Temperature widget - skip null values
if (widgets.temperature?.enabled && isValidValue(data.temperature)) {
widgetCount++;
widgetsHtml += `
<div class="rpg-dashboard-widget rpg-temperature-widget">
<i class="fa-solid fa-temperature-half"></i>
<div class="rpg-widget-content">
<span class="rpg-widget-label" data-i18n-key="infobox.temperature">${i18n.getTranslation('infobox.temperature')}</span>
<span class="rpg-widget-value rpg-editable" contenteditable="true" data-field="temperature">${data.temperature}</span>
</div>
</div>`;
}
// Time widget - skip null values
if (widgets.time?.enabled && isValidValue(data.time)) {
widgetCount++;
widgetsHtml += `
<div class="rpg-dashboard-widget rpg-time-widget">
<i class="fa-solid fa-clock"></i>
<div class="rpg-widget-content">
<span class="rpg-widget-label" data-i18n-key="infobox.time">${i18n.getTranslation('infobox.time')}</span>
<span class="rpg-widget-value rpg-editable" contenteditable="true" data-field="time">${data.time}</span>
</div>
</div>`;
}
// Location widget - skip null values
if (widgets.location?.enabled && isValidValue(data.location)) {
widgetCount++;
widgetsHtml += `
<div class="rpg-dashboard-widget rpg-location-widget">
<i class="fa-solid fa-map-marker-alt"></i>
<div class="rpg-widget-content">
<span class="rpg-widget-label" data-i18n-key="infobox.location">${i18n.getTranslation('infobox.location')}</span>
<span class="rpg-widget-value rpg-editable" contenteditable="true" data-field="location">${data.location}</span>
</div>
</div>`;
}
// Recent events widget - handle both string and array formats
const recentEvents = Array.isArray(data.recentEvents)
? data.recentEvents
: (data.recentEvents ? [data.recentEvents] : []);
if (widgets.recentEvents?.enabled && recentEvents.length > 0) {
widgetCount++;
const eventsHtml = recentEvents.map((event, i) =>
`<li class="rpg-event-item rpg-editable" contenteditable="true" data-field="recentEvents" data-index="${i}">${event}</li>`
).join('');
widgetsHtml += `
<div class="rpg-dashboard-widget rpg-events-widget rpg-widget-wide">
<i class="fa-solid fa-scroll"></i>
<div class="rpg-widget-content">
<span class="rpg-widget-label" data-i18n-key="infobox.recentEvents">${i18n.getTranslation('infobox.recentEvents')}</span>
<ul class="rpg-events-list">${eventsHtml}</ul>
</div>
</div>`;
}
// Determine layout class based on widget count
const layoutClass = widgetCount <= 2 ? 'rpg-dashboard-row-1' :
widgetCount <= 4 ? 'rpg-dashboard-row-2' : 'rpg-dashboard-row-3';
const html = `<div class="rpg-dashboard ${layoutClass}">${widgetsHtml}</div>`;
$infoBoxContainer.html(html);
// Remove updating animation
if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 300);
}
// Setup event listeners for editable fields
setupStructuredInfoBoxEventListeners();
}
/**
* Setup event listeners for structured info box editing
*/
function setupStructuredInfoBoxEventListeners() {
$infoBoxContainer.off('blur', '.rpg-editable').on('blur', '.rpg-editable', function() {
const $this = $(this);
const field = $this.data('field');
const index = $this.data('index');
const newValue = $this.text().trim();
if (!extensionSettings.infoBoxData) {
extensionSettings.infoBoxData = {};
}
if (field === 'recentEvents' && index !== undefined) {
if (!extensionSettings.infoBoxData.recentEvents) {
extensionSettings.infoBoxData.recentEvents = [];
}
extensionSettings.infoBoxData.recentEvents[index] = newValue;
} else if (field === 'weatherEmoji') {
// Combine emoji with existing weather text
const currentWeather = extensionSettings.infoBoxData.weather || '';
const { text } = separateEmojiFromText(currentWeather);
extensionSettings.infoBoxData.weather = newValue + ' ' + text;
} else {
extensionSettings.infoBoxData[field] = newValue;
}
saveChatData();
});
}
/**
* Renders the info box as a visual dashboard with calendar, weather, temperature, clock, and map widgets.
* Includes event listeners for editable fields.
@@ -65,8 +234,28 @@ export function renderInfoBox() {
$infoBoxContainer.addClass('rpg-content-updating');
}
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
// Convert structured JSON data to text format for the original fancy renderer
const structuredData = extensionSettings.infoBoxData;
let infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
// If we have structured data, convert it to text format
if (structuredData && hasStructuredInfoBoxData(structuredData)) {
const lines = [];
if (isValidValue(structuredData.date)) lines.push(`Date: ${structuredData.date}`);
if (isValidValue(structuredData.time)) lines.push(`Time: ${structuredData.time}`);
if (isValidValue(structuredData.weather)) lines.push(`Weather: ${structuredData.weather}`);
if (isValidValue(structuredData.temperature)) lines.push(`Temperature: ${structuredData.temperature}`);
if (isValidValue(structuredData.location)) lines.push(`Location: ${structuredData.location}`);
if (structuredData.recentEvents) {
const events = Array.isArray(structuredData.recentEvents)
? structuredData.recentEvents
: [structuredData.recentEvents];
events.filter(e => e && e !== 'null').forEach(e => lines.push(`Recent Events: ${e}`));
}
if (lines.length > 0) {
infoBoxData = lines.join('\n');
}
}
// If no data yet, show placeholder
if (!infoBoxData) {
@@ -117,12 +306,16 @@ export function renderInfoBox() {
for (const line of lines) {
// console.log('[RPG Companion] Processing line:', line);
// Helper to check if a value is valid (not null/empty)
const isValidParsedValue = (val) => val && val !== 'null' && val !== 'undefined' && val.toLowerCase() !== 'none';
// Support both new text format (Date:) and legacy emoji format (🗓️:)
// Prioritize text format over emoji format
if (line.startsWith('Date:')) {
if (!parsedFields.date) {
// console.log('[RPG Companion] → Matched DATE (text format)');
const dateStr = line.replace('Date:', '').trim();
if (isValidParsedValue(dateStr)) {
const dateParts = dateStr.split(',').map(p => p.trim());
data.weekday = dateParts[0] || '';
data.month = dateParts[1] || '';
@@ -130,10 +323,12 @@ export function renderInfoBox() {
data.date = dateStr;
parsedFields.date = true;
}
}
} else if (line.includes('🗓️:')) {
if (!parsedFields.date) {
// console.log('[RPG Companion] → Matched DATE (emoji format)');
const dateStr = line.replace('🗓️:', '').trim();
if (isValidParsedValue(dateStr)) {
const dateParts = dateStr.split(',').map(p => p.trim());
data.weekday = dateParts[0] || '';
data.month = dateParts[1] || '';
@@ -141,10 +336,12 @@ export function renderInfoBox() {
data.date = dateStr;
parsedFields.date = true;
}
}
} else if (line.startsWith('Temperature:')) {
if (!parsedFields.temperature) {
// console.log('[RPG Companion] → Matched TEMPERATURE (text format)');
const tempStr = line.replace('Temperature:', '').trim();
if (isValidParsedValue(tempStr)) {
data.temperature = tempStr;
const tempMatch = tempStr.match(/(-?\d+)/);
if (tempMatch) {
@@ -152,10 +349,12 @@ export function renderInfoBox() {
}
parsedFields.temperature = true;
}
}
} else if (line.includes('🌡️:')) {
if (!parsedFields.temperature) {
// console.log('[RPG Companion] → Matched TEMPERATURE (emoji format)');
const tempStr = line.replace('🌡️:', '').trim();
if (isValidParsedValue(tempStr)) {
data.temperature = tempStr;
const tempMatch = tempStr.match(/(-?\d+)/);
if (tempMatch) {
@@ -163,42 +362,58 @@ export function renderInfoBox() {
}
parsedFields.temperature = true;
}
}
} else if (line.startsWith('Time:')) {
if (!parsedFields.time) {
// console.log('[RPG Companion] → Matched TIME (text format)');
const timeStr = line.replace('Time:', '').trim();
if (isValidParsedValue(timeStr)) {
data.time = timeStr;
const timeParts = timeStr.split('→').map(t => t.trim());
data.timeStart = timeParts[0] || '';
data.timeEnd = timeParts[1] || '';
parsedFields.time = true;
}
}
} else if (line.includes('🕒:')) {
if (!parsedFields.time) {
// console.log('[RPG Companion] → Matched TIME (emoji format)');
const timeStr = line.replace('🕒:', '').trim();
if (isValidParsedValue(timeStr)) {
data.time = timeStr;
const timeParts = timeStr.split('→').map(t => t.trim());
data.timeStart = timeParts[0] || '';
data.timeEnd = timeParts[1] || '';
parsedFields.time = true;
}
}
} else if (line.startsWith('Location:')) {
if (!parsedFields.location) {
// console.log('[RPG Companion] → Matched LOCATION (text format)');
data.location = line.replace('Location:', '').trim();
const locStr = line.replace('Location:', '').trim();
if (isValidParsedValue(locStr)) {
data.location = locStr;
parsedFields.location = true;
}
}
} else if (line.includes('🗺️:')) {
if (!parsedFields.location) {
// console.log('[RPG Companion] → Matched LOCATION (emoji format)');
data.location = line.replace('🗺️:', '').trim();
const locStr = line.replace('🗺️:', '').trim();
if (isValidParsedValue(locStr)) {
data.location = locStr;
parsedFields.location = true;
}
}
} else if (line.startsWith('Weather:')) {
if (!parsedFields.weather) {
// New text format: Weather: [Emoji], [Forecast] OR Weather: [Emoji][Forecast] (no separator - FIXED)
const weatherStr = line.replace('Weather:', '').trim();
// Skip null/invalid values
if (!isValidParsedValue(weatherStr)) {
parsedFields.weather = true; // Mark as parsed so we don't try again
} else {
const { emoji, text } = separateEmojiFromText(weatherStr);
if (emoji && text) {
@@ -217,6 +432,7 @@ export function renderInfoBox() {
parsedFields.weather = true;
}
}
} else {
// Check if it's a legacy weather line (emoji format)
// Only parse if we haven't already found weather in text format
@@ -271,6 +487,20 @@ export function renderInfoBox() {
// location: data.location
// });
// Sanitize parsed values - filter out "null" strings and invalid values
const sanitize = (val) => (val && val !== 'null' && val !== 'undefined' && val.toLowerCase() !== 'none') ? val : '';
data.date = sanitize(data.date);
data.weekday = sanitize(data.weekday);
data.month = sanitize(data.month);
data.year = sanitize(data.year);
data.weatherEmoji = sanitize(data.weatherEmoji);
data.weatherForecast = sanitize(data.weatherForecast);
data.temperature = sanitize(data.temperature);
data.time = sanitize(data.time);
data.timeStart = sanitize(data.timeStart);
data.timeEnd = sanitize(data.timeEnd);
data.location = sanitize(data.location);
// Get tracker configuration
const config = extensionSettings.trackerConfig?.infoBox;
+342 -29
View File
@@ -8,6 +8,7 @@ import { getInventoryRenderOptions, restoreFormStates } from '../interaction/inv
import { updateInventoryItem } from '../interaction/inventoryEdit.js';
import { parseItems } from '../../utils/itemParser.js';
import { i18n } from '../../core/i18n.js';
import { itemHasLinkedSkills, navigateToLinkedSkills } from './skills.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -23,6 +24,23 @@ export function getLocationId(locationName) {
return locationName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-');
}
/**
* Generates the skill link indicator for an inventory item
* @param {string} itemName - The item name
* @returns {string} HTML string for the link indicator (empty if no links)
*/
function getSkillLinkIndicator(itemName) {
if (!extensionSettings.enableItemSkillLinks || !extensionSettings.showSkills) {
return '';
}
if (itemHasLinkedSkills(itemName)) {
return `<button class="rpg-item-skill-link" data-action="goto-linked-skills" data-item="${escapeHtml(itemName)}" title="${i18n.getTranslation('inventory.gotoLinkedSkills')}">
<i class="fa-solid fa-star"></i>
</button>`;
}
return '';
}
/**
* Renders the inventory sub-tab navigation (On Person, Stored, Assets)
* @param {string} activeTab - Currently active sub-tab ('onPerson', 'stored', 'assets')
@@ -44,6 +62,31 @@ export function renderInventorySubTabs(activeTab = 'onPerson') {
`;
}
/**
* Gets the description for an item from structured inventory data
* @param {string} field - Field type ('onPerson', 'stored', 'assets')
* @param {number} index - Item index
* @param {string} [location] - Location name for stored items
* @returns {string} Item description or empty string
*/
function getItemDescription(field, index, location = null) {
const inv3 = extensionSettings.inventoryV3;
if (!inv3) return '';
let items;
if (field === 'onPerson') {
items = inv3.onPerson;
} else if (field === 'assets') {
items = inv3.assets;
} else if (field === 'stored' && location) {
items = inv3.stored?.[location];
}
if (!items || !Array.isArray(items) || !items[index]) return '';
const item = items[index];
return (typeof item === 'object' ? item.description : '') || '';
}
/**
* Renders the "On Person" inventory view with list or grid display
* @param {string} onPersonItems - Current on-person items (comma-separated string)
@@ -59,24 +102,38 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
<div class="rpg-item-card" data-field="onPerson" data-index="${index}">
itemsHtml = items.map((item, index) => {
const desc = getItemDescription('onPerson', index);
return `
<div class="rpg-item-card ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="onPerson" data-index="${index}">
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
<div class="rpg-item-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(desc)}</span>
</div>
`).join('');
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
<div class="rpg-item-row" data-field="onPerson" data-index="${index}">
itemsHtml = items.map((item, index) => {
const desc = getItemDescription('onPerson', index);
return `
<div class="rpg-item-row ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="onPerson" data-index="${index}">
<div class="rpg-item-main-row">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
<div class="rpg-item-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(desc)}</span>
</div>
</div>
`}).join('');
}
}
@@ -181,24 +238,38 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
<div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
itemsHtml = items.map((item, index) => {
const desc = getItemDescription('stored', index, location);
return `
<div class="rpg-item-card ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
<div class="rpg-item-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(desc)}</span>
</div>
`).join('');
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
itemsHtml = items.map((item, index) => {
const desc = getItemDescription('stored', index, location);
return `
<div class="rpg-item-row ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
<div class="rpg-item-main-row">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
<div class="rpg-item-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(desc)}</span>
</div>
</div>
`}).join('');
}
}
@@ -277,24 +348,38 @@ export function renderAssetsView(assets, viewMode = 'list') {
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
<div class="rpg-item-card" data-field="assets" data-index="${index}">
itemsHtml = items.map((item, index) => {
const desc = getItemDescription('assets', index);
return `
<div class="rpg-item-card ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="assets" data-index="${index}">
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
<div class="rpg-item-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(desc)}</span>
</div>
`).join('');
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
<div class="rpg-item-row" data-field="assets" data-index="${index}">
itemsHtml = items.map((item, index) => {
const desc = getItemDescription('assets', index);
return `
<div class="rpg-item-row ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="assets" data-index="${index}">
<div class="rpg-item-main-row">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="${i18n.getTranslation('inventory.assets.removeAssetTitle')}">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
<div class="rpg-item-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(desc)}</span>
</div>
</div>
`}).join('');
}
}
@@ -461,18 +546,20 @@ export function renderSimplifiedInventoryView(itemsString, viewMode = 'list') {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
<div class="rpg-item-card" data-field="simplified" data-index="${index}">
<div class="rpg-item-card ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="simplified" data-index="${index}">
<button class="rpg-item-remove" data-action="remove-item" data-field="simplified" data-index="${index}" title="${i18n.getTranslation('inventory.simplified.removeTitle')}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="simplified" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
</div>
`).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
<div class="rpg-item-row" data-field="simplified" data-index="${index}">
<div class="rpg-item-row ${itemHasLinkedSkills(item) ? 'rpg-has-skill-link' : ''}" data-field="simplified" data-index="${index}">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="simplified" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
${getSkillLinkIndicator(item)}
<button class="rpg-item-remove" data-action="remove-item" data-field="simplified" data-index="${index}" title="${i18n.getTranslation('inventory.simplified.removeTitle')}">
<i class="fa-solid fa-times"></i>
</button>
@@ -523,6 +610,182 @@ export function renderSimplifiedInventoryView(itemsString, viewMode = 'list') {
`;
}
/**
* Renders a single structured item (with name + description)
* @param {Object} item - Item object with name, description, grantsSkill
* @param {string} field - Field type ('onPerson', 'stored', 'assets')
* @param {number} index - Item index
* @param {string} viewMode - 'list' or 'grid'
* @param {string} [location] - Location name for stored items
* @returns {string} HTML for the item
*/
function renderStructuredItem(item, field, index, viewMode, location = null) {
// Normalize item - handle both string and object formats
const normalizedItem = typeof item === 'string'
? { name: item, description: '' }
: { name: item?.name || 'Unknown', description: item?.description || '', grantsSkill: item?.grantsSkill };
const hasSkillLink = normalizedItem.grantsSkill || itemHasLinkedSkills(normalizedItem.name);
const skillLinkHtml = hasSkillLink ? getSkillLinkIndicator(normalizedItem.name) : '';
const grantsBadge = normalizedItem.grantsSkill
? `<span class="rpg-item-grants-badge" title="Grants: ${escapeHtml(normalizedItem.grantsSkill)}"><i class="fa-solid fa-star"></i></span>`
: '';
const locationAttr = location ? `data-location="${escapeHtml(location)}"` : '';
if (viewMode === 'grid') {
return `
<div class="rpg-item-card ${hasSkillLink ? 'rpg-has-skill-link' : ''}" data-field="${field}" data-index="${index}" ${locationAttr}>
<button class="rpg-item-remove" data-action="remove-item" data-field="${field}" data-index="${index}" ${locationAttr} title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
<div class="rpg-item-content">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="${field}" data-index="${index}" ${locationAttr} title="Click to edit name">${escapeHtml(normalizedItem.name)}</span>
${grantsBadge}
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="${field}" data-index="${index}" data-prop="description" ${locationAttr} title="Click to edit description">${escapeHtml(normalizedItem.description)}</span>
</div>
${skillLinkHtml}
</div>
`;
} else {
return `
<div class="rpg-item-row ${hasSkillLink ? 'rpg-has-skill-link' : ''}" data-field="${field}" data-index="${index}" ${locationAttr}>
<div class="rpg-item-info">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="${field}" data-index="${index}" ${locationAttr} title="Click to edit name">${escapeHtml(normalizedItem.name)}</span>
${grantsBadge}
<span class="rpg-item-description rpg-editable" contenteditable="true" data-field="${field}" data-index="${index}" data-prop="description" ${locationAttr} title="Click to edit description">${escapeHtml(normalizedItem.description)}</span>
</div>
${skillLinkHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="${field}" data-index="${index}" ${locationAttr} title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
</div>
`;
}
}
/**
* Renders structured inventory (v3 format with name + description)
* @param {Object} inventoryV3 - Structured inventory data
* @param {Object} options - Render options
* @returns {string} HTML
*/
function renderStructuredInventory(inventoryV3, options) {
const { activeTab = 'onPerson' } = options;
const viewModes = extensionSettings.inventoryViewModes || {};
// Sub-tabs
let html = renderInventorySubTabs(activeTab);
// On Person tab
const onPersonMode = viewModes.onPerson || 'list';
const onPersonItems = inventoryV3.onPerson || [];
let onPersonHtml = onPersonItems.length === 0
? `<div class="rpg-inventory-empty">${i18n.getTranslation('inventory.onPerson.empty')}</div>`
: onPersonItems.map((item, i) => renderStructuredItem(item, 'onPerson', i, onPersonMode)).join('');
html += `
<div class="rpg-inventory-tab-content ${activeTab === 'onPerson' ? 'active' : ''}" data-tab="onPerson">
<div class="rpg-inventory-header">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${onPersonMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="list" title="${i18n.getTranslation('global.listView')}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${onPersonMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="grid" title="${i18n.getTranslation('global.gridView')}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson" title="Add new item">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.onPerson.addItemButton')}
</button>
</div>
<div class="rpg-item-list rpg-item-${onPersonMode}-view">${onPersonHtml}</div>
</div>
`;
// Stored tab
const storedMode = viewModes.stored || 'list';
const stored = inventoryV3.stored || {};
let storedHtml = '';
for (const [location, items] of Object.entries(stored)) {
const locationItems = items.map((item, i) => renderStructuredItem(item, 'stored', i, storedMode, location)).join('');
storedHtml += `
<div class="rpg-storage-location" data-location="${escapeHtml(location)}">
<div class="rpg-location-header">
<span class="rpg-location-name">${escapeHtml(location)}</span>
<span class="rpg-location-count">(${items.length})</span>
</div>
<div class="rpg-item-list rpg-item-${storedMode}-view">${locationItems}</div>
</div>
`;
}
if (Object.keys(stored).length === 0) {
storedHtml = `<div class="rpg-inventory-empty">${i18n.getTranslation('inventory.stored.empty')}</div>`;
}
html += `
<div class="rpg-inventory-tab-content ${activeTab === 'stored' ? 'active' : ''}" data-tab="stored">
<div class="rpg-inventory-header">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${storedMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="list" title="${i18n.getTranslation('global.listView')}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${storedMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="grid" title="${i18n.getTranslation('global.gridView')}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-location" title="Add storage location">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.stored.addLocationButton')}
</button>
</div>
${storedHtml}
</div>
`;
// Assets tab
const assetsMode = viewModes.assets || 'list';
const assets = inventoryV3.assets || [];
let assetsHtml = assets.length === 0
? `<div class="rpg-inventory-empty">${i18n.getTranslation('inventory.assets.empty')}</div>`
: assets.map((item, i) => renderStructuredItem(item, 'assets', i, assetsMode)).join('');
html += `
<div class="rpg-inventory-tab-content ${activeTab === 'assets' ? 'active' : ''}" data-tab="assets">
<div class="rpg-inventory-header">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${assetsMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="list" title="${i18n.getTranslation('global.listView')}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${assetsMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="grid" title="${i18n.getTranslation('global.gridView')}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets" title="Add new asset">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.assets.addAssetButton')}
</button>
</div>
<div class="rpg-item-list rpg-item-${assetsMode}-view">${assetsHtml}</div>
</div>
`;
return `<div class="rpg-inventory-container rpg-structured">${html}</div>`;
}
/**
* Checks if we have structured inventory data (v3 format)
* @returns {boolean}
*/
function hasStructuredInventory() {
const inv = extensionSettings.inventoryV3;
return inv && (
(inv.onPerson && inv.onPerson.length > 0) ||
(inv.assets && inv.assets.length > 0) ||
(inv.stored && Object.keys(inv.stored).length > 0)
);
}
/**
* Main inventory rendering function (matches pattern of other render functions)
* Gets data from state/settings and updates DOM directly.
@@ -534,24 +797,35 @@ export function renderInventory() {
return;
}
// Get inventory data from settings
const inventory = extensionSettings.userStats.inventory;
let html;
// Convert structured inventory (v3) to legacy format if present
// This ensures we always use the original renderer
let inventory = extensionSettings.userStats.inventory;
if (hasStructuredInventory()) {
const inv = extensionSettings.inventoryV3;
// Convert structured items to comma-separated strings
const itemsToString = (items) => {
if (!items || items.length === 0) return 'None';
return items.map(i => typeof i === 'string' ? i : i.name).join(', ');
};
inventory = {
version: 2,
onPerson: itemsToString(inv.onPerson),
stored: Object.fromEntries(
Object.entries(inv.stored || {}).map(([k, v]) => [k, itemsToString(v)])
),
assets: itemsToString(inv.assets)
};
}
// Check if we should render simplified inventory
if (extensionSettings.useSimplifiedInventory) {
// For simplified mode, combine all items into a single string
// Use the 'items' field if available (from simplified parsing),
// otherwise fall back to onPerson
const itemsString = inventory.items || inventory.onPerson || 'None';
// Get view mode from settings (use 'simplified' key or fall back to 'onPerson')
const viewModes = extensionSettings.inventoryViewModes || {};
const viewMode = viewModes.simplified || viewModes.onPerson || 'list';
html = renderSimplifiedInventoryView(itemsString, viewMode);
} else {
// Full categorized inventory
// Get current render options (active tab, collapsed locations)
const options = getInventoryRenderOptions();
html = generateInventoryHTML(inventory, options);
}
@@ -569,6 +843,45 @@ export function renderInventory() {
const newName = $(this).text().trim();
updateInventoryItem(field, index, newName, location);
});
// Event listener for editing item descriptions (structured mode)
$inventoryContainer.find('.rpg-item-description.rpg-editable').on('blur', function() {
const field = $(this).data('field');
const index = parseInt($(this).data('index'));
const location = $(this).data('location');
const newDesc = $(this).text().trim();
updateStructuredItemDescription(field, index, newDesc, location);
});
}
/**
* Updates an item's description in structured inventory
* @param {string} field - 'onPerson', 'stored', or 'assets'
* @param {number} index - Item index
* @param {string} newDescription - New description
* @param {string} [location] - Location for stored items
*/
function updateStructuredItemDescription(field, index, newDescription, location) {
const inv = extensionSettings.inventoryV3;
if (!inv) return;
let item;
if (field === 'onPerson' && inv.onPerson?.[index]) {
item = inv.onPerson[index];
} else if (field === 'assets' && inv.assets?.[index]) {
item = inv.assets[index];
} else if (field === 'stored' && location && inv.stored?.[location]?.[index]) {
item = inv.stored[location][index];
}
if (item) {
item.description = newDescription;
// Save changes
import('../../core/persistence.js').then(({ saveSettings, saveChatData }) => {
saveSettings();
saveChatData();
});
}
}
/**
+103 -17
View File
@@ -18,6 +18,48 @@ function escapeHtml(text) {
return div.innerHTML;
}
/**
* Checks if we have structured quests data (v2 format with name + description)
* @returns {boolean}
*/
function hasStructuredQuests() {
const q = extensionSettings.questsV2;
return q && (q.main !== undefined || q.optional !== undefined);
}
/**
* Gets the main quest (supports both legacy and structured format)
* @returns {{name: string, description: string}|null}
*/
function getMainQuest() {
if (hasStructuredQuests() && extensionSettings.questsV2.main) {
return extensionSettings.questsV2.main;
}
// Legacy format
const title = extensionSettings.quests?.main;
if (title && title !== 'None') {
return { name: title, description: extensionSettings.quests?.mainDescription || '' };
}
return null;
}
/**
* Gets optional quests (supports both legacy and structured format)
* @returns {Array<{name: string, description: string}>}
*/
function getOptionalQuests() {
if (hasStructuredQuests() && extensionSettings.questsV2.optional) {
return extensionSettings.questsV2.optional;
}
// Legacy format
const titles = extensionSettings.quests?.optional || [];
const descriptions = extensionSettings.quests?.optionalDescriptions || [];
return titles.map((title, i) => ({
name: title,
description: descriptions[i] || ''
}));
}
/**
* Renders the quests sub-tab navigation (Main, Optional)
* @param {string} activeTab - Currently active sub-tab ('main', 'optional')
@@ -38,12 +80,15 @@ export function renderQuestsSubTabs(activeTab = 'main') {
/**
* Renders the main quest view
* @param {string} mainQuest - Current main quest title
* @param {string} mainQuest - Current main quest title (legacy param, ignored if structured)
* @returns {string} HTML for main quest view
*/
export function renderMainQuestView(mainQuest) {
const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : '';
const hasQuest = questDisplay.length > 0;
// Use structured data helpers
const quest = getMainQuest();
const hasQuest = quest !== null;
const questName = quest?.name || '';
const questDesc = quest?.description || '';
return `
<div class="rpg-quest-section">
@@ -56,7 +101,8 @@ export function renderMainQuestView(mainQuest) {
<div class="rpg-quest-content">
${hasQuest ? `
<div class="rpg-inline-form" id="rpg-edit-quest-form-main" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questDisplay)}" />
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questName)}" placeholder="Quest name" />
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-desc-main" value="${escapeHtml(questDesc)}" placeholder="Description (optional)" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-edit-quest" data-field="main">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span>
@@ -67,7 +113,7 @@ export function renderMainQuestView(mainQuest) {
</div>
</div>
<div class="rpg-quest-item" data-field="main">
<div class="rpg-quest-title">${escapeHtml(questDisplay)}</div>
<div class="rpg-quest-title">${escapeHtml(questName)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="Edit quest">
<i class="fa-solid fa-edit"></i>
@@ -106,7 +152,8 @@ export function renderMainQuestView(mainQuest) {
* @returns {string} HTML for optional quests view
*/
export function renderOptionalQuestsView(optionalQuests) {
const quests = optionalQuests.filter(q => q && q !== 'None');
// Use structured data helpers
const quests = getOptionalQuests().filter(q => q && q.name && q.name !== 'None');
let questsHtml = '';
if (quests.length === 0) {
@@ -114,7 +161,7 @@ export function renderOptionalQuestsView(optionalQuests) {
} else {
questsHtml = quests.map((quest, index) => `
<div class="rpg-quest-item" data-field="optional" data-index="${index}">
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest)}</div>
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest.name)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="Complete/Remove quest">
<i class="fa-solid fa-check"></i>
@@ -218,17 +265,29 @@ function attachQuestEventHandlers() {
// Save add quest
$questsContainer.find('[data-action="save-add-quest"]').on('click', function() {
const field = $(this).data('field');
const input = $(`#rpg-new-quest-${field}`);
const questTitle = input.val().trim();
const nameInput = $(`#rpg-new-quest-${field}`);
const descInput = $(`#rpg-new-quest-desc-${field}`);
const questTitle = nameInput.val().trim();
const questDesc = descInput?.val()?.trim() || '';
if (questTitle) {
// Ensure structured format exists
if (!extensionSettings.questsV2) {
extensionSettings.questsV2 = { main: null, optional: [] };
}
if (field === 'main') {
extensionSettings.quests.main = questTitle;
extensionSettings.questsV2.main = { name: questTitle, description: questDesc };
} else {
if (!extensionSettings.quests.optional) {
extensionSettings.quests.optional = [];
}
if (!extensionSettings.questsV2.optional) {
extensionSettings.questsV2.optional = [];
}
extensionSettings.quests.optional.push(questTitle);
extensionSettings.questsV2.optional.push({ name: questTitle, description: questDesc });
}
saveSettings();
renderQuests();
@@ -250,13 +309,21 @@ function attachQuestEventHandlers() {
$('.rpg-quest-item[data-field="main"]').show();
});
// Save edit quest
// Save edit quest (main)
$questsContainer.find('[data-action="save-edit-quest"]').on('click', function() {
const field = $(this).data('field');
const input = $(`#rpg-edit-quest-${field}`);
const questTitle = input.val().trim();
const nameInput = $(`#rpg-edit-quest-${field}`);
const descInput = $(`#rpg-edit-quest-desc-${field}`);
const questTitle = nameInput.val().trim();
const questDesc = descInput.val()?.trim() || '';
if (questTitle) {
// Use structured format
if (!extensionSettings.questsV2) {
extensionSettings.questsV2 = { main: null, optional: [] };
}
extensionSettings.questsV2.main = { name: questTitle, description: questDesc };
// Also update legacy for backwards compatibility
extensionSettings.quests.main = questTitle;
saveSettings();
renderQuests();
@@ -270,22 +337,41 @@ function attachQuestEventHandlers() {
if (field === 'main') {
extensionSettings.quests.main = 'None';
if (extensionSettings.questsV2) {
extensionSettings.questsV2.main = null;
}
} else {
extensionSettings.quests.optional.splice(index, 1);
if (extensionSettings.questsV2?.optional) {
extensionSettings.questsV2.optional.splice(index, 1);
}
}
saveSettings();
renderQuests();
});
// Inline editing for optional quests
$questsContainer.find('.rpg-quest-title.rpg-editable').on('blur', function() {
// Inline editing for optional quests (name and description)
$questsContainer.find('.rpg-quest-title.rpg-editable, .rpg-quest-description.rpg-editable').on('blur', function() {
const $this = $(this);
const field = $this.data('field');
const index = $this.data('index');
const newTitle = $this.text().trim();
const prop = $this.data('prop') || 'name';
const newValue = $this.text().trim();
if (newTitle && field === 'optional' && index !== undefined) {
extensionSettings.quests.optional[index] = newTitle;
if (field === 'optional' && index !== undefined) {
// Ensure structured format exists
if (!extensionSettings.questsV2) {
extensionSettings.questsV2 = { main: null, optional: [] };
}
if (!extensionSettings.questsV2.optional[index]) {
extensionSettings.questsV2.optional[index] = { name: '', description: '' };
}
extensionSettings.questsV2.optional[index][prop] = newValue;
// Also update legacy for backwards compatibility
if (prop === 'name') {
extensionSettings.quests.optional[index] = newValue;
}
saveSettings();
}
});
+985
View File
@@ -0,0 +1,985 @@
/**
* Skills Rendering Module
* Handles rendering of the skills section with skill categories (like inventory)
* Each configured skill becomes a category, and abilities/items can be added within each
*/
import { extensionSettings, $skillsContainer } from '../../core/state.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
import { parseItems } from '../../utils/itemParser.js';
/**
* Escapes HTML special characters to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Serializes an array of items into a comma-separated string
* @param {string[]} items - Array of items
* @returns {string} Comma-separated string or 'None'
*/
function serializeItems(items) {
if (!items || items.length === 0) return 'None';
return items.join(', ');
}
/**
* Gets the configured skill categories from settings
* @returns {string[]} Array of skill category names
*/
export function getSkillCategories() {
return extensionSettings.trackerConfig?.userStats?.skillsSection?.customFields || [];
}
/**
* Gets the items/abilities for a specific skill category
* Checks both skillsData (from parser) and skills.categories (manual entries)
* @param {string} skillName - The skill category name
* @returns {string} Comma-separated items string or 'None'
*/
export function getSkillItems(skillName) {
// Check skillsData first (populated by parser)
if (extensionSettings.skillsData?.[skillName]) {
return extensionSettings.skillsData[skillName];
}
// Fall back to skills.categories (manual entries)
if (extensionSettings.skills?.categories?.[skillName]) {
return extensionSettings.skills.categories[skillName];
}
return 'None';
}
/**
* Sets the items/abilities for a specific skill category
* @param {string} skillName - The skill category name
* @param {string} itemsString - Comma-separated items string
*/
export function setSkillItems(skillName, itemsString) {
// Initialize structures if needed
if (!extensionSettings.skillsData) {
extensionSettings.skillsData = {};
}
if (!extensionSettings.skills) {
extensionSettings.skills = { categories: {}, list: [] };
}
if (!extensionSettings.skills.categories) {
extensionSettings.skills.categories = {};
}
// Store in both places for compatibility
extensionSettings.skillsData[skillName] = itemsString || 'None';
extensionSettings.skills.categories[skillName] = itemsString || 'None';
saveSettings();
saveChatData();
updateMessageSwipeData();
}
/**
* Adds an item to a skill category
* @param {string} skillName - The skill category name
* @param {string} item - The item to add
*/
export function addSkillItem(skillName, item, description = '') {
// Check for structured data first
const skillsV2 = extensionSettings.skillsV2;
if (skillsV2 && skillsV2[skillName] !== undefined) {
if (!Array.isArray(skillsV2[skillName])) {
skillsV2[skillName] = [];
}
// Check if ability already exists
const exists = skillsV2[skillName].some(a => a.name === item);
if (!exists) {
skillsV2[skillName].push({ name: item, description: description, grantedBy: null });
saveSettings();
saveChatData();
}
return;
}
// Fall back to legacy format
const currentItems = parseItems(getSkillItems(skillName));
if (!currentItems.includes(item)) {
currentItems.push(item);
setSkillItems(skillName, serializeItems(currentItems));
}
}
/**
* Removes an item from a skill category
* @param {string} skillName - The skill category name
* @param {number} index - Index of item to remove
*/
export function removeSkillItem(skillName, index) {
// Check for structured data first
const skillsV2 = extensionSettings.skillsV2;
if (skillsV2 && skillsV2[skillName] !== undefined && Array.isArray(skillsV2[skillName])) {
if (index >= 0 && index < skillsV2[skillName].length) {
const removedAbility = skillsV2[skillName][index];
skillsV2[skillName].splice(index, 1);
// Remove any skill-ability links
if (extensionSettings.skillAbilityLinks) {
const linkKey = `${skillName}::${removedAbility.name}`;
delete extensionSettings.skillAbilityLinks[linkKey];
}
saveSettings();
saveChatData();
}
return;
}
// Fall back to legacy format
const currentItems = parseItems(getSkillItems(skillName));
if (index >= 0 && index < currentItems.length) {
const removedItem = currentItems[index];
currentItems.splice(index, 1);
setSkillItems(skillName, serializeItems(currentItems));
// Handle item-skill link removal if enabled
if (extensionSettings.enableItemSkillLinks && extensionSettings.itemSkillLinks) {
// Check if this item was linked and remove the link
for (const [itemName, linkedSkill] of Object.entries(extensionSettings.itemSkillLinks)) {
if (linkedSkill === skillName && itemName === removedItem) {
delete extensionSettings.itemSkillLinks[itemName];
break;
}
}
}
}
}
/**
* Updates an item in a skill category
* @param {string} skillName - The skill category name
* @param {number} index - Index of item to update
* @param {string} newValue - New item value
*/
export function updateSkillItem(skillName, index, newValue) {
// Check for structured data first
const skillsV2 = extensionSettings.skillsV2;
if (skillsV2 && skillsV2[skillName] && Array.isArray(skillsV2[skillName]) && skillsV2[skillName][index]) {
skillsV2[skillName][index].name = newValue;
saveSettings();
saveChatData();
return;
}
// Fall back to legacy format
const currentItems = parseItems(getSkillItems(skillName));
if (index >= 0 && index < currentItems.length) {
currentItems[index] = newValue;
setSkillItems(skillName, serializeItems(currentItems));
}
}
/**
* Updates a skill ability's description (structured format only)
* @param {string} skillName - The skill category name
* @param {number} index - Index of ability to update
* @param {string} newDescription - New description
*/
function updateStructuredSkillDescription(skillName, index, newDescription) {
const skillsV2 = extensionSettings.skillsV2;
if (skillsV2 && skillsV2[skillName] && Array.isArray(skillsV2[skillName]) && skillsV2[skillName][index]) {
skillsV2[skillName][index].description = newDescription;
saveSettings();
saveChatData();
}
}
/**
* Called when an item is removed from inventory
* Based on deleteSkillWithItem setting:
* - false (default): Just removes the link, skill remains
* - true: Deletes the linked skill abilities entirely
* @param {string} itemName - The name of the removed item
*/
export function handleItemRemoved(itemName) {
if (!extensionSettings.enableItemSkillLinks) return;
if (!extensionSettings.skillAbilityLinks) return;
const itemNameLower = itemName.toLowerCase().trim();
const linksToRemove = [];
// Find all skill abilities linked to this item
for (const [key, linkedItem] of Object.entries(extensionSettings.skillAbilityLinks)) {
if (linkedItem && linkedItem.toLowerCase().trim() === itemNameLower) {
linksToRemove.push(key);
}
}
if (linksToRemove.length === 0) return;
// Remove the links
for (const key of linksToRemove) {
delete extensionSettings.skillAbilityLinks[key];
// If deleteSkillWithItem is enabled, also delete the skill ability itself
if (extensionSettings.deleteSkillWithItem) {
const [skillName, abilityName] = key.split('::');
deleteSkillAbility(skillName, abilityName);
}
}
saveSettings();
saveChatData();
renderSkills();
}
/**
* Deletes a skill ability from the skills data
* @param {string} skillName - The skill category name
* @param {string} abilityName - The ability name to delete
*/
function deleteSkillAbility(skillName, abilityName) {
// Delete from structured skills (skillsV2)
if (extensionSettings.skillsV2 && extensionSettings.skillsV2[skillName]) {
const abilities = extensionSettings.skillsV2[skillName];
if (Array.isArray(abilities)) {
const index = abilities.findIndex(a =>
(typeof a === 'string' ? a : a.name)?.toLowerCase().trim() === abilityName.toLowerCase().trim()
);
if (index !== -1) {
abilities.splice(index, 1);
}
}
}
// Delete from legacy skillsData
if (extensionSettings.skillsData && extensionSettings.skillsData[skillName]) {
const currentItems = parseItems(extensionSettings.skillsData[skillName]);
const index = currentItems.findIndex(item =>
item.toLowerCase().trim() === abilityName.toLowerCase().trim()
);
if (index !== -1) {
currentItems.splice(index, 1);
extensionSettings.skillsData[skillName] = currentItems.length > 0 ? currentItems.join(', ') : 'None';
}
}
}
/**
* Gets the linked item for a skill ability
* @param {string} skillName - The skill category name
* @param {string} abilityName - The ability name
* @returns {string|null} The linked item name or null
*/
export function getLinkedItem(skillName, abilityName) {
if (!extensionSettings.skillAbilityLinks) return null;
const key = `${skillName}::${abilityName}`;
return extensionSettings.skillAbilityLinks[key] || null;
}
/**
* Links a skill ability to an inventory item
* @param {string} skillName - The skill category name
* @param {string} abilityName - The ability name
* @param {string} itemName - The inventory item name
*/
export function linkAbilityToItem(skillName, abilityName, itemName) {
if (!extensionSettings.skillAbilityLinks) {
extensionSettings.skillAbilityLinks = {};
}
const key = `${skillName}::${abilityName}`;
extensionSettings.skillAbilityLinks[key] = itemName;
saveSettings();
saveChatData();
}
/**
* Unlinks a skill ability from its inventory item
* @param {string} skillName - The skill category name
* @param {string} abilityName - The ability name
*/
export function unlinkAbility(skillName, abilityName) {
if (!extensionSettings.skillAbilityLinks) return;
const key = `${skillName}::${abilityName}`;
delete extensionSettings.skillAbilityLinks[key];
saveSettings();
saveChatData();
}
/**
* Gets all skill abilities linked to a specific inventory item
* @param {string} itemName - The inventory item name
* @returns {Array<{skillName: string, abilityName: string}>} Array of linked abilities
*/
export function getAbilitiesLinkedToItem(itemName) {
if (!extensionSettings.skillAbilityLinks || !itemName) return [];
const linked = [];
const normalizedItemName = itemName.toLowerCase().trim();
for (const [key, linkedItem] of Object.entries(extensionSettings.skillAbilityLinks)) {
// Case-insensitive comparison
if (linkedItem && linkedItem.toLowerCase().trim() === normalizedItemName) {
const [skillName, abilityName] = key.split('::');
linked.push({ skillName, abilityName });
}
}
return linked;
}
/**
* Checks if an inventory item has any linked skills
* @param {string} itemName - The inventory item name
* @returns {boolean} True if item has linked skills
*/
export function itemHasLinkedSkills(itemName) {
return getAbilitiesLinkedToItem(itemName).length > 0;
}
/**
* Navigates to the inventory tab and highlights an item
* @param {string} itemName - The item to highlight
*/
export function navigateToInventoryItem(itemName) {
// Switch to inventory tab if on desktop
if (window.innerWidth > 1000) {
const $inventoryTab = $('.rpg-tab-btn[data-tab="inventory"]');
if ($inventoryTab.length) {
$inventoryTab.click();
}
}
// Find and highlight the item after a delay for tab switch animation
setTimeout(() => {
// Search in inventory container specifically
const $inventoryContainer = $('#rpg-inventory');
const $items = $inventoryContainer.find('.rpg-item-name');
let found = false;
$items.each(function() {
const text = $(this).text().trim();
// Match exact or partial (for items that might have quantities etc)
if (text === itemName || text.toLowerCase() === itemName.toLowerCase()) {
const $row = $(this).closest('.rpg-item-row, .rpg-item-card');
if ($row.length) {
// Scroll into view
$row[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add highlight class
$row.addClass('rpg-highlight-item');
found = true;
// Remove after 3.5 seconds (after 3 animation cycles)
setTimeout(() => {
$row.removeClass('rpg-highlight-item');
}, 3500);
}
return false; // Break the loop
}
});
if (!found) {
toastr.warning(`Item "${itemName}" not found in inventory`);
}
}, 300);
}
/**
* Navigates to the skills tab and highlights abilities linked to an item
* @param {string} itemName - The item whose linked abilities to highlight
*/
export function navigateToLinkedSkills(itemName) {
const linkedAbilities = getAbilitiesLinkedToItem(itemName);
if (linkedAbilities.length === 0) {
toastr.info(`No skills linked to "${itemName}"`);
return;
}
// Switch to skills tab if on desktop
if (window.innerWidth > 1000) {
const $skillsTab = $('.rpg-tab-btn[data-tab="skills"]');
if ($skillsTab.length) {
$skillsTab.click();
}
}
// Highlight all linked abilities after a delay for tab switch
setTimeout(() => {
let firstHighlighted = false;
linkedAbilities.forEach(({ skillName, abilityName }) => {
// Find the skill category
const $category = $(`.rpg-skill-category[data-skill="${skillName}"]`);
if ($category.length) {
// Find items within this category
const $items = $category.find('.rpg-item-row, .rpg-item-card');
$items.each(function() {
const $row = $(this);
const $name = $row.find('.rpg-item-name');
if ($name.text().trim() === abilityName) {
// Scroll first match into view
if (!firstHighlighted) {
$row[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
firstHighlighted = true;
}
// Add highlight class
$row.addClass('rpg-highlight-item');
// Remove after 3.5 seconds
setTimeout(() => {
$row.removeClass('rpg-highlight-item');
}, 3500);
}
});
}
});
}, 300);
}
/**
* Gets all inventory items (from all categories) for linking dropdown
* Supports both legacy (v2) and structured (v3) inventory formats
* @returns {string[]} Array of item names
*/
function getAllInventoryItems() {
const items = [];
// Check structured inventory (v3) first
const inv3 = extensionSettings.inventoryV3;
if (inv3) {
// On Person
if (inv3.onPerson && Array.isArray(inv3.onPerson)) {
items.push(...inv3.onPerson.map(i => typeof i === 'string' ? i : i.name).filter(Boolean));
}
// Stored
if (inv3.stored && typeof inv3.stored === 'object') {
for (const locationItems of Object.values(inv3.stored)) {
if (Array.isArray(locationItems)) {
items.push(...locationItems.map(i => typeof i === 'string' ? i : i.name).filter(Boolean));
}
}
}
// Assets
if (inv3.assets && Array.isArray(inv3.assets)) {
items.push(...inv3.assets.map(i => typeof i === 'string' ? i : i.name).filter(Boolean));
}
}
// Fall back to legacy inventory if no v3 items found
if (items.length === 0) {
const inventory = extensionSettings.userStats?.inventory;
if (inventory) {
// On Person
if (inventory.onPerson && inventory.onPerson.toLowerCase() !== 'none') {
items.push(...parseItems(inventory.onPerson));
}
// Stored locations
if (inventory.stored && typeof inventory.stored === 'object') {
for (const locationItems of Object.values(inventory.stored)) {
if (locationItems && locationItems.toLowerCase() !== 'none') {
items.push(...parseItems(locationItems));
}
}
}
// Assets
if (inventory.assets && inventory.assets.toLowerCase() !== 'none') {
items.push(...parseItems(inventory.assets));
}
// Simplified inventory
if (inventory.items && inventory.items.toLowerCase() !== 'none') {
items.push(...parseItems(inventory.items));
}
}
}
return [...new Set(items)];
}
// Track open add forms
let openAddForms = {};
/**
* Shows the add item form for a skill category
* @param {string} skillName - The skill category name
*/
function showAddForm(skillName) {
openAddForms[skillName] = true;
renderSkills();
// Focus the input after render
setTimeout(() => {
$(`#rpg-new-skill-item-${CSS.escape(skillName)}`).focus();
}, 50);
}
/**
* Hides the add item form for a skill category
* @param {string} skillName - The skill category name
*/
function hideAddForm(skillName) {
openAddForms[skillName] = false;
renderSkills();
}
/**
* Saves a new item from the add form
* @param {string} skillName - The skill category name
*/
function saveAddItem(skillName) {
const input = $(`#rpg-new-skill-item-${CSS.escape(skillName)}`);
const value = input.val()?.trim();
if (value) {
addSkillItem(skillName, value);
}
hideAddForm(skillName);
}
/**
* Renders a structured skill ability (with name + description)
* @param {string} skillName - The skill category name
* @param {Object} ability - Structured ability object {name, description, grantedBy}
* @param {number} index - The item index
* @param {string} viewMode - View mode ('list' or 'grid')
* @returns {string} HTML string
*/
function renderStructuredSkillAbility(skillName, ability, index, viewMode) {
// Normalize ability - handle both string and object formats
const normalizedAbility = typeof ability === 'string'
? { name: ability, description: '', grantedBy: null }
: { name: ability?.name || 'Unknown', description: ability?.description || '', grantedBy: ability?.grantedBy || null };
// Check for linked item - first from ability.grantedBy, then from skillAbilityLinks
const linkedItem = normalizedAbility.grantedBy ||
(extensionSettings.enableItemSkillLinks ? getLinkedItem(skillName, normalizedAbility.name) : null);
const itemClass = viewMode === 'grid' ? 'rpg-item-card' : 'rpg-item-row';
const hasLink = !!linkedItem;
// Link indicator HTML - shows the item that grants this skill
const linkIndicator = hasLink
? `<span class="rpg-skill-link-badge" data-action="goto-linked-item" data-item="${escapeHtml(linkedItem)}" title="${i18n.getTranslation('skills.gotoItem')}: ${escapeHtml(linkedItem)}">
<i class="fa-solid fa-link"></i>
<span class="rpg-link-item-name">${escapeHtml(linkedItem)}</span>
</span>`
: (extensionSettings.enableItemSkillLinks
? `<button class="rpg-skill-link-btn" data-action="show-link-dropdown" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(normalizedAbility.name)}" data-index="${index}" title="${i18n.getTranslation('skills.linkToItem')}">
<i class="fa-solid fa-link"></i>
</button>`
: '');
// Unlink button
const unlinkBtn = hasLink && extensionSettings.enableItemSkillLinks
? `<button class="rpg-skill-unlink-btn" data-action="unlink-ability" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(normalizedAbility.name)}" title="${i18n.getTranslation('skills.unlinkItem')}">
<i class="fa-solid fa-unlink"></i>
</button>`
: '';
if (viewMode === 'list') {
return `
<div class="${itemClass} rpg-structured ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(normalizedAbility.name)}">
<div class="rpg-skill-ability-row">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(normalizedAbility.name)}</span>
${linkIndicator}
${unlinkBtn}
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="rpg-skill-ability-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(normalizedAbility.description)}</span>
</div>
</div>
`;
} else {
return `
<div class="${itemClass} rpg-structured ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(normalizedAbility.name)}">
<div class="rpg-card-actions">
${unlinkBtn}
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
<i class="fa-solid fa-times"></i>
</button>
</div>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(normalizedAbility.name)}</span>
${linkIndicator}
<div class="rpg-skill-ability-desc-row">
<span class="rpg-item-description rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(normalizedAbility.description)}</span>
</div>
</div>
`;
}
}
/**
* Renders a single skill ability item with link indicator (legacy string format)
* @param {string} skillName - The skill category name
* @param {string} abilityName - The ability name
* @param {number} index - The item index
* @param {string} viewMode - View mode ('list' or 'grid')
* @returns {string} HTML string
*/
function renderSkillAbilityItem(skillName, abilityName, index, viewMode) {
const linkedItem = extensionSettings.enableItemSkillLinks ? getLinkedItem(skillName, abilityName) : null;
const itemClass = viewMode === 'grid' ? 'rpg-item-card' : 'rpg-item-row';
const hasLink = !!linkedItem;
// Link indicator HTML
const linkIndicator = extensionSettings.enableItemSkillLinks ? (hasLink
? `<span class="rpg-skill-link-badge" data-action="goto-linked-item" data-item="${escapeHtml(linkedItem)}" title="${i18n.getTranslation('skills.gotoItem')}: ${escapeHtml(linkedItem)}">
<i class="fa-solid fa-link"></i>
<span class="rpg-link-item-name">${escapeHtml(linkedItem)}</span>
</span>`
: `<button class="rpg-skill-link-btn" data-action="show-link-dropdown" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(abilityName)}" data-index="${index}" title="${i18n.getTranslation('skills.linkToItem')}">
<i class="fa-solid fa-link"></i>
</button>`
) : '';
// Unlink button (only shown if linked)
const unlinkBtn = hasLink && extensionSettings.enableItemSkillLinks
? `<button class="rpg-skill-unlink-btn" data-action="unlink-ability" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(abilityName)}" title="${i18n.getTranslation('skills.unlinkItem')}">
<i class="fa-solid fa-unlink"></i>
</button>`
: '';
if (viewMode === 'list') {
return `
<div class="${itemClass} ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(abilityName)}">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(abilityName)}</span>
${linkIndicator}
${unlinkBtn}
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
<i class="fa-solid fa-times"></i>
</button>
</div>
`;
} else {
return `
<div class="${itemClass} ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(abilityName)}">
<div class="rpg-card-actions">
${unlinkBtn}
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
<i class="fa-solid fa-times"></i>
</button>
</div>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(abilityName)}</span>
${linkIndicator}
</div>
`;
}
}
/**
* Checks if we have structured skills data (v2 format)
* @param {string} skillName - The skill category name
* @returns {Array|null} Structured abilities array or null
*/
function getStructuredSkillAbilities(skillName) {
const skillsV2 = extensionSettings.skillsV2;
if (skillsV2 && skillsV2[skillName] && Array.isArray(skillsV2[skillName])) {
return skillsV2[skillName];
}
return null;
}
/**
* Renders a single skill category section
* @param {string} skillName - The skill category name
* @param {string} viewMode - View mode ('list' or 'grid')
* @returns {string} HTML string
*/
function renderSkillCategory(skillName, viewMode) {
// Check for structured data first
const structuredAbilities = getStructuredSkillAbilities(skillName);
const isStructured = structuredAbilities !== null;
const items = isStructured ? structuredAbilities : parseItems(getSkillItems(skillName));
const safeSkillName = skillName.replace(/[^a-zA-Z0-9]/g, '_');
const isFormOpen = openAddForms[skillName];
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = `<div class="rpg-skill-items-empty" data-i18n-key="skills.noAbilities">${i18n.getTranslation('skills.noAbilities')}</div>`;
} else {
if (isStructured) {
// Render structured abilities with name + description
itemsHtml = items.map((ability, index) => renderStructuredSkillAbility(skillName, ability, index, viewMode)).join('');
} else {
// Render legacy string-based abilities
itemsHtml = items.map((item, index) => renderSkillAbilityItem(skillName, item, index, viewMode)).join('');
}
}
const listViewClass = viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view';
return `
<div class="rpg-skill-category" data-skill="${escapeHtml(skillName)}">
<div class="rpg-skill-category-header">
<h5 class="rpg-skill-category-title">
<i class="fa-solid fa-star"></i>
<span>${escapeHtml(skillName)}</span>
<span class="rpg-skill-category-count">(${items.length})</span>
</h5>
<div class="rpg-skill-category-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-skill-view" data-skill="${escapeHtml(skillName)}" data-view="list" title="${i18n.getTranslation('global.listView')}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-skill-view" data-skill="${escapeHtml(skillName)}" data-view="grid" title="${i18n.getTranslation('global.gridView')}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-skill-add-btn" data-action="show-add-skill-item" data-skill="${escapeHtml(skillName)}" title="${i18n.getTranslation('skills.addAbility')}">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="skills.addAbilityButton">${i18n.getTranslation('skills.addAbilityButton')}</span>
</button>
</div>
</div>
<div class="rpg-skill-category-content">
<div class="rpg-inline-form" id="rpg-add-skill-form-${safeSkillName}" style="display: ${isFormOpen ? 'flex' : 'none'};">
<input type="text" class="rpg-inline-input" id="rpg-new-skill-item-${safeSkillName}" placeholder="${i18n.getTranslation('skills.addAbilityPlaceholder')}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-skill-item" data-skill="${escapeHtml(skillName)}">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span>
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-skill-item" data-skill="${escapeHtml(skillName)}">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span>
</button>
</div>
</div>
<div class="rpg-item-list ${listViewClass}">
${itemsHtml}
</div>
</div>
</div>
`;
}
/**
* Generates the full skills section HTML
* @returns {string} HTML string
*/
export function generateSkillsHTML() {
const skillCategories = getSkillCategories();
if (skillCategories.length === 0) {
return `
<div class="rpg-skills-container">
<div class="rpg-skills-empty">
<i class="fa-solid fa-star"></i>
<p data-i18n-key="skills.empty">${i18n.getTranslation('skills.empty')}</p>
<small data-i18n-key="skills.emptyNote">${i18n.getTranslation('skills.emptyNote')}</small>
</div>
</div>
`;
}
// Get view modes for each skill (default to list)
const viewModes = extensionSettings.skillsViewModes || {};
let html = `<div class="rpg-skills-container">`;
// Render each skill category
for (const skillName of skillCategories) {
const viewMode = viewModes[skillName] || 'list';
html += renderSkillCategory(skillName, viewMode);
}
html += '</div>';
return html;
}
/**
* Sets up event listeners for skills section
*/
function setupSkillsEventListeners() {
if (!$skillsContainer || $skillsContainer.length === 0) return;
// Show add form
$skillsContainer.off('click', '[data-action="show-add-skill-item"]').on('click', '[data-action="show-add-skill-item"]', function() {
const skillName = $(this).data('skill');
showAddForm(skillName);
});
// Cancel add form
$skillsContainer.off('click', '[data-action="cancel-add-skill-item"]').on('click', '[data-action="cancel-add-skill-item"]', function() {
const skillName = $(this).data('skill');
hideAddForm(skillName);
});
// Save add form
$skillsContainer.off('click', '[data-action="save-add-skill-item"]').on('click', '[data-action="save-add-skill-item"]', function() {
const skillName = $(this).data('skill');
saveAddItem(skillName);
});
// Enter key in add form
$skillsContainer.off('keypress', '.rpg-inline-input').on('keypress', '.rpg-inline-input', function(e) {
if (e.which === 13) { // Enter key
const skillName = $(this).closest('.rpg-skill-category').data('skill');
saveAddItem(skillName);
}
});
// Remove item
$skillsContainer.off('click', '[data-action="remove-skill-item"]').on('click', '[data-action="remove-skill-item"]', function() {
const skillName = $(this).data('skill');
const index = $(this).data('index');
removeSkillItem(skillName, index);
renderSkills();
});
// Edit item name (blur event for contenteditable)
$skillsContainer.off('blur', '.rpg-item-name.rpg-editable').on('blur', '.rpg-item-name.rpg-editable', function() {
const skillName = $(this).data('skill');
const index = $(this).data('index');
const newValue = $(this).text().trim();
if (newValue) {
updateSkillItem(skillName, index, newValue);
} else {
// If empty, remove the item
removeSkillItem(skillName, index);
renderSkills();
}
});
// Edit item description (for structured skills)
$skillsContainer.off('blur', '.rpg-item-description.rpg-editable').on('blur', '.rpg-item-description.rpg-editable', function() {
const skillName = $(this).data('skill');
const index = $(this).data('index');
const newDesc = $(this).text().trim();
updateStructuredSkillDescription(skillName, index, newDesc);
});
// Switch view mode for a skill category
$skillsContainer.off('click', '[data-action="switch-skill-view"]').on('click', '[data-action="switch-skill-view"]', function() {
const skillName = $(this).data('skill');
const view = $(this).data('view');
if (!extensionSettings.skillsViewModes) {
extensionSettings.skillsViewModes = {};
}
extensionSettings.skillsViewModes[skillName] = view;
saveSettings();
renderSkills();
});
// Show link dropdown
$skillsContainer.off('click', '[data-action="show-link-dropdown"]').on('click', '[data-action="show-link-dropdown"]', function(e) {
e.stopPropagation();
const $btn = $(this);
const skillName = $btn.data('skill');
const abilityName = $btn.data('ability');
showLinkDropdown($btn, skillName, abilityName);
});
// Go to linked item in inventory
$skillsContainer.off('click', '[data-action="goto-linked-item"]').on('click', '[data-action="goto-linked-item"]', function() {
const itemName = $(this).data('item');
if (itemName) {
navigateToInventoryItem(itemName);
}
});
// Unlink ability from item
$skillsContainer.off('click', '[data-action="unlink-ability"]').on('click', '[data-action="unlink-ability"]', function(e) {
e.stopPropagation();
const skillName = $(this).data('skill');
const abilityName = $(this).data('ability');
unlinkAbility(skillName, abilityName);
renderSkills();
});
}
/**
* Shows a dropdown to select an inventory item to link
*/
function showLinkDropdown($btn, skillName, abilityName) {
// Remove any existing dropdown
$('.rpg-link-dropdown').remove();
const inventoryItems = getAllInventoryItems();
if (inventoryItems.length === 0) {
toastr.info(i18n.getTranslation('skills.noItemsToLink'));
return;
}
// Build dropdown HTML
let dropdownHtml = `<div class="rpg-link-dropdown">
<div class="rpg-link-dropdown-header">
<span>${i18n.getTranslation('skills.selectItemToLink')}</span>
<button class="rpg-link-dropdown-close" data-action="close-link-dropdown"><i class="fa-solid fa-times"></i></button>
</div>
<div class="rpg-link-dropdown-list">`;
for (const item of inventoryItems) {
dropdownHtml += `<div class="rpg-link-dropdown-item" data-action="link-to-item" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(abilityName)}" data-item="${escapeHtml(item)}">
<i class="fa-solid fa-box"></i> <span>${escapeHtml(item)}</span>
</div>`;
}
dropdownHtml += `</div></div>`;
const $dropdown = $(dropdownHtml);
$('body').append($dropdown);
// Position near the button
const btnOffset = $btn.offset();
$dropdown.css({
position: 'fixed',
top: btnOffset.top + $btn.outerHeight() + 5,
left: Math.min(btnOffset.left, $(window).width() - $dropdown.outerWidth() - 10),
zIndex: 10000
});
// Event handlers
$dropdown.on('click', '[data-action="close-link-dropdown"]', () => $dropdown.remove());
$dropdown.on('click', '[data-action="link-to-item"]', function() {
linkAbilityToItem($(this).data('skill'), $(this).data('ability'), $(this).data('item'));
$dropdown.remove();
renderSkills();
toastr.success(i18n.getTranslation('skills.linkCreated'));
});
// Close when clicking outside
setTimeout(() => {
$(document).one('click', function(e) {
if (!$(e.target).closest('.rpg-link-dropdown, [data-action="show-link-dropdown"]').length) {
$dropdown.remove();
}
});
}, 100);
}
/**
* Main render function for skills section
*/
export function renderSkills() {
if (!extensionSettings.showSkills || !$skillsContainer || $skillsContainer.length === 0) {
return;
}
const html = generateSkillsHTML();
$skillsContainer.html(html);
setupSkillsEventListeners();
// Apply i18n translations (pass DOM element, not jQuery object)
const domElement = $skillsContainer[0];
if (domElement) {
i18n.applyTranslations(domElement);
}
}
/**
* Builds a summary string of all skills for prompt injection
* @returns {string} Formatted skills summary
*/
export function buildSkillsSummary() {
const skillCategories = getSkillCategories();
if (skillCategories.length === 0) return '';
let summary = '';
for (const skillName of skillCategories) {
const items = getSkillItems(skillName);
summary += `${skillName}: ${items}\n`;
}
return summary.trim();
}
+320 -6
View File
@@ -106,6 +106,279 @@ function namesMatch(cardName, aiName) {
* Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields.
*/
/**
* Converts structured character data to the internal format used by the renderer
* @param {Array} charactersData - Array of structured character objects
* @param {Object} config - Tracker configuration
* @returns {Array} Array of character objects in the format expected by the renderer
*/
function convertStructuredCharactersToFormat(charactersData, config) {
if (!charactersData || !Array.isArray(charactersData) || charactersData.length === 0) {
return null;
}
const enabledFields = config?.customFields?.filter(f => f && f.enabled && f.name) || [];
const enabledCharStats = config?.characterStats?.enabled && config?.characterStats?.customStats?.filter(s => s && s.enabled && s.name) || [];
const thoughtsFieldName = config?.thoughts?.name || 'Thoughts';
return charactersData.map(char => {
const result = {
name: char.name || 'Unknown',
emoji: char.emoji || '😶',
fields: {},
relationship: char.relationship || null,
stats: {},
thoughts: char.thoughts || ''
};
// Map custom fields - check both top-level and nested in char.fields
const charFields = char.fields || {};
enabledFields.forEach(field => {
const fieldId = field.id || field.name.toLowerCase().replace(/\s+/g, '_');
// First check char.fields (LLM format), then check top-level
if (charFields[field.name] !== undefined) {
result.fields[field.name] = charFields[field.name];
} else if (charFields[fieldId] !== undefined) {
result.fields[field.name] = charFields[fieldId];
} else if (char[fieldId] !== undefined) {
result.fields[field.name] = char[fieldId];
} else if (char[field.name] !== undefined) {
result.fields[field.name] = char[field.name];
}
});
// Map character stats - check both nested and top-level
const charStats = char.stats || {};
if (enabledCharStats.length > 0) {
enabledCharStats.forEach(stat => {
const statId = stat.id || stat.name.toLowerCase().replace(/\s+/g, '_');
if (charStats[stat.name] !== undefined) {
result.stats[stat.name] = charStats[stat.name];
} else if (charStats[statId] !== undefined) {
result.stats[stat.name] = charStats[statId];
}
});
}
// Also add description if present
if (char.description) {
result.description = char.description;
}
return result;
});
}
/**
* Renders characters using structured data format
* @param {Array} characters - Parsed character data
* @param {Object} config - Tracker config
* @param {Array} enabledFields - Enabled custom fields
* @param {Array} enabledCharStats - Enabled character stats
* @param {Array} relationshipFields - Available relationship types
* @param {boolean} hasRelationshipEnabled - Whether relationships are enabled
*/
function renderStructuredCharacters(characters, config, enabledFields, enabledCharStats, relationshipFields, hasRelationshipEnabled) {
debugLog('[RPG Thoughts] Rendering structured characters:', characters.length);
const thoughtsFieldName = config?.thoughts?.name || 'Thoughts';
const thoughtsEnabled = config?.thoughts?.enabled;
// Build HTML for each character
let html = '<div class="rpg-present-characters">';
for (const char of characters) {
const avatarUrl = getCharacterAvatarUrl(char.name);
const relationshipEmoji = getRelationshipEmoji(char.relationship);
html += `
<div class="rpg-character-card rpg-structured" data-character="${escapeHtmlAttr(char.name)}">
<div class="rpg-character-header">
<div class="rpg-character-avatar">
<img src="${avatarUrl}" onerror="this.src='${FALLBACK_AVATAR_DATA_URI}'" alt="${escapeHtmlAttr(char.name)}">
${char.relationship ? `<span class="rpg-relationship-badge" title="${escapeHtmlAttr(char.relationship)}">${relationshipEmoji}</span>` : ''}
</div>
<div class="rpg-character-info">
<div class="rpg-character-name">
<span class="rpg-char-emoji rpg-editable" contenteditable="true" data-character="${escapeHtmlAttr(char.name)}" data-field="emoji">${char.emoji}</span>
<span class="rpg-char-name-text rpg-editable" contenteditable="true" data-character="${escapeHtmlAttr(char.name)}" data-field="name">${char.name}</span>
</div>
${char.description ? `<div class="rpg-character-description">${char.description}</div>` : ''}
</div>
</div>`;
// Custom fields - safely check if fields exists
const charFields = char.fields || {};
if (enabledFields.length > 0 && Object.keys(charFields).length > 0) {
html += '<div class="rpg-character-fields">';
for (const field of enabledFields) {
const value = charFields[field.name] || '';
if (value) {
html += `
<div class="rpg-character-field">
<span class="rpg-field-label">${field.name}:</span>
<span class="rpg-field-value rpg-editable" contenteditable="true" data-character="${escapeHtmlAttr(char.name)}" data-field="${field.name}">${value}</span>
</div>`;
}
}
html += '</div>';
}
// Character stats (health, arousal, etc.) - safely check if stats exists
const charStats = char.stats || {};
if (enabledCharStats.length > 0 && Object.keys(charStats).length > 0) {
html += '<div class="rpg-character-stats">';
for (const stat of enabledCharStats) {
const value = charStats[stat.name];
if (value !== undefined) {
const color = getStatColor(value, stat.lowColor || '#ff0000', stat.highColor || '#00ff00');
html += `
<div class="rpg-char-stat">
<span class="rpg-stat-name">${stat.name}</span>
<div class="rpg-stat-bar">
<div class="rpg-stat-fill" style="width: ${value}%; background-color: ${color};"></div>
</div>
<span class="rpg-stat-value rpg-editable" contenteditable="true" data-character="${escapeHtmlAttr(char.name)}" data-field="${stat.name}">${value}%</span>
</div>`;
}
}
html += '</div>';
}
// Relationship field
if (hasRelationshipEnabled && char.relationship) {
// Add the character's relationship to options if not already in the list
const allRelationships = [...relationshipFields];
if (char.relationship && !allRelationships.includes(char.relationship)) {
allRelationships.unshift(char.relationship);
}
html += `
<div class="rpg-character-relationship">
<span class="rpg-field-label">Relationship:</span>
<select class="rpg-relationship-select" data-character="${escapeHtmlAttr(char.name)}" data-field="Relationship">
${allRelationships.map(r => `<option value="${r}" ${char.relationship === r ? 'selected' : ''}>${r}</option>`).join('')}
</select>
</div>`;
}
// Thoughts
if (thoughtsEnabled && char.thoughts) {
html += `
<div class="rpg-character-thoughts">
<span class="rpg-thoughts-label">${thoughtsFieldName}:</span>
<span class="rpg-thoughts-text rpg-editable" contenteditable="true" data-character="${escapeHtmlAttr(char.name)}" data-field="${thoughtsFieldName}">${char.thoughts}</span>
</div>`;
}
html += '</div>';
}
html += '</div>';
// If no characters
if (characters.length === 0) {
html = '<div class="rpg-no-characters">No characters present</div>';
}
$thoughtsContainer.html(html);
// Remove updating animation
if (extensionSettings.enableAnimations) {
setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 300);
}
// Setup event listeners for editable fields
setupStructuredCharacterEventListeners();
}
/**
* Gets relationship emoji from relationship string
* Returns a default emoji () if relationship is not in the predefined map
*/
function getRelationshipEmoji(relationship) {
if (!relationship) return null;
const map = {
'Enemy': '⚔️',
'Neutral': '⚖️',
'Friend': '⭐',
'Lover': '❤️',
'Ally': '🤝',
'Rival': '🎯',
'Family': '👨‍👩‍👧',
'Stranger': '❓'
};
// Return mapped emoji or default '⚖️' for unknown relationships
return map[relationship] || '⚖️';
}
/**
* Gets character avatar URL
*/
function getCharacterAvatarUrl(characterName) {
// Try to find matching character from SillyTavern
try {
const context = getContext();
if (context && characters) {
const char = characters.find(c => namesMatch(c.name, characterName));
if (char && char.avatar) {
return getSafeThumbnailUrl('avatar', char.avatar);
}
}
} catch (e) {
debugLog('[RPG Thoughts] Error getting avatar:', e);
}
return FALLBACK_AVATAR_DATA_URI;
}
/**
* Setup event listeners for structured character editing
*/
function setupStructuredCharacterEventListeners() {
$thoughtsContainer.off('blur', '.rpg-editable').on('blur', '.rpg-editable', function() {
const $this = $(this);
const characterName = $this.data('character');
const field = $this.data('field');
const newValue = $this.text().trim();
// Update the charactersData
const charIndex = extensionSettings.charactersData?.findIndex(c => c.name === characterName);
if (charIndex !== undefined && charIndex !== -1) {
const char = extensionSettings.charactersData[charIndex];
if (field === 'name') {
char.name = newValue;
} else if (field === 'emoji') {
char.emoji = newValue;
} else if (field === 'Thoughts' || field === extensionSettings.trackerConfig?.presentCharacters?.thoughts?.name) {
char.thoughts = newValue;
} else {
// Custom field or stat
const fieldId = field.toLowerCase().replace(/\s+/g, '_');
if (char.stats && char.stats[fieldId] !== undefined) {
char.stats[fieldId] = parseInt(newValue.replace('%', '')) || 0;
} else {
char[fieldId] = newValue;
}
}
saveChatData();
}
});
// Relationship select
$thoughtsContainer.off('change', '.rpg-relationship-select').on('change', '.rpg-relationship-select', function() {
const characterName = $(this).data('character');
const newValue = $(this).val();
const charIndex = extensionSettings.charactersData?.findIndex(c => c.name === characterName);
if (charIndex !== undefined && charIndex !== -1) {
extensionSettings.charactersData[charIndex].relationship = newValue;
saveChatData();
renderThoughts(); // Re-render to update badge
}
});
}
export function renderThoughts() {
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return;
@@ -128,8 +401,47 @@ export function renderThoughts() {
const relationshipFields = config?.relationshipFields || [];
const hasRelationshipEnabled = relationshipFields.length > 0;
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || '';
// Convert structured character data to text format for the original fancy renderer
let characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || '';
// If we have structured data, convert it to text format
if (extensionSettings.charactersData && Array.isArray(extensionSettings.charactersData) && extensionSettings.charactersData.length > 0) {
const lines = [];
for (const char of extensionSettings.charactersData) {
// 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) {
const thoughtsFieldName = config?.thoughts?.name || 'Thoughts';
lines.push(`${thoughtsFieldName}: ${char.thoughts}`);
}
}
if (lines.length > 0) {
characterThoughtsData = lines.join('\n');
debugLog('[RPG Thoughts] Converted structured data to text format');
}
}
debugLog('[RPG Thoughts] Raw characterThoughts data:', characterThoughtsData);
debugLog('[RPG Thoughts] Data length:', characterThoughtsData.length + ' chars');
@@ -376,14 +688,16 @@ export function renderThoughts() {
debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...');
// Get relationship badge - only if relationships are enabled in config
let relationshipBadge = '⚖️'; // Default
let relationshipBadge = '⚖️'; // Default emoji
let relationshipText = 'Neutral'; // Default text for tooltip
let relationshipFieldName = 'Relationship';
if (hasRelationshipEnabled) {
// In the new format, relationship is always stored in char.Relationship
if (char.Relationship) {
// Try to map text to emoji
relationshipBadge = relationshipEmojis[char.Relationship] || char.Relationship;
relationshipText = char.Relationship;
// Try to map text to emoji, fall back to default link emoji for unknown types
relationshipBadge = relationshipEmojis[char.Relationship] || '⚖️';
}
}
@@ -396,7 +710,7 @@ export function renderThoughts() {
<div class="rpg-character-card" data-character-name="${escapedName}">
<div class="rpg-character-avatar">
<img src="${characterPortrait}" alt="${escapedName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${relationshipFieldName}" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipBadge}</div>` : ''}
${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${relationshipFieldName}" title="${escapeHtmlAttr(relationshipText)}">${relationshipBadge}</div>` : ''}
</div>
<div class="rpg-character-content">
<div class="rpg-character-info">
+4 -4
View File
@@ -61,8 +61,8 @@ export function buildUserStatsText() {
text += inventorySummary;
}
// Add skills if enabled
if (config.skillsSection.enabled && stats.skills) {
// Add skills if enabled AND not shown in separate tab
if (config.skillsSection.enabled && stats.skills && !extensionSettings.showSkills) {
text += `\n${config.skillsSection.label}: ${stats.skills}`;
}
@@ -167,8 +167,8 @@ export function renderUserStats() {
html += '</div>';
}
// Skills section (conditionally rendered)
if (config.skillsSection.enabled) {
// Skills section (conditionally rendered) - only if NOT shown in separate tab
if (config.skillsSection.enabled && !extensionSettings.showSkills) {
const skillsValue = stats.skills || 'None';
html += `
<div class="rpg-skills-section">
+25 -7
View File
@@ -24,15 +24,22 @@ export function setupDesktopTabs() {
const $userStats = $('#rpg-user-stats');
const $infoBox = $('#rpg-info-box');
const $thoughts = $('#rpg-thoughts');
const $skills = $('#rpg-skills');
const $inventory = $('#rpg-inventory');
const $quests = $('#rpg-quests');
// If no sections exist, nothing to organize
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $skills.length === 0 && $inventory.length === 0 && $quests.length === 0) {
return;
}
// Create tab navigation - conditionally show inventory and quests tabs based on settings
// Create tab navigation - conditionally show skills, inventory and quests tabs based on settings
const skillsTabHtml = extensionSettings.showSkills ? `
<button class="rpg-tab-btn" data-tab="skills">
<i class="fa-solid fa-star"></i>
<span data-i18n-key="global.skills">Skills</span>
</button>` : '';
const inventoryTabHtml = extensionSettings.showInventory ? `
<button class="rpg-tab-btn" data-tab="inventory">
<i class="fa-solid fa-box"></i>
@@ -50,12 +57,13 @@ export function setupDesktopTabs() {
<button class="rpg-tab-btn active" data-tab="status">
<i class="fa-solid fa-chart-simple"></i>
<span data-i18n-key="global.status">Status</span>
</button>${inventoryTabHtml}${questsTabHtml}
</button>${skillsTabHtml}${inventoryTabHtml}${questsTabHtml}
</div>
`);
// Create tab content containers
const $statusTab = $('<div class="rpg-tab-content active" data-tab-content="status"></div>');
const $skillsTab = $('<div class="rpg-tab-content" data-tab-content="skills"></div>');
const $inventoryTab = $('<div class="rpg-tab-content" data-tab-content="inventory"></div>');
const $questsTab = $('<div class="rpg-tab-content" data-tab-content="quests"></div>');
@@ -72,11 +80,15 @@ export function setupDesktopTabs() {
$statusTab.append($thoughts.detach());
$thoughts.show();
}
if ($inventory.length > 0) {
if (extensionSettings.showSkills && $skills.length > 0) {
$skillsTab.append($skills.detach());
$skills.show();
}
if (extensionSettings.showInventory && $inventory.length > 0) {
$inventoryTab.append($inventory.detach());
$inventory.show();
}
if ($quests.length > 0) {
if (extensionSettings.showQuests && $quests.length > 0) {
$questsTab.append($quests.detach());
$quests.show();
}
@@ -88,6 +100,7 @@ export function setupDesktopTabs() {
const $tabsContainer = $('<div class="rpg-tabs-container"></div>');
$tabsContainer.append($tabNav);
$tabsContainer.append($statusTab);
$tabsContainer.append($skillsTab);
$tabsContainer.append($inventoryTab);
$tabsContainer.append($questsTab);
@@ -120,27 +133,30 @@ export function removeDesktopTabs() {
const $userStats = $('#rpg-user-stats').detach();
const $infoBox = $('#rpg-info-box').detach();
const $thoughts = $('#rpg-thoughts').detach();
const $skills = $('#rpg-skills').detach();
const $inventory = $('#rpg-inventory').detach();
const $quests = $('#rpg-quests').detach();
// Remove tabs container
$('.rpg-tabs-container').remove();
// Get dividers (all 4 dividers in the template)
// Get dividers (all 5 dividers in the template)
const $dividerStats = $('#rpg-divider-stats');
const $dividerInfo = $('#rpg-divider-info');
const $dividerThoughts = $('#rpg-divider-thoughts');
const $dividerSkills = $('#rpg-divider-skills');
const $dividerInventory = $('#rpg-divider-inventory');
// Restore original sections to content box in correct order
const $contentBox = $('.rpg-content-box');
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Skills, Inventory, Quests
// Each section goes before its corresponding divider
if ($dividerStats.length) {
$dividerStats.before($userStats);
$dividerInfo.before($infoBox);
$dividerThoughts.before($thoughts);
$dividerSkills.before($skills);
$dividerInventory.before($inventory);
$contentBox.append($quests);
} else {
@@ -148,6 +164,7 @@ export function removeDesktopTabs() {
$contentBox.append($userStats);
$contentBox.append($infoBox);
$contentBox.append($thoughts);
$contentBox.append($skills);
$contentBox.append($inventory);
$contentBox.append($quests);
}
@@ -156,6 +173,7 @@ export function removeDesktopTabs() {
$userStats.show();
$infoBox.show();
$thoughts.show();
$skills.show();
$inventory.show();
$quests.show();
$('.rpg-divider').show();
+13 -4
View File
@@ -9,6 +9,7 @@ import {
$userStatsContainer,
$infoBoxContainer,
$thoughtsContainer,
$skillsContainer,
$inventoryContainer,
$questsContainer
} from '../../core/state.js';
@@ -228,6 +229,9 @@ export function updateSectionVisibility() {
$userStatsContainer.toggle(extensionSettings.showUserStats);
$infoBoxContainer.toggle(extensionSettings.showInfoBox);
$thoughtsContainer.toggle(extensionSettings.showCharacterThoughts);
if ($skillsContainer) {
$skillsContainer.toggle(extensionSettings.showSkills);
}
if ($inventoryContainer) {
$inventoryContainer.toggle(extensionSettings.showInventory);
}
@@ -238,19 +242,24 @@ export function updateSectionVisibility() {
// Show/hide dividers intelligently
// Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible
const showDividerAfterStats = extensionSettings.showUserStats &&
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests);
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showSkills || extensionSettings.showInventory || extensionSettings.showQuests);
$('#rpg-divider-stats').toggle(showDividerAfterStats);
// Divider after Info Box: shown if Info Box is visible AND at least one section after it is visible
const showDividerAfterInfo = extensionSettings.showInfoBox &&
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests);
(extensionSettings.showCharacterThoughts || extensionSettings.showSkills || extensionSettings.showInventory || extensionSettings.showQuests);
$('#rpg-divider-info').toggle(showDividerAfterInfo);
// Divider after Thoughts: shown if Thoughts is visible AND Inventory or Quests is visible
// Divider after Thoughts: shown if Thoughts is visible AND Skills, Inventory or Quests is visible
const showDividerAfterThoughts = extensionSettings.showCharacterThoughts &&
(extensionSettings.showInventory || extensionSettings.showQuests);
(extensionSettings.showSkills || extensionSettings.showInventory || extensionSettings.showQuests);
$('#rpg-divider-thoughts').toggle(showDividerAfterThoughts);
// Divider after Skills: shown if Skills is visible AND Inventory or Quests is visible
const showDividerAfterSkills = extensionSettings.showSkills &&
(extensionSettings.showInventory || extensionSettings.showQuests);
$('#rpg-divider-skills').toggle(showDividerAfterSkills);
// Divider after Inventory: shown if Inventory is visible AND Quests is visible
const showDividerAfterInventory = extensionSettings.showInventory &&
extensionSettings.showQuests;
+21 -2
View File
@@ -8,6 +8,7 @@ import { saveSettings } from '../../core/persistence.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts } from '../rendering/thoughts.js';
import { renderSkills } from '../rendering/skills.js';
let $editorModal = null;
let activeTab = 'userStats';
@@ -287,11 +288,22 @@ function renderUserStatsTab() {
// Skills Section
html += `<h4><i class="fa-solid fa-star"></i> ${i18n.getTranslation('template.trackerEditorModal.userStatsTab.skillsSectionTitle')}</h4>`;
html += '<div class="rpg-editor-toggle-row">';
html += `<input type="checkbox" id="rpg-skills-enabled" ${config.skillsSection.enabled ? 'checked' : ''}>`;
// Check if skills are shown as separate section - if so, disable the toggle
const skillsInSeparateTab = extensionSettings.showSkills;
const skillsToggleDisabled = skillsInSeparateTab ? 'disabled' : '';
const skillsToggleStyle = skillsInSeparateTab ? 'style="opacity: 0.5; cursor: not-allowed;"' : '';
html += `<div class="rpg-editor-toggle-row" ${skillsToggleStyle}>`;
html += `<input type="checkbox" id="rpg-skills-enabled" ${config.skillsSection.enabled ? 'checked' : ''} ${skillsToggleDisabled}>`;
html += `<label for="rpg-skills-enabled">${i18n.getTranslation('template.trackerEditorModal.userStatsTab.enableSkillsSection')}</label>`;
html += '</div>';
// Show note when skills are in separate tab
if (skillsInSeparateTab) {
html += `<small style="display: block; margin-top: -8px; margin-bottom: 12px; color: #888; font-size: 11px;" data-i18n-key="template.trackerEditorModal.userStatsTab.skillsInSeparateTabNote">${i18n.getTranslation('template.trackerEditorModal.userStatsTab.skillsInSeparateTabNote')}</small>`;
}
html += `<label>${i18n.getTranslation('template.trackerEditorModal.userStatsTab.skillsLabelLabel')}</label>`;
html += `<input type="text" id="rpg-skills-label" value="${config.skillsSection.label}" class="rpg-text-input" placeholder="Skills">`;
@@ -415,17 +427,24 @@ function setupUserStatsListeners() {
// Skills section toggles
$('#rpg-skills-enabled').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.skillsSection.enabled = $(this).is(':checked');
saveSettings();
// Re-render both user stats (if skills shown there) and skills section
renderUserStats();
renderSkills();
});
$('#rpg-skills-label').off('blur').on('blur', function() {
extensionSettings.trackerConfig.userStats.skillsSection.label = $(this).val();
saveSettings();
renderUserStats();
});
$('#rpg-skills-fields').off('blur').on('blur', function() {
const fields = $(this).val().split(',').map(f => f.trim()).filter(f => f);
extensionSettings.trackerConfig.userStats.skillsSection.customFields = fields;
saveSettings();
// Re-render skills section when skills list changes
renderSkills();
});
}
+455
View File
@@ -0,0 +1,455 @@
/**
* Tracker Data Types - Unified JSON Schema
* Structure adapts dynamically based on trackerConfig settings
*
* TODO: Future enhancements:
* - Generate formal JSON Schema for prompting (helps LLMs understand structure better)
* - Validate LLM responses against schema
* - In SEPARATE mode, retry generation if schema validation fails
* - Could use libraries like ajv for validation
*/
/**
* @typedef {Object} TrackerItem
* @property {string} name - Item name
* @property {string} description - Item description
* @property {string} [grantsSkill] - Optional: skill name this item grants
*/
/**
* @typedef {Object} TrackerSkill
* @property {string} name - Skill/ability name
* @property {string} description - Skill description
* @property {string} [grantedBy] - Optional: item name that grants this skill
*/
/**
* @typedef {Object} TrackerCharacter
* @property {string} name - Character name
* @property {string} [relationship] - Relationship type
* @property {Object.<string, string>} [fields] - Dynamic custom fields (appearance, demeanor, etc.)
* @property {string} [thoughts] - Character's inner thoughts
*/
/**
* @typedef {Object} TrackerQuest
* @property {string} name - Quest name/title
* @property {string} description - Quest description/objective
*/
/**
* @typedef {Object.<string, number>} TrackerStats
* Dynamic stats object - keys are stat names from config, values are percentages
* Example: { "Health": 85, "Energy": 70, "Custom Stat": 50 }
*/
/**
* @typedef {Object} TrackerStatus
* @property {string} [mood] - Mood emoji (if enabled)
* @property {Object.<string, string>} [fields] - Dynamic custom fields from config
*/
/**
* @typedef {Object} TrackerInfoBox
* Dynamic based on enabled widgets in config
* @property {string} [date] - Current date (if widget enabled)
* @property {string} [time] - Time range (if widget enabled)
* @property {string} [weather] - Weather with emoji (if widget enabled)
* @property {string} [temperature] - Temperature (if widget enabled)
* @property {string} [location] - Scene location (if widget enabled)
* @property {string} [recentEvents] - Recent events summary (if widget enabled)
*/
/**
* @typedef {Object} TrackerInventory
* @property {TrackerItem[]} onPerson - Items carried/worn
* @property {Object.<string, TrackerItem[]>} stored - Items stored at locations
* @property {TrackerItem[]} assets - Major possessions (vehicles, property)
*/
/**
* @typedef {Object.<string, TrackerSkill[]>} TrackerSkills
* Key is skill category name from config, value is array of abilities
*/
/**
* @typedef {Object} TrackerQuests
* @property {TrackerQuest|null} main - Main quest or null
* @property {TrackerQuest[]} optional - Optional quests
*/
/**
* Complete tracker data structure from LLM
* All fields are optional - only enabled sections are included
* @typedef {Object} TrackerData
* @property {TrackerStats} [stats] - Numeric stats (based on config)
* @property {TrackerStatus} [status] - Status info (mood, custom fields)
* @property {TrackerInfoBox} [infoBox] - Scene information (based on enabled widgets)
* @property {TrackerCharacter[]} [characters] - Present characters
* @property {TrackerInventory} [inventory] - Player inventory
* @property {TrackerSkills} [skills] - Player skills by category
* @property {TrackerQuests} [quests] - Active quests
*/
export const TRACKER_DATA_VERSION = 3;
/**
* Creates empty tracker data based on current config
* @param {Object} trackerConfig - The tracker configuration
* @returns {TrackerData}
*/
export function createEmptyTrackerData(trackerConfig) {
const data = {};
// Stats based on config
if (trackerConfig?.userStats?.customStats) {
data.stats = {};
for (const stat of trackerConfig.userStats.customStats) {
if (stat.enabled) {
data.stats[stat.name] = 100;
}
}
}
// Status
data.status = { mood: '😐', fields: {} };
// Info box based on enabled widgets
if (trackerConfig?.infoBox?.widgets) {
data.infoBox = {};
}
// Characters
data.characters = [];
// Inventory
data.inventory = {
onPerson: [],
stored: {},
assets: []
};
// Skills based on config categories
data.skills = {};
if (trackerConfig?.userStats?.skillsSection?.customFields) {
for (const category of trackerConfig.userStats.skillsSection.customFields) {
data.skills[category] = [];
}
}
// Quests
data.quests = {
main: null,
optional: []
};
return data;
}
/**
* Generates a JSON schema example based on tracker config
* Used in prompts to show LLM the expected format
* @param {Object} trackerConfig - The tracker configuration
* @param {Object} options - Generation options
* @returns {Object} Example JSON object
*/
export function generateSchemaExample(trackerConfig, options = {}) {
const example = {};
const {
includeStats = true,
includeInfoBox = true,
includeCharacters = true,
includeInventory = true,
includeSkills = true,
includeQuests = true,
enableItemSkillLinks = false
} = options;
// Stats section
if (includeStats && trackerConfig?.userStats?.customStats) {
example.stats = {};
for (const stat of trackerConfig.userStats.customStats) {
if (stat.enabled) {
example.stats[stat.name] = 75; // Example value
}
}
// Status fields
if (trackerConfig.userStats.statusSection?.enabled) {
example.status = {};
if (trackerConfig.userStats.statusSection.showMoodEmoji) {
example.status.mood = "😊";
}
if (trackerConfig.userStats.statusSection.customFields?.length > 0) {
example.status.fields = {};
for (const field of trackerConfig.userStats.statusSection.customFields) {
example.status.fields[field] = `[${field} value]`;
}
}
}
}
// Info Box
if (includeInfoBox && trackerConfig?.infoBox?.widgets) {
example.infoBox = {};
const widgets = trackerConfig.infoBox.widgets;
if (widgets.date?.enabled) example.infoBox.date = "Monday, March 15, 1242";
if (widgets.time?.enabled) example.infoBox.time = "14:00 → 15:30";
if (widgets.weather?.enabled) example.infoBox.weather = "☀️ Sunny";
if (widgets.temperature?.enabled) {
const unit = widgets.temperature.unit === 'F' ? '°F' : '°C';
example.infoBox.temperature = `22${unit}`;
}
if (widgets.location?.enabled) example.infoBox.location = "Forest Clearing";
if (widgets.recentEvents?.enabled) example.infoBox.recentEvents = "The party arrived at dawn";
}
// Characters
if (includeCharacters && trackerConfig?.presentCharacters) {
const charConfig = trackerConfig.presentCharacters;
const charExample = { name: "Elena" };
if (charConfig.relationshipFields?.length > 0) {
charExample.relationship = charConfig.relationshipFields[0];
}
if (charConfig.customFields?.length > 0) {
charExample.fields = {};
for (const field of charConfig.customFields) {
if (field.enabled) {
charExample.fields[field.name] = `[${field.description || field.name}]`;
}
}
}
if (charConfig.thoughts?.enabled) {
charExample.thoughts = "I wonder what adventures await...";
}
example.characters = [charExample];
}
// Inventory
if (includeInventory) {
const itemExample = { name: "Iron Sword", description: "A sturdy blade" };
if (enableItemSkillLinks) {
itemExample.grantsSkill = "Sword Fighting";
}
example.inventory = {
onPerson: [itemExample],
stored: { "Home": [{ name: "Gold Coins", description: "50 gold pieces" }] },
assets: [{ name: "Small House", description: "A modest dwelling" }]
};
}
// Skills
if (includeSkills && trackerConfig?.userStats?.skillsSection?.customFields?.length > 0) {
example.skills = {};
for (const category of trackerConfig.userStats.skillsSection.customFields) {
const skillExample = { name: "Example Ability", description: "What this ability does" };
if (enableItemSkillLinks) {
skillExample.grantedBy = "Item Name";
}
example.skills[category] = [skillExample];
}
}
// Quests
if (includeQuests) {
example.quests = {
main: { name: "Main Quest", description: "The primary objective" },
optional: [{ name: "Side Quest", description: "An optional objective" }]
};
}
return example;
}
/**
* Validates tracker data structure
* @param {any} data - Data to validate
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateTrackerData(data) {
const errors = [];
if (!data || typeof data !== 'object') {
return { valid: false, errors: ['Data must be an object'] };
}
// Validate inventory structure if present (be flexible with LLM variations)
if (data.inventory) {
// Accept arrays or empty objects - normalize in parser
if (data.inventory.onPerson && !Array.isArray(data.inventory.onPerson) && Object.keys(data.inventory.onPerson).length > 0) {
errors.push('inventory.onPerson must be an array');
}
if (data.inventory.stored && typeof data.inventory.stored !== 'object') {
errors.push('inventory.stored must be an object');
}
// Accept arrays or empty objects for assets
if (data.inventory.assets && !Array.isArray(data.inventory.assets) && Object.keys(data.inventory.assets).length > 0) {
errors.push('inventory.assets must be an array');
}
}
// Validate skills structure if present
if (data.skills && typeof data.skills !== 'object') {
errors.push('skills must be an object');
}
// Validate quests structure if present
if (data.quests) {
if (data.quests.optional && !Array.isArray(data.quests.optional)) {
errors.push('quests.optional must be an array');
}
}
// Validate characters structure if present
if (data.characters && !Array.isArray(data.characters)) {
errors.push('characters must be an array');
}
return { valid: errors.length === 0, errors };
}
/**
* Finds all items that grant a specific skill
* @param {TrackerData} data - Tracker data
* @param {string} skillName - Skill name to search for
* @returns {TrackerItem[]}
*/
export function findItemsGrantingSkill(data, skillName) {
const items = [];
if (!data.inventory) return items;
const checkItems = (itemList) => {
if (!Array.isArray(itemList)) return;
for (const item of itemList) {
if (item.grantsSkill === skillName) {
items.push(item);
}
}
};
checkItems(data.inventory.onPerson);
checkItems(data.inventory.assets);
if (data.inventory.stored) {
for (const locationItems of Object.values(data.inventory.stored)) {
checkItems(locationItems);
}
}
return items;
}
/**
* Finds all skills granted by a specific item
* @param {TrackerData} data - Tracker data
* @param {string} itemName - Item name to search for
* @returns {Array<{category: string, skill: TrackerSkill}>}
*/
export function findSkillsGrantedByItem(data, itemName) {
const skills = [];
if (!data.skills) return skills;
for (const [category, skillList] of Object.entries(data.skills)) {
if (!Array.isArray(skillList)) continue;
for (const skill of skillList) {
if (skill.grantedBy === itemName) {
skills.push({ category, skill });
}
}
}
return skills;
}
/**
* Removes an item and optionally its linked skills from tracker data
* @param {TrackerData} data - Tracker data (mutated)
* @param {string} itemName - Item name to remove
* @param {string} location - 'onPerson', 'assets', or stored location name
* @param {boolean} removeLinkedSkills - Whether to also remove skills granted by this item
*/
export function removeItemAndLinkedSkills(data, itemName, location, removeLinkedSkills = true) {
if (!data.inventory) return;
let removedItem = null;
const removeFromList = (list) => {
if (!Array.isArray(list)) return false;
const index = list.findIndex(item => item.name === itemName);
if (index >= 0) {
removedItem = list[index];
list.splice(index, 1);
return true;
}
return false;
};
if (location === 'onPerson') {
removeFromList(data.inventory.onPerson);
} else if (location === 'assets') {
removeFromList(data.inventory.assets);
} else if (data.inventory.stored?.[location]) {
removeFromList(data.inventory.stored[location]);
}
// Remove linked skills if requested
if (removeLinkedSkills && removedItem?.grantsSkill && data.skills) {
for (const skillList of Object.values(data.skills)) {
if (!Array.isArray(skillList)) continue;
const skillIndex = skillList.findIndex(s =>
s.name === removedItem.grantsSkill && s.grantedBy === itemName
);
if (skillIndex >= 0) {
skillList.splice(skillIndex, 1);
}
}
}
}
/**
* Merges new tracker data with existing data
* New data overwrites existing fields, but preserves fields not in new data
* @param {TrackerData} existing - Existing tracker data
* @param {TrackerData} newData - New data from LLM
* @returns {TrackerData} Merged data
*/
export function mergeTrackerData(existing, newData) {
const merged = JSON.parse(JSON.stringify(existing || {}));
if (newData.stats) {
merged.stats = { ...merged.stats, ...newData.stats };
}
if (newData.status) {
merged.status = {
...merged.status,
...newData.status,
fields: { ...merged.status?.fields, ...newData.status?.fields }
};
}
if (newData.infoBox) {
merged.infoBox = { ...merged.infoBox, ...newData.infoBox };
}
if (newData.characters) {
merged.characters = newData.characters;
}
if (newData.inventory) {
merged.inventory = newData.inventory;
}
if (newData.skills) {
merged.skills = newData.skills;
}
if (newData.quests) {
merged.quests = newData.quests;
}
return merged;
}
+756
View File
@@ -6637,3 +6637,759 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
font-size: clamp(14px, 3vw, 18px) !important;
}
}
/* ============================================
SKILLS SECTION STYLES
============================================ */
.rpg-skills-container {
display: flex;
flex-direction: column;
width: 100%;
gap: 1rem;
padding: 0.5rem;
font-size: 0.9rem;
box-sizing: border-box;
}
.rpg-skills-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--SmartThemeBorderColor);
}
.rpg-skills-header h4 {
margin: 0;
font-size: 1.1rem;
color: var(--SmartThemeBodyColor);
}
.rpg-skills-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.rpg-skills-section {
margin-top: 0.75rem;
}
.rpg-skills-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
color: var(--SmartThemeBodyColor);
}
.rpg-skills-section-title i {
font-size: 0.85rem;
opacity: 0.7;
}
.rpg-skills-count {
font-size: 0.8rem;
opacity: 0.6;
}
.rpg-skills-list {
min-height: 2rem;
padding: 0.5rem;
}
/* Skills List View */
.rpg-skills-list-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rpg-skill-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: transparent;
border: 2px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
font-size: 0.95rem;
transition: all 0.2s ease;
}
.rpg-skill-row.rpg-skill-active {
border-color: var(--rpg-highlight);
}
.rpg-skill-row.rpg-skill-inactive {
opacity: 0.6;
border-style: dashed;
}
.rpg-skill-row:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
}
.rpg-skill-row .rpg-skill-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Skills Grid View */
.rpg-skills-grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.rpg-skill-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 0.75rem;
background: transparent;
border: 2px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
font-size: 0.9rem;
transition: all 0.2s ease;
min-height: 80px;
}
.rpg-skill-card.rpg-skill-active {
border-color: var(--rpg-highlight);
}
.rpg-skill-card.rpg-skill-inactive {
opacity: 0.6;
border-style: dashed;
}
.rpg-skill-card:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
}
.rpg-skill-card .rpg-skill-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.5rem;
}
.rpg-skill-card .rpg-skill-name {
text-align: center;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
max-width: 100%;
}
/* Skill Toggle Button */
.rpg-skill-toggle {
background: transparent;
border: none;
padding: 0.3rem;
cursor: pointer;
color: var(--SmartThemeFastUISliderColColor);
transition: all 0.2s ease;
font-size: 1rem;
}
.rpg-skill-toggle:hover {
color: var(--rpg-highlight);
transform: scale(1.1);
}
.rpg-skill-active .rpg-skill-toggle {
color: var(--rpg-highlight);
}
/* Skill Linked Badge */
.rpg-skill-linked-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.2);
border: 1px solid var(--rpg-highlight);
border-radius: 0.25rem;
color: var(--rpg-highlight);
}
.rpg-skill-linked-badge i {
font-size: 0.7rem;
}
/* Skills Empty State */
.rpg-skills-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: var(--SmartThemeBodyColor);
opacity: 0.7;
}
.rpg-skills-empty i {
font-size: 2rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.rpg-skills-empty p {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.rpg-skills-empty small {
font-size: 0.85rem;
opacity: 0.8;
}
/* ============================================
SKILLS CATEGORY STYLES (like Inventory)
============================================ */
.rpg-skill-category {
width: 100%;
margin-bottom: 1rem;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.5rem;
overflow: hidden;
box-sizing: border-box;
}
.rpg-skill-category-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--SmartThemeBlurTintColor);
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.rpg-skill-category-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--SmartThemeBodyColor);
}
.rpg-skill-category-title i {
color: var(--rpg-highlight);
font-size: 0.85rem;
}
.rpg-skill-category-count {
font-size: 0.8rem;
font-weight: normal;
opacity: 0.6;
}
.rpg-skill-category-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.rpg-skill-add-btn {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid var(--rpg-highlight);
border-radius: 0.25rem;
color: var(--rpg-highlight);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
}
.rpg-skill-add-btn:hover {
background: var(--rpg-highlight);
color: white;
}
.rpg-skill-category-content {
padding: 0.75rem;
}
.rpg-skill-items-empty {
padding: 1rem;
text-align: center;
color: var(--SmartThemeBodyColor);
opacity: 0.6;
font-style: italic;
}
/* ============================================
SKILL-ITEM LINKING STYLES
============================================ */
/* Skill link badge (shows linked item name in skills section) */
.rpg-skill-link-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.15);
border: 1px solid rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.3);
border-radius: 0.25rem;
color: var(--rpg-highlight);
cursor: pointer;
transition: all 0.2s ease;
margin-left: 0.5rem;
}
.rpg-skill-link-badge:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.25);
border-color: var(--rpg-highlight);
}
.rpg-skill-link-badge i {
font-size: 0.7rem;
}
.rpg-link-item-name {
max-width: 70px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Link button (on skills without a link) */
.rpg-skill-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: 1px dashed var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeFastUISliderColColor);
cursor: pointer;
transition: all 0.2s ease;
opacity: 0.5;
margin-left: 0.5rem;
}
.rpg-skill-link-btn:hover {
opacity: 1;
border-style: solid;
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
/* Unlink button */
.rpg-skill-unlink-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
color: var(--SmartThemeFastUISliderColColor);
cursor: pointer;
transition: all 0.2s ease;
opacity: 0.5;
margin-left: 0.25rem;
}
.rpg-skill-unlink-btn:hover {
opacity: 1;
color: #e94560;
}
/* Skill link indicator on inventory items */
.rpg-item-skill-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.15);
border: 1px solid rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.3);
border-radius: 50%;
color: var(--rpg-highlight);
cursor: pointer;
transition: all 0.2s ease;
margin-left: 0.5rem;
flex-shrink: 0;
}
.rpg-item-skill-link:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.3);
transform: scale(1.1);
}
.rpg-item-skill-link i {
font-size: 0.7rem;
}
/* Items/abilities with links get a subtle highlight */
.rpg-has-skill-link,
.rpg-has-link {
border-left: 2px solid var(--rpg-highlight) !important;
}
/* Link dropdown for selecting items */
.rpg-link-dropdown {
min-width: 200px;
max-width: 300px;
max-height: 300px;
background: var(--SmartThemeBlurTintColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.rpg-link-dropdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--SmartThemeBorderColor);
font-weight: 600;
font-size: 0.85rem;
}
.rpg-link-dropdown-close {
background: transparent;
border: none;
color: var(--SmartThemeBodyColor);
cursor: pointer;
padding: 0.25rem;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.rpg-link-dropdown-close:hover {
opacity: 1;
}
.rpg-link-dropdown-list {
max-height: 220px;
overflow-y: auto;
}
.rpg-link-dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
cursor: pointer;
transition: background 0.2s ease;
font-size: 0.85rem;
}
.rpg-link-dropdown-item:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.15);
}
.rpg-link-dropdown-item i {
color: var(--rpg-highlight);
opacity: 0.7;
}
/* Highlight animation for items */
@keyframes rpg-highlight-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(233, 69, 96, 0);
background: rgba(233, 69, 96, 0.05);
}
50% {
box-shadow: 0 0 8px 2px rgba(233, 69, 96, 0.3);
background: rgba(233, 69, 96, 0.12);
}
}
.rpg-highlight-item {
animation: rpg-highlight-pulse 1.2s ease-in-out 2 !important;
border-left: 3px solid #e94560 !important;
position: relative;
}
/* Card actions container for grid view */
.rpg-card-actions {
position: absolute;
top: 0.25rem;
right: 0.25rem;
display: flex;
gap: 0.25rem;
}
/* ============================================
STRUCTURED INVENTORY ITEMS (name + description)
============================================ */
.rpg-inventory-container.rpg-structured .rpg-item-row {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
padding: 0.75rem;
}
.rpg-inventory-container.rpg-structured .rpg-item-row .rpg-item-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.rpg-inventory-container.rpg-structured .rpg-item-row .rpg-item-name {
font-weight: 600;
flex-shrink: 0;
}
.rpg-inventory-container.rpg-structured .rpg-item-description {
font-size: 0.85em;
color: var(--SmartThemeBodyColor);
opacity: 0.7;
font-style: italic;
width: 100%;
min-height: 1.2em;
}
.rpg-inventory-container.rpg-structured .rpg-item-description:empty::before {
content: "No description";
opacity: 0.4;
}
.rpg-inventory-container.rpg-structured .rpg-item-card {
flex-direction: column;
padding: 0.75rem;
gap: 0.5rem;
}
.rpg-inventory-container.rpg-structured .rpg-item-card .rpg-item-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: center;
}
.rpg-inventory-container.rpg-structured .rpg-item-card .rpg-item-name {
font-weight: 600;
}
.rpg-inventory-container.rpg-structured .rpg-item-card .rpg-item-description {
font-size: 0.8em;
opacity: 0.7;
font-style: italic;
}
/* Grants skill badge */
.rpg-item-grants-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.2);
border-radius: 50%;
color: var(--rpg-highlight);
font-size: 0.65rem;
margin-left: 0.25rem;
cursor: help;
}
/* Storage location styling */
.rpg-storage-location {
margin-bottom: 1rem;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.5rem;
overflow: hidden;
}
.rpg-location-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--SmartThemeBlurTintColor);
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.rpg-location-name {
font-weight: 600;
}
.rpg-location-count {
font-size: 0.85em;
opacity: 0.6;
}
/* ============================================
STRUCTURED SKILLS (name + description)
============================================ */
.rpg-item-row.rpg-structured {
flex-direction: row;
align-items: flex-start;
gap: 0.5rem;
}
/* Skill ability row layout */
.rpg-item-row.rpg-structured {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.rpg-skill-ability-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.rpg-skill-ability-row .rpg-item-name {
flex: 1 1 auto;
font-weight: 600;
min-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Link badge should shrink before the skill name */
.rpg-skill-ability-row .rpg-skill-link-badge {
flex-shrink: 1;
margin-left: 0;
}
.rpg-skill-ability-desc-row {
width: 100%;
padding-left: 0.25rem;
}
.rpg-skill-ability-desc-row .rpg-item-description {
display: block;
width: 100%;
font-size: 0.85em;
color: var(--SmartThemeBodyColor);
opacity: 0.7;
font-style: italic;
min-height: 1.2em;
}
.rpg-skill-ability-desc-row .rpg-item-description:empty::before {
content: "Add description...";
opacity: 0.4;
}
.rpg-item-card.rpg-structured .rpg-skill-ability-desc-row {
text-align: center;
}
.rpg-item-card.rpg-structured .rpg-item-description {
font-size: 0.8em;
opacity: 0.7;
}
/* Inventory item row layout with descriptions */
.rpg-item-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.rpg-item-main-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.rpg-item-main-row .rpg-item-name {
flex: 1;
min-width: 0;
}
.rpg-item-desc-row {
width: 100%;
padding-left: 0.25rem;
}
.rpg-item-desc-row .rpg-item-description {
display: block;
width: 100%;
font-size: 0.85em;
color: var(--SmartThemeBodyColor);
opacity: 0.7;
font-style: italic;
min-height: 1.2em;
}
.rpg-item-desc-row .rpg-item-description:empty::before {
content: "Add description...";
opacity: 0.4;
}
.rpg-item-card .rpg-item-desc-row {
text-align: center;
font-style: italic;
}
/* ============================================
STRUCTURED QUESTS (name + description)
============================================ */
.rpg-quest-item.rpg-structured {
flex-direction: row;
align-items: flex-start;
}
.rpg-quest-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 0.25rem;
}
.rpg-quest-info .rpg-quest-title {
font-weight: 600;
}
.rpg-quest-description {
font-size: 0.85em;
color: var(--SmartThemeBodyColor);
opacity: 0.7;
font-style: italic;
min-height: 1.2em;
}
.rpg-quest-description:empty::before {
content: "Add description...";
opacity: 0.4;
}
.rpg-quest-description.rpg-editable {
cursor: text;
}
+32
View File
@@ -48,6 +48,14 @@
<!-- Divider after Thoughts -->
<div id="rpg-divider-thoughts" class="rpg-divider"></div>
<!-- Skills Section -->
<div id="rpg-skills" class="rpg-section rpg-skills-section">
<!-- Content will be populated by JavaScript -->
</div>
<!-- Divider after Skills -->
<div id="rpg-divider-skills" class="rpg-divider"></div>
<!-- Inventory Section -->
<div id="rpg-inventory" class="rpg-section rpg-inventory-section">
<!-- Content will be populated by JavaScript -->
@@ -193,6 +201,30 @@
Single flat list instead of On Person / Stored / Assets categories
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-skills" />
<span data-i18n-key="template.settingsModal.display.showSkills">Show Skills Section</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" data-i18n-key="template.settingsModal.display.showSkillsNote">
Displays skills as a separate tab instead of within Status. Configure skills in Edit Trackers.
</small>
<label class="checkbox_label" style="margin-left: 24px;">
<input type="checkbox" id="rpg-toggle-item-skill-links" />
<span data-i18n-key="template.settingsModal.display.enableItemSkillLinks">Enable Item-Skill Links</span>
</label>
<small style="display: block; margin-left: 48px; margin-top: -8px; color: #888; font-size: 11px;" data-i18n-key="template.settingsModal.display.enableItemSkillLinksNote">
Items can grant skills. Removing an item unlinks or removes the skill.
</small>
<label class="checkbox_label" style="margin-left: 48px;">
<input type="checkbox" id="rpg-toggle-delete-skill-with-item" />
<span data-i18n-key="template.settingsModal.display.deleteSkillWithItem">Delete skill when item removed</span>
</label>
<small style="display: block; margin-left: 72px; margin-top: -8px; color: #888; font-size: 11px;" data-i18n-key="template.settingsModal.display.deleteSkillWithItemNote">
When disabled (default), removing an item just unlinks the skill. When enabled, the skill is deleted.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-quests" />
<span data-i18n-key="template.settingsModal.display.showQuests">Show Quests</span>
+355
View File
@@ -0,0 +1,355 @@
/**
* RPG Companion Integration Test Helper
*
* This module provides functions for testing JSON parsing and validation
* within SillyTavern. It can be loaded in the browser console or integrated
* into the extension's debug mode.
*
* Usage in browser console:
* 1. Open SillyTavern with RPG Companion enabled
* 2. Open browser dev tools (F12)
* 3. Copy/paste this file's contents into console
* 4. Run: RPGTestHelper.validateLastResponse() or other methods
*/
window.RPGTestHelper = {
/**
* Validates the last generated tracker data against expected JSON structure
*/
validateLastResponse() {
console.log('🔍 Validating last generated tracker data...\n');
// Access extension settings (this assumes RPG Companion is loaded)
const settings = window.extension_settings?.['rpg-companion'];
if (!settings) {
console.error('❌ RPG Companion not found in extension_settings');
return false;
}
const results = {
inventoryV3: this.validateInventory(settings.inventoryV3),
skillsV2: this.validateSkills(settings.skillsV2),
questsV2: this.validateQuests(settings.questsV2),
infoBoxData: this.validateInfoBox(settings.infoBoxData),
charactersData: this.validateCharacters(settings.charactersData)
};
console.log('\n📊 Validation Results:');
Object.entries(results).forEach(([key, valid]) => {
console.log(` ${valid ? '✅' : '❌'} ${key}`);
});
return Object.values(results).every(v => v);
},
/**
* Validates inventory structure
*/
validateInventory(inv) {
console.log('\n📦 Validating Inventory...');
if (!inv) {
console.log(' ⚠️ inventoryV3 is null/undefined');
return true; // Not an error if not populated yet
}
let valid = true;
// Check onPerson array
if (inv.onPerson && !Array.isArray(inv.onPerson)) {
console.log(' ❌ onPerson should be an array');
valid = false;
} else if (inv.onPerson?.length > 0) {
const item = inv.onPerson[0];
if (typeof item !== 'object' || !item.name) {
console.log(' ❌ onPerson items should be objects with name property');
valid = false;
} else {
console.log(` ✅ onPerson: ${inv.onPerson.length} items (e.g., "${item.name}")`);
}
}
// Check stored object
if (inv.stored && typeof inv.stored !== 'object') {
console.log(' ❌ stored should be an object');
valid = false;
} else if (inv.stored) {
const locations = Object.keys(inv.stored);
console.log(` ✅ stored: ${locations.length} locations`);
}
// Check assets array
if (inv.assets && !Array.isArray(inv.assets)) {
console.log(' ❌ assets should be an array');
valid = false;
} else if (inv.assets?.length > 0) {
console.log(` ✅ assets: ${inv.assets.length} items`);
}
// Check simplified array
if (inv.simplified && !Array.isArray(inv.simplified)) {
console.log(' ❌ simplified should be an array');
valid = false;
} else if (inv.simplified?.length > 0) {
console.log(` ✅ simplified: ${inv.simplified.length} items`);
}
return valid;
},
/**
* Validates skills structure
*/
validateSkills(skills) {
console.log('\n⚔️ Validating Skills...');
if (!skills) {
console.log(' ⚠️ skillsV2 is null/undefined');
return true;
}
if (typeof skills !== 'object') {
console.log(' ❌ skillsV2 should be an object');
return false;
}
let valid = true;
for (const [category, abilities] of Object.entries(skills)) {
if (!Array.isArray(abilities)) {
console.log(`${category} should be an array`);
valid = false;
continue;
}
abilities.forEach((ability, i) => {
if (typeof ability !== 'object' || !ability.name) {
console.log(`${category}[${i}] should be an object with name`);
valid = false;
}
});
console.log(`${category}: ${abilities.length} abilities`);
}
return valid;
},
/**
* Validates quests structure
*/
validateQuests(quests) {
console.log('\n📜 Validating Quests...');
if (!quests) {
console.log(' ⚠️ questsV2 is null/undefined');
return true;
}
let valid = true;
if (quests.main !== null && quests.main !== undefined) {
if (typeof quests.main === 'string') {
console.log(` ✅ main: "${quests.main}"`);
} else if (typeof quests.main === 'object' && quests.main.name) {
console.log(` ✅ main: "${quests.main.name}" (structured)`);
} else {
console.log(' ❌ main should be string or {name, description}');
valid = false;
}
}
if (quests.optional) {
if (!Array.isArray(quests.optional)) {
console.log(' ❌ optional should be an array');
valid = false;
} else {
console.log(` ✅ optional: ${quests.optional.length} quests`);
}
}
return valid;
},
/**
* Validates info box structure
*/
validateInfoBox(info) {
console.log('\n📍 Validating Info Box...');
if (!info) {
console.log(' ⚠️ infoBoxData is null/undefined');
return true;
}
const fields = ['date', 'weather', 'temperature', 'time', 'location'];
let valid = true;
fields.forEach(field => {
if (info[field] !== undefined && info[field] !== null) {
if (typeof info[field] !== 'string') {
console.log(`${field} should be a string`);
valid = false;
} else {
console.log(`${field}: "${info[field]}"`);
}
}
});
if (info.recentEvents) {
if (!Array.isArray(info.recentEvents)) {
console.log(' ❌ recentEvents should be an array');
valid = false;
} else {
console.log(` ✅ recentEvents: ${info.recentEvents.length} events`);
}
}
return valid;
},
/**
* Validates characters structure
*/
validateCharacters(chars) {
console.log('\n👥 Validating Characters...');
if (!chars) {
console.log(' ⚠️ charactersData is null/undefined');
return true;
}
if (!Array.isArray(chars)) {
console.log(' ❌ charactersData should be an array');
return false;
}
let valid = true;
chars.forEach((char, i) => {
if (typeof char !== 'object' || !char.name) {
console.log(` ❌ character[${i}] should have name`);
valid = false;
} else {
console.log(`${char.name}: ${char.relationship || 'no relationship'}`);
}
});
return valid;
},
/**
* Tests JSON extraction from a raw response string
*/
testJSONExtraction(responseText) {
console.log('\n🔬 Testing JSON Extraction...\n');
const jsonRegex = /```(?:json)?\s*([\s\S]*?)```/i;
const match = responseText.match(jsonRegex);
if (!match) {
console.log('❌ No JSON code block found');
return null;
}
console.log('✅ Found JSON code block');
try {
const parsed = JSON.parse(match[1].trim());
console.log('✅ JSON parsed successfully');
console.log('📋 Structure:', Object.keys(parsed).join(', '));
return parsed;
} catch (e) {
console.log('❌ JSON parse failed:', e.message);
// Try to fix common issues
console.log('🔧 Attempting to fix JSON...');
const fixed = match[1].trim()
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']');
try {
const fixedParsed = JSON.parse(fixed);
console.log('✅ Fixed JSON parsed successfully');
return fixedParsed;
} catch (e2) {
console.log('❌ Could not fix JSON:', e2.message);
return null;
}
}
},
/**
* Simulates a full parse cycle with a sample response
*/
simulateParseResponse(sampleResponse) {
console.log('\n🔄 Simulating Parse Response...\n');
const parsed = this.testJSONExtraction(sampleResponse);
if (parsed) {
console.log('\n📊 Validating parsed structure:');
if (parsed.userStats) {
console.log(' ✅ userStats present');
}
if (parsed.skills) {
console.log(' ✅ skills present');
}
if (parsed.inventory) {
console.log(' ✅ inventory present');
}
if (parsed.quests) {
console.log(' ✅ quests present');
}
if (parsed.infoBox) {
console.log(' ✅ infoBox present');
}
if (parsed.presentCharacters) {
console.log(' ✅ presentCharacters present');
}
}
return parsed;
},
/**
* Prints current extension settings for debugging
*/
printCurrentState() {
const settings = window.extension_settings?.['rpg-companion'];
if (!settings) {
console.error('RPG Companion not found');
return;
}
console.log('📋 Current RPG Companion State:\n');
console.log('inventoryV3:', JSON.stringify(settings.inventoryV3, null, 2));
console.log('skillsV2:', JSON.stringify(settings.skillsV2, null, 2));
console.log('questsV2:', JSON.stringify(settings.questsV2, null, 2));
console.log('infoBoxData:', JSON.stringify(settings.infoBoxData, null, 2));
console.log('charactersData:', JSON.stringify(settings.charactersData, null, 2));
},
/**
* Help message
*/
help() {
console.log(`
🧪 RPG Companion Test Helper Commands:
RPGTestHelper.validateLastResponse() - Validate current structured data
RPGTestHelper.testJSONExtraction(text) - Test JSON extraction from text
RPGTestHelper.simulateParseResponse(text) - Full parse simulation
RPGTestHelper.printCurrentState() - Print current extension state
RPGTestHelper.help() - Show this help message
Example:
RPGTestHelper.validateLastResponse()
`);
}
};
// Print help on load
console.log('🧪 RPG Companion Test Helper loaded. Run RPGTestHelper.help() for commands.');
+504
View File
@@ -0,0 +1,504 @@
/**
* JSON Format Tests for RPG Companion
*
* These tests can be run in two ways:
* 1. In browser console: Copy/paste or load as module in SillyTavern
* 2. Via Node.js: Run with `node tests/jsonFormat.test.js`
*
* Tests cover:
* - JSON prompt generation
* - JSON response parsing
* - Data structure validation
*/
// Mock SillyTavern context for Node.js testing
const isBrowser = typeof window !== 'undefined';
// Sample mock data for testing
const mockTrackerConfig = {
userStats: {
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'energy', name: 'Energy', enabled: true }
],
showRPGAttributes: true,
rpgAttributes: [
{ id: 'str', name: 'Strength', enabled: true },
{ id: 'dex', name: 'Dexterity', enabled: true }
],
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions']
},
skillsSection: {
enabled: true,
customFields: ['Combat', 'Stealth', 'Magic']
}
},
infoBox: {
widgets: {
date: { enabled: true },
weather: { enabled: true },
temperature: { enabled: true, unit: 'C' },
time: { enabled: true },
location: { enabled: true },
recentEvents: { enabled: true }
}
},
presentCharacters: {
showEmoji: true,
relationshipFields: ['Enemy', 'Neutral', 'Friend', 'Lover'],
customFields: [
{ id: 'appearance', name: 'Appearance', enabled: true },
{ id: 'demeanor', name: 'Demeanor', enabled: true }
],
thoughts: { enabled: true, name: 'Thoughts' },
characterStats: {
enabled: true,
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
]
}
}
};
// Sample JSON responses for testing parser
const sampleValidJSONResponse = `
Here's an interesting development in the story...
\`\`\`json
{
"userStats": {
"health": 85,
"energy": 60,
"str": 14,
"dex": 12,
"status": {
"mood": "😊",
"conditions": "Well-rested"
}
},
"skills": {
"combat": [
{ "name": "Sword Fighting", "description": "Basic melee combat with swords", "linkedItem": "Iron Sword" },
{ "name": "Parry", "description": "Deflect incoming attacks" }
],
"stealth": [
{ "name": "Sneak", "description": "Move quietly" }
],
"magic": []
},
"inventory": {
"onPerson": [
{ "name": "Iron Sword", "description": "A sturdy blade" },
{ "name": "Leather Armor", "description": "Basic protection" },
{ "name": "Health Potion", "description": "Restores 50 HP" }
],
"stored": {
"Backpack": [
{ "name": "Rope", "description": "50 feet of hemp rope" }
]
},
"assets": [
{ "name": "Small Cottage", "description": "A humble dwelling in the village" }
]
},
"quests": {
"main": "Find the Lost Artifact",
"optional": ["Gather herbs for the healer", "Clear the rat infestation"]
},
"infoBox": {
"date": "15th of Sunstone, Year 1423",
"weather": "☀️ Sunny",
"temperature": "22°C",
"time": "Midday",
"location": "Village Square",
"recentEvents": ["Met the village elder", "Bought supplies"]
},
"presentCharacters": [
{
"name": "Elena",
"description": "A young healer with kind eyes",
"emoji": "😊",
"relationship": "Friend",
"stats": { "health": 100, "arousal": 10 },
"appearance": "Long brown hair, green robes",
"demeanor": "Cheerful and helpful",
"thoughts": "I hope they can help me find the rare herbs..."
}
]
}
\`\`\`
The village was bustling with activity...
`;
const sampleMalformedJSONResponse = `
Some story text here...
\`\`\`json
{
"userStats": {
"health": 85,
"energy": 60,
},
"inventory": {
"onPerson": [
{ "name": "Sword", "description": "Sharp" }
]
}
}
\`\`\`
`;
const sampleSimplifiedInventoryResponse = `
\`\`\`json
{
"userStats": {
"health": 75,
"energy": 50
},
"inventory": {
"simplified": [
{ "name": "Magic Staff", "description": "Channels arcane energy" },
{ "name": "Spell Book", "description": "Contains basic spells" },
{ "name": "Mana Potion", "description": "Restores magical energy" }
]
}
}
\`\`\`
`;
// Test results accumulator
const testResults = {
passed: 0,
failed: 0,
errors: []
};
/**
* Simple assertion helper
*/
function assert(condition, message) {
if (condition) {
testResults.passed++;
console.log(`✅ PASS: ${message}`);
} else {
testResults.failed++;
testResults.errors.push(message);
console.error(`❌ FAIL: ${message}`);
}
}
/**
* Test JSON extraction from markdown code blocks
*/
function testJSONExtraction() {
console.log('\n📋 Testing JSON Extraction from Code Blocks...\n');
// Test 1: Extract valid JSON from code block
const jsonRegex = /```(?:json)?\s*([\s\S]*?)```/i;
const match = sampleValidJSONResponse.match(jsonRegex);
assert(match !== null, 'Should find JSON code block in response');
if (match) {
let parsed;
try {
parsed = JSON.parse(match[1].trim());
assert(true, 'Should parse extracted JSON successfully');
assert(parsed.userStats !== undefined, 'Parsed JSON should have userStats');
assert(parsed.inventory !== undefined, 'Parsed JSON should have inventory');
assert(parsed.skills !== undefined, 'Parsed JSON should have skills');
assert(parsed.quests !== undefined, 'Parsed JSON should have quests');
assert(parsed.infoBox !== undefined, 'Parsed JSON should have infoBox');
assert(parsed.presentCharacters !== undefined, 'Parsed JSON should have presentCharacters');
} catch (e) {
assert(false, `Should not throw parsing error: ${e.message}`);
}
}
// Test 2: Handle malformed JSON (trailing comma)
const malformedMatch = sampleMalformedJSONResponse.match(jsonRegex);
assert(malformedMatch !== null, 'Should find malformed JSON code block');
if (malformedMatch) {
try {
JSON.parse(malformedMatch[1].trim());
assert(false, 'Malformed JSON should throw parsing error');
} catch (e) {
assert(true, 'Malformed JSON correctly throws parsing error');
// Test JSON fixing (remove trailing commas)
const fixed = malformedMatch[1].trim()
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']');
try {
const fixedParsed = JSON.parse(fixed);
assert(fixedParsed.userStats.health === 85, 'Fixed JSON should parse correctly');
} catch (e2) {
assert(false, `JSON fixing should work: ${e2.message}`);
}
}
}
}
/**
* Test inventory data structure validation
*/
function testInventoryStructure() {
console.log('\n📦 Testing Inventory Structure...\n');
const jsonRegex = /```(?:json)?\s*([\s\S]*?)```/i;
const match = sampleValidJSONResponse.match(jsonRegex);
const data = JSON.parse(match[1].trim());
const inv = data.inventory;
// Test onPerson array structure
assert(Array.isArray(inv.onPerson), 'onPerson should be an array');
assert(inv.onPerson.length > 0, 'onPerson should have items');
assert(inv.onPerson[0].name !== undefined, 'Items should have name property');
assert(inv.onPerson[0].description !== undefined, 'Items should have description property');
// Test stored object structure
assert(typeof inv.stored === 'object', 'stored should be an object');
assert(inv.stored.Backpack !== undefined, 'stored should have location keys');
assert(Array.isArray(inv.stored.Backpack), 'stored locations should be arrays');
// Test assets array
assert(Array.isArray(inv.assets), 'assets should be an array');
// Test simplified inventory
const simplifiedMatch = sampleSimplifiedInventoryResponse.match(jsonRegex);
const simplifiedData = JSON.parse(simplifiedMatch[1].trim());
assert(Array.isArray(simplifiedData.inventory.simplified), 'simplified inventory should be an array');
}
/**
* Test skills data structure validation
*/
function testSkillsStructure() {
console.log('\n⚔️ Testing Skills Structure...\n');
const jsonRegex = /```(?:json)?\s*([\s\S]*?)```/i;
const match = sampleValidJSONResponse.match(jsonRegex);
const data = JSON.parse(match[1].trim());
const skills = data.skills;
// Test skill categories
assert(typeof skills === 'object', 'skills should be an object');
assert(skills.combat !== undefined, 'skills should have combat category');
assert(Array.isArray(skills.combat), 'skill categories should be arrays');
// Test ability structure
const ability = skills.combat[0];
assert(ability.name !== undefined, 'Abilities should have name');
assert(ability.description !== undefined, 'Abilities should have description');
// Test linked item
assert(ability.linkedItem === 'Iron Sword', 'First combat ability should be linked to Iron Sword');
assert(skills.combat[1].linkedItem === undefined || skills.combat[1].linkedItem === null,
'Second combat ability should not have linkedItem');
}
/**
* Test quests data structure
*/
function testQuestsStructure() {
console.log('\n📜 Testing Quests Structure...\n');
const jsonRegex = /```(?:json)?\s*([\s\S]*?)```/i;
const match = sampleValidJSONResponse.match(jsonRegex);
const data = JSON.parse(match[1].trim());
const quests = data.quests;
assert(typeof quests.main === 'string', 'main quest should be a string');
assert(Array.isArray(quests.optional), 'optional quests should be an array');
assert(quests.optional.length === 2, 'Should have 2 optional quests');
}
/**
* Test characters data structure
*/
function testCharactersStructure() {
console.log('\n👥 Testing Characters Structure...\n');
const jsonRegex = /```(?:json)?\s*([\s\S]*?)```/i;
const match = sampleValidJSONResponse.match(jsonRegex);
const data = JSON.parse(match[1].trim());
const chars = data.presentCharacters;
assert(Array.isArray(chars), 'presentCharacters should be an array');
assert(chars.length > 0, 'Should have at least one character');
const char = chars[0];
assert(char.name === 'Elena', 'Character should have name');
assert(char.description !== undefined, 'Character should have description');
assert(char.emoji !== undefined, 'Character should have emoji');
assert(char.relationship !== undefined, 'Character should have relationship');
assert(char.stats !== undefined, 'Character should have stats');
assert(char.thoughts !== undefined, 'Character should have thoughts');
}
/**
* Test info box data structure
*/
function testInfoBoxStructure() {
console.log('\n📍 Testing Info Box Structure...\n');
const jsonRegex = /```(?:json)?\s*([\s\S]*?)```/i;
const match = sampleValidJSONResponse.match(jsonRegex);
const data = JSON.parse(match[1].trim());
const info = data.infoBox;
assert(typeof info.date === 'string', 'date should be a string');
assert(typeof info.weather === 'string', 'weather should be a string');
assert(typeof info.temperature === 'string', 'temperature should be a string');
assert(typeof info.time === 'string', 'time should be a string');
assert(typeof info.location === 'string', 'location should be a string');
assert(Array.isArray(info.recentEvents), 'recentEvents should be an array');
}
/**
* Test JSON prompt schema generation (mock)
*/
function testPromptSchemaGeneration() {
console.log('\n📝 Testing Prompt Schema Generation...\n');
// This tests the expected schema structure that generateJSONTrackerInstructions should produce
const expectedSchemaProperties = [
'userStats',
'skills',
'inventory',
'quests',
'infoBox',
'presentCharacters'
];
// Mock schema generation based on config
const schema = {
type: 'object',
properties: {}
};
// User stats
if (mockTrackerConfig.userStats) {
schema.properties.userStats = { type: 'object', properties: {} };
// Custom stats
mockTrackerConfig.userStats.customStats.forEach(stat => {
if (stat.enabled) {
schema.properties.userStats.properties[stat.id] = {
type: 'integer',
minimum: 0,
maximum: 100
};
}
});
// RPG attributes
if (mockTrackerConfig.userStats.showRPGAttributes) {
mockTrackerConfig.userStats.rpgAttributes.forEach(attr => {
if (attr.enabled) {
schema.properties.userStats.properties[attr.id] = {
type: 'integer',
minimum: 1
};
}
});
}
}
// Skills
if (mockTrackerConfig.userStats.skillsSection?.enabled) {
schema.properties.skills = { type: 'object', properties: {} };
mockTrackerConfig.userStats.skillsSection.customFields.forEach(field => {
const fieldId = field.toLowerCase();
schema.properties.skills.properties[fieldId] = {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
linkedItem: { type: 'string', nullable: true }
}
}
};
});
}
// Inventory
schema.properties.inventory = {
type: 'object',
properties: {
onPerson: { type: 'array' },
stored: { type: 'object' },
assets: { type: 'array' }
}
};
// Validate schema structure
assert(schema.properties.userStats !== undefined, 'Schema should include userStats');
assert(schema.properties.userStats.properties.health !== undefined, 'Schema should include health stat');
assert(schema.properties.skills !== undefined, 'Schema should include skills');
assert(schema.properties.skills.properties.combat !== undefined, 'Schema should include combat skill');
assert(schema.properties.inventory !== undefined, 'Schema should include inventory');
console.log('Generated schema structure:', JSON.stringify(schema, null, 2).substring(0, 500) + '...');
}
/**
* Run all tests
*/
function runAllTests() {
console.log('🧪 RPG Companion JSON Format Tests\n');
console.log('='.repeat(50));
try {
testJSONExtraction();
testInventoryStructure();
testSkillsStructure();
testQuestsStructure();
testCharactersStructure();
testInfoBoxStructure();
testPromptSchemaGeneration();
} catch (e) {
console.error('💥 Test suite error:', e);
testResults.failed++;
testResults.errors.push(e.message);
}
console.log('\n' + '='.repeat(50));
console.log(`\n📊 Results: ${testResults.passed} passed, ${testResults.failed} failed`);
if (testResults.errors.length > 0) {
console.log('\n❌ Failed tests:');
testResults.errors.forEach(err => console.log(` - ${err}`));
}
return testResults;
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
runAllTests,
sampleValidJSONResponse,
sampleMalformedJSONResponse,
sampleSimplifiedInventoryResponse,
mockTrackerConfig
};
}
// Auto-run if executed directly
if (!isBrowser || (isBrowser && window.RPG_RUN_TESTS)) {
runAllTests();
}
+342
View File
@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RPG Companion - Test Runner</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--border: #0f3460;
--text: #e8e8e8;
--accent: #e94560;
--success: #4caf50;
--error: #f44336;
--warning: #ff9800;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
padding: 2rem;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
color: var(--accent);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
h1::before {
content: '🧪';
}
.description {
background: var(--surface);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
border-left: 4px solid var(--accent);
}
.controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
button {
background: var(--accent);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.3);
}
button.secondary {
background: var(--surface);
border: 1px solid var(--border);
}
.results-summary {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
padding: 1rem;
background: var(--surface);
border-radius: 8px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
}
.stat-label {
font-size: 0.85rem;
opacity: 0.7;
text-transform: uppercase;
}
.stat.passed .stat-value { color: var(--success); }
.stat.failed .stat-value { color: var(--error); }
.stat.total .stat-value { color: var(--accent); }
.test-output {
background: #0d1117;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.9rem;
max-height: 500px;
overflow-y: auto;
}
.test-output .pass { color: var(--success); }
.test-output .fail { color: var(--error); }
.test-output .section {
color: var(--accent);
font-weight: bold;
margin-top: 1rem;
}
.test-output .info { color: #8b949e; }
.test-section {
margin-bottom: 2rem;
}
h2 {
color: var(--accent);
margin-bottom: 1rem;
font-size: 1.25rem;
}
.json-sample {
background: #0d1117;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.8rem;
overflow-x: auto;
white-space: pre;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
padding: 0.5rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px 6px 0 0;
cursor: pointer;
transition: all 0.2s;
}
.tab.active {
background: var(--accent);
border-color: var(--accent);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.note {
background: rgba(255, 152, 0, 0.1);
border: 1px solid var(--warning);
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
}
.note::before {
content: '⚠️ ';
}
</style>
</head>
<body>
<div class="container">
<h1>RPG Companion Test Runner</h1>
<div class="description">
<p>This test suite validates the JSON format used for structured tracker data.</p>
<p>Tests cover: JSON extraction, parsing, data structure validation, and schema generation.</p>
</div>
<div class="controls">
<button onclick="runTests()">▶️ Run All Tests</button>
<button class="secondary" onclick="clearResults()">🗑️ Clear Results</button>
</div>
<div class="results-summary" id="results-summary" style="display: none;">
<div class="stat total">
<span class="stat-value" id="total-count">0</span>
<span class="stat-label">Total</span>
</div>
<div class="stat passed">
<span class="stat-value" id="passed-count">0</span>
<span class="stat-label">Passed</span>
</div>
<div class="stat failed">
<span class="stat-value" id="failed-count">0</span>
<span class="stat-label">Failed</span>
</div>
</div>
<div class="test-section">
<h2>Test Output</h2>
<div class="test-output" id="test-output">
<span class="info">Click "Run All Tests" to start...</span>
</div>
</div>
<div class="tabs">
<div class="tab active" onclick="showTab('valid')">Valid JSON</div>
<div class="tab" onclick="showTab('malformed')">Malformed JSON</div>
<div class="tab" onclick="showTab('simplified')">Simplified Inventory</div>
</div>
<div id="tab-valid" class="tab-content active">
<h2>Sample Valid JSON Response</h2>
<div class="json-sample" id="valid-json"></div>
</div>
<div id="tab-malformed" class="tab-content">
<h2>Sample Malformed JSON (with trailing comma)</h2>
<div class="json-sample" id="malformed-json"></div>
</div>
<div id="tab-simplified" class="tab-content">
<h2>Sample Simplified Inventory Response</h2>
<div class="json-sample" id="simplified-json"></div>
</div>
<div class="note">
<strong>Note:</strong> These tests run in isolation and don't require SillyTavern to be running.
For integration testing with actual LLM responses, use the Debug Mode in the extension settings.
</div>
</div>
<script type="module">
// Import test module
import {
runAllTests,
sampleValidJSONResponse,
sampleMalformedJSONResponse,
sampleSimplifiedInventoryResponse
} from './jsonFormat.test.js';
// Display sample JSON
document.getElementById('valid-json').textContent = sampleValidJSONResponse;
document.getElementById('malformed-json').textContent = sampleMalformedJSONResponse;
document.getElementById('simplified-json').textContent = sampleSimplifiedInventoryResponse;
// Override console for capturing test output
const originalLog = console.log;
const originalError = console.error;
let outputBuffer = [];
function captureConsole() {
outputBuffer = [];
console.log = (...args) => {
outputBuffer.push({ type: 'log', message: args.join(' ') });
originalLog.apply(console, args);
};
console.error = (...args) => {
outputBuffer.push({ type: 'error', message: args.join(' ') });
originalError.apply(console, args);
};
}
function restoreConsole() {
console.log = originalLog;
console.error = originalError;
}
function formatOutput(buffer) {
return buffer.map(item => {
let cls = 'info';
if (item.message.includes('✅')) cls = 'pass';
else if (item.message.includes('❌')) cls = 'fail';
else if (item.message.includes('📋') || item.message.includes('📦') ||
item.message.includes('⚔️') || item.message.includes('📜') ||
item.message.includes('👥') || item.message.includes('📍') ||
item.message.includes('📝')) cls = 'section';
return `<div class="${cls}">${escapeHtml(item.message)}</div>`;
}).join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
window.runTests = function() {
captureConsole();
const results = runAllTests();
restoreConsole();
document.getElementById('test-output').innerHTML = formatOutput(outputBuffer);
document.getElementById('results-summary').style.display = 'flex';
document.getElementById('total-count').textContent = results.passed + results.failed;
document.getElementById('passed-count').textContent = results.passed;
document.getElementById('failed-count').textContent = results.failed;
};
window.clearResults = function() {
document.getElementById('test-output').innerHTML = '<span class="info">Click "Run All Tests" to start...</span>';
document.getElementById('results-summary').style.display = 'none';
};
window.showTab = function(tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + tabId).classList.add('active');
};
</script>
</body>
</html>