/** * 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.} [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.} 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.} [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.} stored - Items stored at locations * @property {TrackerItem[]} assets - Major possessions (vehicles, property) */ /** * @typedef {Object.} 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; }