diff --git a/src/core/persistence.js b/src/core/persistence.js index 74c1d3f..01a19e1 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -434,6 +434,8 @@ function migrateToTrackerConfig() { userStats: { customStats: [], showRPGAttributes: true, + alwaysSendAttributes: false, + allowAIUpdateAttributes: true, rpgAttributes: [ { id: 'str', name: 'STR', description: '', enabled: true }, { id: 'dex', name: 'DEX', description: '', enabled: true }, diff --git a/src/core/state.js b/src/core/state.js index ec135b6..4e11e26 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -120,6 +120,7 @@ export let extensionSettings = { // RPG Attributes (customizable D&D-style attributes) showRPGAttributes: true, alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls + allowAIUpdateAttributes: true, // If true, allow AI to update attributes from JSON response; if false, attributes are read-only rpgAttributes: [ { id: 'str', name: 'STR', description: '', enabled: true }, { id: 'dex', name: 'DEX', description: '', enabled: true }, diff --git a/src/i18n/en.json b/src/i18n/en.json index d69bf16..6b9130f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -87,6 +87,8 @@ "template.trackerEditorModal.userStatsTab.enableRpgAttributes": "Enable RPG Attributes Section", "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "Always Include Attributes in Prompt", "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "If disabled, attributes are only sent when a dice roll is active.", + "template.trackerEditorModal.userStatsTab.allowAIUpdateAttributes": "Allow AI to Update RPG Attributes", + "template.trackerEditorModal.userStatsTab.allowAIUpdateAttributesNote": "If enabled, the AI can update attribute values and level from its responses. If disabled, attributes are read-only and can only be changed manually.", "template.trackerEditorModal.userStatsTab.addAttributeButton": "Add Attribute", "template.trackerEditorModal.userStatsTab.statusSectionTitle": "Status Section", "template.trackerEditorModal.userStatsTab.enableStatusSection": "Enable Status Section", diff --git a/src/i18n/zh-tw.json b/src/i18n/zh-tw.json index 1480434..b94b1aa 100644 --- a/src/i18n/zh-tw.json +++ b/src/i18n/zh-tw.json @@ -87,6 +87,8 @@ "template.trackerEditorModal.userStatsTab.enableRpgAttributes": "啟用 RPG 屬性", "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "始終發送屬性(prompt)", "template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "將 RPG 屬性始終包含在提示詞中,即使它們未顯示在面板上也一樣。", + "template.trackerEditorModal.userStatsTab.allowAIUpdateAttributes": "允許 AI 更新 RPG 屬性", + "template.trackerEditorModal.userStatsTab.allowAIUpdateAttributesNote": "如果啟用,AI 可以從其 JSON 回應中更新屬性值和等級。如果禁用,屬性為唯讀,只能手動更改。", "template.trackerEditorModal.userStatsTab.addAttributeButton": "添加屬性", "template.trackerEditorModal.userStatsTab.statusSectionTitle": "狀態欄", "template.trackerEditorModal.userStatsTab.enableStatusSection": "啟用狀態欄", diff --git a/src/systems/generation/injector.js b/src/systems/generation/injector.js index 6f2cc8c..2bb1fa6 100644 --- a/src/systems/generation/injector.js +++ b/src/systems/generation/injector.js @@ -235,15 +235,15 @@ export function onGenerationStarted(type, data) { setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); } } else if (extensionSettings.generationMode === 'separate') { - // In SEPARATE mode, inject the contextual summary for main roleplay generation - const contextSummary = generateContextualSummary(); + // In SEPARATE mode, inject the current state as JSON for main roleplay generation + const currentStateJSON = generateContextualSummary(); - if (contextSummary) { - const wrappedContext = `\nHere is context information about the current scene, and what follows is the last message in the chat history: + if (currentStateJSON) { + const wrappedContext = `\nHere is {{user}}'s current state in JSON format. This is merely informative, it's not your job to update it: -${contextSummary} - -Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses. +\`\`\`json +${currentStateJSON} +\`\`\` \n\n`; // Inject context at depth 1 (before last user message) as SYSTEM @@ -251,7 +251,7 @@ Ensure these details naturally reflect and influence the narrative. Character be if (!shouldSuppress) { setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false); } - // console.log('[RPG Companion] Injected contextual summary for separate mode:', contextSummary); + // console.log('[RPG Companion] Injected current state JSON for separate mode:', currentStateJSON); } else { // Clear if no data yet setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false); diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index 0d6b012..803765e 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -217,6 +217,43 @@ export function parseJSONTrackerData(jsonData) { } } + // Parse attributes (RPG attributes like STR, DEX, etc.) + // Only parse if allowAIUpdateAttributes is enabled + const allowAIUpdateAttributes = trackerConfig?.userStats?.allowAIUpdateAttributes !== false; // Default to true for backwards compatibility + if (jsonData.attributes && typeof jsonData.attributes === 'object' && allowAIUpdateAttributes) { + debugLog('[RPG Parser] Parsing attributes:', Object.keys(jsonData.attributes)); + const rpgAttributes = trackerConfig?.userStats?.rpgAttributes || [ + { id: 'str', name: 'STR', description: '', enabled: true }, + { id: 'dex', name: 'DEX', description: '', enabled: true }, + { id: 'con', name: 'CON', description: '', enabled: true }, + { id: 'int', name: 'INT', description: '', enabled: true }, + { id: 'wis', name: 'WIS', description: '', enabled: true }, + { id: 'cha', name: 'CHA', description: '', enabled: true } + ]; + + for (const [attrName, value] of Object.entries(jsonData.attributes)) { + // Find matching attribute in config (case-insensitive) + const attrConfig = rpgAttributes.find(a => + a && a.name && a.name.toLowerCase() === attrName.toLowerCase() + ); + if (attrConfig && typeof value === 'number') { + // Store in classicStats using the attribute id + extensionSettings.classicStats[attrConfig.id] = Math.max(1, value); + debugLog(`[RPG Parser] Attribute ${attrConfig.name}: ${value}`); + } + } + } else if (jsonData.attributes && !allowAIUpdateAttributes) { + debugLog('[RPG Parser] Attributes found in response but allowAIUpdateAttributes is disabled - skipping update'); + } + + // Parse level (only if allowAIUpdateAttributes is enabled) + if (jsonData.level !== undefined && typeof jsonData.level === 'number' && allowAIUpdateAttributes) { + extensionSettings.level = Math.max(1, jsonData.level); + debugLog(`[RPG Parser] Level: ${extensionSettings.level}`); + } else if (jsonData.level !== undefined && !allowAIUpdateAttributes) { + debugLog('[RPG Parser] Level found in response but allowAIUpdateAttributes is disabled - skipping update'); + } + // Parse status if (jsonData.status) { if (jsonData.status.mood) { diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index f819d7f..bb2c302 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -242,6 +242,38 @@ export function generateJSONTrackerInstructions(includeHtmlPrompt = true, includ } } + // Attributes section (if RPG attributes are enabled and should be included) + const showRPGAttributes = trackerConfig?.userStats?.showRPGAttributes; + const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes; + const shouldSendAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll; + + if (showRPGAttributes && shouldSendAttributes) { + const rpgAttributes = trackerConfig?.userStats?.rpgAttributes || [ + { id: 'str', name: 'STR', description: '', enabled: true }, + { id: 'dex', name: 'DEX', description: '', enabled: true }, + { id: 'con', name: 'CON', description: '', enabled: true }, + { id: 'int', name: 'INT', description: '', enabled: true }, + { id: 'wis', name: 'WIS', description: '', enabled: true }, + { id: 'cha', name: 'CHA', description: '', enabled: true } + ]; + const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id); + + if (enabledAttributes.length > 0) { + let attrsJson = ' "attributes": {\n'; + const attrParts = enabledAttributes.map(attr => { + const value = extensionSettings.classicStats?.[attr.id] ?? 10; + return ` "${attr.name}": ${value}`; + }); + attrsJson += attrParts.join(',\n'); + attrsJson += '\n }'; + sections.push(attrsJson); + + // Add level + const currentLevel = extensionSettings.level ?? 1; + sections.push(` "level": ${currentLevel}`); + } + } + // Info Box section if (showInfoBox) { const widgets = trackerConfig?.infoBox?.widgets || {}; @@ -362,6 +394,12 @@ export function generateJSONTrackerInstructions(includeHtmlPrompt = true, includ 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'; + + if (showRPGAttributes && shouldSendAttributes) { + instructions += '- Attributes are numeric values (typically 1-20, but can be higher)\n'; + instructions += '- Level is a numeric value (typically 1+, represents character progression)\n'; + } + instructions += '- Empty arrays [] for sections with no items\n'; instructions += '- null for main quest if none active\n'; @@ -447,104 +485,175 @@ export function generateJSONTrackerInstructions(includeHtmlPrompt = true, includ } /** - * Generates a formatted contextual summary for SEPARATE mode injection. - * Includes the full tracker data in original format (without code fences and separators). + * Generates the current tracker state as a JSON string for SEPARATE mode injection. * Uses COMMITTED data (not displayed data) for generation context. + * Similar to how is formatted, but for the current state. * - * @returns {string} Formatted contextual summary + * @returns {string} JSON string of current state, or empty string if no data */ export function generateContextualSummary() { - // Use COMMITTED data for generation context, not displayed data - const userName = getContext().name1; + // Build current state as JSON (similar to previousState in generateRPGPromptText) + const currentState = {}; const trackerConfig = extensionSettings.trackerConfig; - let summary = ''; - - // Helper function to clean tracker data (remove code fences and separator lines) - const cleanTrackerData = (data) => { - if (!data) return ''; - return data - .split('\n') - .filter(line => { - const trimmed = line.trim(); - return trimmed && - !trimmed.startsWith('```') && - trimmed !== '---'; - }) - .join('\n'); - }; - - // Add User Stats tracker data if enabled - if (extensionSettings.showUserStats && committedTrackerData.userStats) { - const cleanedStats = cleanTrackerData(committedTrackerData.userStats); - if (cleanedStats) { - summary += cleanedStats + '\n\n'; - } - } - - // Add Info Box tracker data if enabled - if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { - const cleanedInfoBox = cleanTrackerData(committedTrackerData.infoBox); - if (cleanedInfoBox) { - summary += cleanedInfoBox + '\n\n'; - } - } - - // Add Present Characters tracker data if enabled - if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { - const cleanedThoughts = cleanTrackerData(committedTrackerData.characterThoughts); - if (cleanedThoughts) { - summary += cleanedThoughts + '\n\n'; - } - } - - // Add inventory context if enabled (only if not already present in cleaned stats) - if (extensionSettings.showInventory && extensionSettings.userStats?.inventory) { - const inventorySummary = buildInventorySummary(extensionSettings.userStats.inventory); - if (inventorySummary && inventorySummary !== 'None') { - // Check if inventory is already in the summary (case-insensitive) - const summaryLower = summary.toLowerCase(); - if (!summaryLower.includes('inventory:') && !summaryLower.includes('on person:')) { - summary += inventorySummary + '\n\n'; - } - } - } - - // Add quests context if enabled (only if not already present in cleaned stats) - if (extensionSettings.showQuests && extensionSettings.quests) { - const summaryLower = summary.toLowerCase(); - // Only add if not already present - if (!summaryLower.includes('main quest') && !summaryLower.includes('optional quest')) { - if (extensionSettings.quests.main && extensionSettings.quests.main !== 'None') { - summary += `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(', '); - if (optionalQuests) { - summary += `Optional Quests: ${optionalQuests}\n`; + const descriptions = {}; + + // Stats + if (extensionSettings.showUserStats) { + const customStats = trackerConfig?.userStats?.customStats?.filter(s => s?.enabled) || []; + if (customStats.length > 0) { + currentState.stats = {}; + descriptions.stats = {}; + for (const stat of customStats) { + currentState.stats[stat.name] = extensionSettings.userStats[stat.id] ?? 100; + if (stat.description) { + descriptions.stats[stat.name] = stat.description; } } - summary += '\n'; + } + + // Status + const statusConfig = trackerConfig?.userStats?.statusSection; + if (statusConfig?.enabled) { + currentState.status = { + mood: extensionSettings.userStats.mood || '😐', + fields: {} + }; + const customFields = statusConfig.customFields || []; + for (const field of customFields) { + currentState.status.fields[field] = extensionSettings.userStats.conditions || 'None'; + } } } - - // Include attributes based on settings + + // InfoBox + if (extensionSettings.showInfoBox && extensionSettings.infoBoxData) { + currentState.infoBox = extensionSettings.infoBoxData; + } + + // Characters - format to match schema + if (extensionSettings.showCharacterThoughts && extensionSettings.charactersData?.length > 0) { + // Ensure characters match the expected schema format + currentState.characters = extensionSettings.charactersData.map(char => { + const formatted = { name: char.name }; + if (char.relationship) formatted.relationship = char.relationship; + if (char.emoji) formatted.emoji = char.emoji; + if (char.fields && Object.keys(char.fields).length > 0) formatted.fields = char.fields; + if (char.stats && Object.keys(char.stats).length > 0) formatted.stats = char.stats; + if (char.thoughts) formatted.thoughts = char.thoughts; + return formatted; + }); + + // Add character field descriptions + const charConfig = trackerConfig?.presentCharacters; + if (charConfig?.customFields?.length > 0) { + descriptions.characterFields = {}; + for (const field of charConfig.customFields) { + if (field.enabled && field.description) { + descriptions.characterFields[field.name] = field.description; + } + } + } + + // Add character stats descriptions + const charStatsConfig = charConfig?.characterStats; + if (charStatsConfig?.enabled && charStatsConfig?.customStats?.length > 0) { + if (!descriptions.characterStats) { + descriptions.characterStats = {}; + } + for (const stat of charStatsConfig.customStats) { + if (stat.enabled && stat.description) { + descriptions.characterStats[stat.name] = stat.description; + } + } + } + } + + // Inventory - format to match schema (use "items" for simplified mode) + if (extensionSettings.showInventory && extensionSettings.inventoryV3) { + const inv = extensionSettings.inventoryV3; + if (extensionSettings.useSimplifiedInventory) { + // Simplified mode uses "items" key + const items = inv.simplified || inv.onPerson || []; + if (items.length > 0) { + currentState.inventory = { items }; + } + } else { + // Full categorized mode + if (inv.onPerson?.length > 0 || Object.keys(inv.stored || {}).length > 0 || inv.assets?.length > 0) { + currentState.inventory = { + onPerson: inv.onPerson || [], + stored: inv.stored || {}, + assets: inv.assets || [] + }; + } + } + } + + // Skills + if (extensionSettings.showSkills && extensionSettings.skillsV2) { + currentState.skills = extensionSettings.skillsV2; + + // Add skill category descriptions + const skillCategories = trackerConfig?.userStats?.skillsSection?.customFields || []; + const categoriesWithDesc = skillCategories.filter(cat => + typeof cat === 'object' && cat.enabled !== false && cat.description + ); + if (categoriesWithDesc.length > 0) { + descriptions.skillCategories = {}; + for (const cat of categoriesWithDesc) { + descriptions.skillCategories[cat.name] = cat.description; + } + } + } + + // Quests + if (extensionSettings.showQuests && extensionSettings.questsV2) { + currentState.quests = extensionSettings.questsV2; + } + + // Attributes and level (if RPG attributes are enabled and should be included) + const showRPGAttributes = trackerConfig?.userStats?.showRPGAttributes; const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes; const shouldSendAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll; - - if (shouldSendAttributes) { - const attributesString = buildAttributesString(); - summary += `${userName}'s attributes: ${attributesString}\n`; - - // Add dice roll context if there was one - if (extensionSettings.lastDiceRoll) { - const roll = extensionSettings.lastDiceRoll; - summary += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeeded or failed the action they attempted.\n\n`; - } else { - summary += `\n`; + + if (showRPGAttributes && shouldSendAttributes) { + const rpgAttributes = trackerConfig?.userStats?.rpgAttributes || [ + { id: 'str', name: 'STR', description: '', enabled: true }, + { id: 'dex', name: 'DEX', description: '', enabled: true }, + { id: 'con', name: 'CON', description: '', enabled: true }, + { id: 'int', name: 'INT', description: '', enabled: true }, + { id: 'wis', name: 'WIS', description: '', enabled: true }, + { id: 'cha', name: 'CHA', description: '', enabled: true } + ]; + const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id); + + if (enabledAttributes.length > 0) { + currentState.attributes = {}; + descriptions.attributes = {}; + for (const attr of enabledAttributes) { + const value = extensionSettings.classicStats?.[attr.id] ?? 10; + currentState.attributes[attr.name] = value; + if (attr.description) { + descriptions.attributes[attr.name] = attr.description; + } + } + + // Add level + currentState.level = extensionSettings.level ?? 1; } } - - return summary.trim(); + + // Add descriptions metadata if any exist + if (Object.keys(descriptions).length > 0) { + currentState._descriptions = descriptions; + } + + // Return JSON string if we have any data, otherwise empty string + if (Object.keys(currentState).length > 0) { + return JSON.stringify(currentState, null, 2); + } + + return ''; } /** @@ -638,6 +747,35 @@ export function generateRPGPromptText() { previousState.quests = extensionSettings.questsV2; } + // Attributes and level (if RPG attributes are enabled and should be included) + const trackerConfig = extensionSettings.trackerConfig; + const showRPGAttributes = trackerConfig?.userStats?.showRPGAttributes; + const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes; + const shouldSendAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll; + + if (showRPGAttributes && shouldSendAttributes) { + const rpgAttributes = trackerConfig?.userStats?.rpgAttributes || [ + { id: 'str', name: 'STR', description: '', enabled: true }, + { id: 'dex', name: 'DEX', description: '', enabled: true }, + { id: 'con', name: 'CON', description: '', enabled: true }, + { id: 'int', name: 'INT', description: '', enabled: true }, + { id: 'wis', name: 'WIS', description: '', enabled: true }, + { id: 'cha', name: 'CHA', description: '', enabled: true } + ]; + const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id); + + if (enabledAttributes.length > 0) { + previousState.attributes = {}; + for (const attr of enabledAttributes) { + const value = extensionSettings.classicStats?.[attr.id] ?? 10; + previousState.attributes[attr.name] = value; + } + + // Add level + previousState.level = extensionSettings.level ?? 1; + } + } + // Output as JSON if we have any data, otherwise indicate first update if (Object.keys(previousState).length > 0) { promptText += '```json\n'; @@ -649,8 +787,9 @@ export function generateRPGPromptText() { promptText += `\n`; - // Add JSON format instructions - promptText += generateJSONTrackerInstructions(false, false, false); + // Add JSON format instructions - include attributes if alwaysSendAttributes is enabled + const includeAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll; + promptText += generateJSONTrackerInstructions(false, false, includeAttributes); return promptText; } @@ -708,3 +847,4 @@ export async function generateSeparateUpdatePrompt() { return messages; } + diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index 416385f..b34b5eb 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -242,6 +242,14 @@ function renderUserStatsTab() { html += ''; html += `${i18n.getTranslation('template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote')}`; + // Allow AI to update attributes toggle + const allowAIUpdateAttributes = config.allowAIUpdateAttributes !== undefined ? config.allowAIUpdateAttributes : true; + html += '
'; + html += ``; + html += ``; + html += '
'; + html += `${i18n.getTranslation('template.trackerEditorModal.userStatsTab.allowAIUpdateAttributesNote')}`; + html += '
'; // Ensure rpgAttributes exists in the actual config (not just local fallback) @@ -444,9 +452,13 @@ function setupUserStatsListeners() { }); // Always send attributes toggle - $('#rpg-always-send-attrs').off('change').on('change', function() { - extensionSettings.trackerConfig.userStats.alwaysSendAttributes = $(this).is(':checked'); - }); + $('#rpg-always-send-attrs').off('change').on('change', function() { + extensionSettings.trackerConfig.userStats.alwaysSendAttributes = $(this).is(':checked'); + }); + + $('#rpg-allow-ai-update-attrs').off('change').on('change', function() { + extensionSettings.trackerConfig.userStats.allowAIUpdateAttributes = $(this).is(':checked'); + }); // Status section toggles $('#rpg-status-enabled').off('change').on('change', function() { diff --git a/src/types/trackerData.js b/src/types/trackerData.js index f995446..06981c5 100644 --- a/src/types/trackerData.js +++ b/src/types/trackerData.js @@ -43,6 +43,12 @@ * Example: { "Health": 85, "Energy": 70, "Custom Stat": 50 } */ +/** + * @typedef {Object.} TrackerAttributes + * Dynamic attributes object - keys are attribute names from config (e.g., STR, DEX), values are numeric + * Example: { "STR": 15, "DEX": 12, "INT": 18 } + */ + /** * @typedef {Object} TrackerStatus * @property {string} [mood] - Mood emoji (if enabled) @@ -84,6 +90,8 @@ * @typedef {Object} TrackerData * @property {TrackerStats} [stats] - Numeric stats (based on config) * @property {TrackerStatus} [status] - Status info (mood, custom fields) + * @property {TrackerAttributes} [attributes] - RPG attributes (STR, DEX, etc.) + * @property {number} [level] - Character level * @property {TrackerInfoBox} [infoBox] - Scene information (based on enabled widgets) * @property {TrackerCharacter[]} [characters] - Present characters * @property {TrackerInventory} [inventory] - Player inventory @@ -431,6 +439,14 @@ export function mergeTrackerData(existing, newData) { }; } + if (newData.attributes) { + merged.attributes = { ...merged.attributes, ...newData.attributes }; + } + + if (newData.level !== undefined) { + merged.level = newData.level; + } + if (newData.infoBox) { merged.infoBox = { ...merged.infoBox, ...newData.infoBox }; }