From 0f96c62c6224997a3fb855d929a33dfc8f3b80e4 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:06:22 +1100 Subject: [PATCH] feat: add structured skills parsing with categories and levels Add AI tracker awareness for skills system with proper level and category support. Changes: - Add extractSkills() parser function to extract structured skills data - Parses category-based format: "CategoryName:\n- SkillName (Lv X)" - Falls back to legacy string format for backward compatibility - Returns structured data: { version: 1, categories: {}, uncategorized: [] } - Update prompt instructions to request structured skills format - AI now generates: "Skills:\nCombat:\n- Swordsmanship (Lv 5)" - Supports multiple categories (Combat, Magic, Social, Crafting, etc.) - Includes Uncategorized section for skills without clear category - Add buildSkillsSummary() utility function - Converts structured skills data back to formatted text - Ready for future feature: syncing manual skill edits to AI context Parser integration: - parseUserStats() now uses extractSkills() to parse Skills section - Stores structured data in extensionSettings.userStats.skills - Widget reads structured data for display and level-up/down functionality AI workflow: 1. AI generates skills in structured format (via prompt instructions) 2. Parser extracts to structured data (via extractSkills) 3. Widget displays with level controls (already implemented) 4. Raw text flows through committedTrackerData to next generation Note: Manual skill edits (level-up/down in widget) are not yet synced back to AI context. This requires additional work to regenerate the raw text when skills are manually modified. buildSkillsSummary is ready for this. Refs: Skills widget implementation (previous session) --- src/systems/generation/parser.js | 92 +++++++++++++++++++++++-- src/systems/generation/promptBuilder.js | 57 ++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index 3a6d020..7fec28a 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -133,6 +133,88 @@ function debugLog(message, data = null) { } } +/** + * Extract structured skills data from stats text + * Parses format: + * Skills: + * CategoryName: + * - SkillName (Lv X) + * - SkillName (Lv X) + * Uncategorized: + * - SkillName (Lv X) + * + * @param {string} statsText - Stats section text containing skills + * @returns {Object|null} Structured skills data or null if not found + */ +function extractSkills(statsText) { + if (!statsText) return null; + + // Find the Skills section + const skillsMatch = statsText.match(/Skills:([\s\S]*?)(?=\n\n|On Person:|Stored|Assets:|Main Quest|Optional Quest|$)/i); + if (!skillsMatch) { + // Fallback: try simple format "Skills: skill1, skill2" + const simpleMatch = statsText.match(/Skills:\s*(.+)/i); + if (simpleMatch) { + const skillsText = simpleMatch[1].trim(); + if (skillsText && skillsText !== 'None') { + // Return as string for backward compatibility + return skillsText; + } + } + return null; + } + + const skillsSection = skillsMatch[1]; + const skillsData = { + version: 1, + categories: {}, + uncategorized: [] + }; + + // Split into lines and process + const lines = skillsSection.split('\n').map(line => line.trim()).filter(line => line); + + let currentCategory = null; + + for (const line of lines) { + // Check if this is a category header (ends with colon, no dash) + if (line.endsWith(':') && !line.startsWith('-')) { + currentCategory = line.slice(0, -1).trim(); + if (currentCategory !== 'Uncategorized' && !skillsData.categories[currentCategory]) { + skillsData.categories[currentCategory] = []; + } + continue; + } + + // Check if this is a skill line (starts with -, has level info) + const skillMatch = line.match(/^-\s*(.+?)\s*\(Lv\s*(\d+)\)/i); + if (skillMatch) { + const skillName = skillMatch[1].trim(); + const level = parseInt(skillMatch[2], 10) || 1; + + const skill = { + name: skillName, + level: level, + xp: 0, + maxXP: 100 + }; + + if (currentCategory === 'Uncategorized' || currentCategory === null) { + skillsData.uncategorized.push(skill); + } else if (currentCategory && skillsData.categories[currentCategory]) { + skillsData.categories[currentCategory].push(skill); + } + } + } + + // Return null if no skills were found + if (Object.keys(skillsData.categories).length === 0 && skillsData.uncategorized.length === 0) { + return null; + } + + return skillsData; +} + /** * Parses the model response to extract the different data sections. * Extracts tracker data from markdown code blocks in the AI response. @@ -351,10 +433,12 @@ export function parseUserStats(statsText) { // Parse skills section if enabled const skillsConfig = trackerConfig?.userStats?.skillsSection; if (skillsConfig?.enabled) { - const skillsMatch = statsText.match(/Skills:\s*(.+)/i); - if (skillsMatch) { - extensionSettings.userStats.skills = skillsMatch[1].trim(); - debugLog('[RPG Parser] Skills extracted:', skillsMatch[1].trim()); + const skillsData = extractSkills(statsText); + if (skillsData) { + extensionSettings.userStats.skills = skillsData; + debugLog('[RPG Parser] Skills extracted:', skillsData); + } else { + debugLog('[RPG Parser] Skills extraction failed or none found'); } } diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index 62be66c..a1dbf42 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -10,6 +10,53 @@ import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../co // Type imports /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ +/** + * Builds a formatted skills summary for AI context injection. + * Converts structured skills data to multi-line plaintext format organized by category. + * + * @param {Object|string} skills - Current skills (structured or legacy string) + * @returns {string} Formatted skills summary for prompt injection + * @example + * // Structured input: { version: 1, categories: { Combat: [{name: 'Swordsmanship', level: 5}] }, uncategorized: [] } + * // Returns: "Skills:\nCombat:\n- Swordsmanship (Lv 5)" + */ +export function buildSkillsSummary(skills) { + // Handle legacy string format + if (typeof skills === 'string') { + return `Skills: ${skills}`; + } + + // Handle structured format + if (skills && typeof skills === 'object' && skills.version) { + let summary = 'Skills:'; + const categories = skills.categories || {}; + const uncategorized = skills.uncategorized || []; + + // Add categorized skills + for (const [categoryName, skillsList] of Object.entries(categories)) { + if (skillsList && skillsList.length > 0) { + summary += `\n${categoryName}:`; + for (const skill of skillsList) { + summary += `\n- ${skill.name} (Lv ${skill.level})`; + } + } + } + + // Add uncategorized skills + if (uncategorized.length > 0) { + summary += '\nUncategorized:'; + for (const skill of uncategorized) { + summary += `\n- ${skill.name} (Lv ${skill.level})`; + } + } + + return summary; + } + + // Empty or invalid + return 'Skills: None'; +} + /** * Builds a formatted inventory summary for AI context injection. * Converts v2 inventory structure to multi-line plaintext format. @@ -166,9 +213,13 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon // Add skills section if enabled if (userStatsConfig?.skillsSection?.enabled) { - const skillFields = userStatsConfig.skillsSection.customFields || []; - const skillFieldsText = skillFields.map(f => `[${f}]`).join(', '); - instructions += `Skills: [${skillFieldsText || 'Skill1, Skill2, etc.'}]\n`; + instructions += `Skills:\n`; + instructions += `[Category Name]:\n`; + instructions += `- [Skill Name] (Lv [1-100])\n`; + instructions += `- [Another Skill] (Lv [1-100])\n`; + instructions += `Uncategorized:\n`; + instructions += `- [Uncategorized Skill] (Lv [1-100])\n`; + instructions += `(Organize skills by logical categories like Combat, Magic, Social, Crafting, etc. Include level as integer 1-100. Skills without a clear category go in Uncategorized.)\n`; } // Add inventory format based on feature flag