# Schema System Architecture **Status:** Design Phase **Priority:** Critical (Tier 1 Feature - 16% vote priority) **Target Version:** 2.0.0 --- ## Overview The Schema System allows users to define custom RPG systems using human-readable YAML files instead of being locked into hardcoded stats. Inspired by Gemini Deep Research recommendations and Entity-Component-System (ECS) patterns. ### Vision > Transform RPG Companion from a fixed D&D-style tracker into a universal RPG system that adapts to ANY tabletop game: Pathfinder, Cyberpunk RED, World of Darkness, homebrew systems, etc. --- ## Architecture Overview ### Three-Layer System ``` ┌─────────────────────────────────────────┐ │ System Definition (YAML) │ ← Design Time │ Rules, structure, formulas │ └─────────────────────────────────────────┘ ↓ validates ┌─────────────────────────────────────────┐ │ Character Instance (JSON) │ ← Run Time │ Actual character data │ └─────────────────────────────────────────┘ ↓ renders via ┌─────────────────────────────────────────┐ │ Widget Dashboard (UI) │ ← User Interface │ Dynamic widget rendering │ └─────────────────────────────────────────┘ ``` --- ## System Definition Layer (YAML) ### Schema Structure ```yaml # dnd5e.yaml - Example D&D 5th Edition Schema meta: name: "D&D 5th Edition" version: "1.0.0" author: "RPG Companion Community" description: "Official D&D 5e ruleset" tags: ["fantasy", "d20", "official"] components: # Core Abilities (STR, DEX, CON, etc.) coreAbilities: type: object label: "Ability Scores" icon: "🎲" properties: strength: type: number label: "Strength" abbr: "STR" min: 1 max: 30 default: 10 dexterity: type: number label: "Dexterity" abbr: "DEX" min: 1 max: 30 default: 10 constitution: type: number label: "Constitution" abbr: "CON" min: 1 max: 30 default: 10 intelligence: type: number label: "Intelligence" abbr: "INT" min: 1 max: 30 default: 10 wisdom: type: number label: "Wisdom" abbr: "WIS" min: 1 max: 30 default: 10 charisma: type: number label: "Charisma" abbr: "CHA" min: 1 max: 30 default: 10 # Derived Stats (calculated from abilities) abilityModifiers: type: object label: "Ability Modifiers" properties: str_mod: type: formula formula: "floor((@coreAbilities.strength - 10) / 2)" dex_mod: type: formula formula: "floor((@coreAbilities.dexterity - 10) / 2)" con_mod: type: formula formula: "floor((@coreAbilities.constitution - 10) / 2)" int_mod: type: formula formula: "floor((@coreAbilities.intelligence - 10) / 2)" wis_mod: type: formula formula: "floor((@coreAbilities.wisdom - 10) / 2)" cha_mod: type: formula formula: "floor((@coreAbilities.charisma - 10) / 2)" # Resources (pools that track usage) resources: type: list label: "Resources" icon: "⚡" items: hitPoints: type: resource label: "Hit Points" abbr: "HP" current: 0 max: type: formula formula: "10 + @abilityModifiers.con_mod" color: "#cc3333" display: "bar" spellSlots: type: resource label: "Spell Slots" abbr: "Spells" current: 0 max: 0 color: "#3366cc" display: "dots" # Skills skills: type: list label: "Skills" icon: "⚔️" items: acrobatics: type: number label: "Acrobatics" baseAbility: "dexterity" proficient: false expertise: false animalHandling: type: number label: "Animal Handling" baseAbility: "wisdom" proficient: false expertise: false arcana: type: number label: "Arcana" baseAbility: "intelligence" proficient: false expertise: false # ... more skills # Conditions/Status Effects statusEffects: type: list label: "Conditions" icon: "✨" items: name: type: text label: "Condition Name" duration: type: number label: "Rounds Remaining" min: 0 effect: type: text label: "Effect Description" # Inventory (simplified) inventory: type: object label: "Equipment" icon: "🎒" properties: carried: type: list label: "Carried Items" worn: type: list label: "Worn Armor" gold: type: number label: "Gold Pieces" abbr: "GP" default: 0 # Character Identity identity: type: object label: "Character Info" properties: name: type: text label: "Name" required: true race: type: text label: "Race" class: type: text label: "Class" level: type: number label: "Level" min: 1 max: 20 default: 1 background: type: text label: "Background" # Prompt template for AI generation prompts: stats: | Character Stats --- HP: [@resources.hitPoints.current/@resources.hitPoints.max] Spell Slots: [@resources.spellSlots.current/@resources.spellSlots.max] Conditions: [List active conditions or "None"] skills: | Skills --- [Skill Name]: [Modifier] | [Proficiency Status] (List all relevant skills for the current scene) # Widget layout suggestions layout: defaultTabs: - name: "Combat" widgets: - type: "resources" component: "resources" x: 0 y: 0 w: 4 h: 3 - type: "skills" component: "skills" filter: ["acrobatics", "athletics", "stealth"] x: 4 y: 0 w: 4 h: 4 - type: "statusEffects" component: "statusEffects" x: 8 y: 0 w: 4 h: 2 - name: "Character" widgets: - type: "coreAbilities" component: "coreAbilities" x: 0 y: 0 w: 6 h: 3 - type: "identity" component: "identity" x: 6 y: 0 w: 6 h: 3 ``` --- ## Component Types ### 1. Object Components Group related properties together. ```yaml identity: type: object properties: name: type: text age: type: number ``` **Rendered as:** Card with labeled fields ### 2. List Components Collections of items. ```yaml skills: type: list items: name: type: text value: type: number ``` **Rendered as:** Vertical list, table, or grid ### 3. Resource Components Tracked pools with current/max values. ```yaml hitPoints: type: resource current: 10 max: 20 display: "bar" ``` **Rendered as:** Progress bar or numeric display ### 4. Formula Components Derived values calculated from other components. ```yaml armorClass: type: formula formula: "10 + @abilityModifiers.dex_mod + @equipment.armor.bonus" ``` **Rendered as:** Read-only calculated value --- ## Character Instance Layer (JSON) ### Instance Structure Character data stored in `extensionSettings.characterInstance`: ```javascript extensionSettings.characterInstance = { schemaId: "dnd5e-v1.0.0", // Which schema this uses schemaVersion: "1.0.0", // Schema version data: { // Component data matching schema structure coreAbilities: { strength: 16, dexterity: 14, constitution: 15, intelligence: 10, wisdom: 12, charisma: 8 }, abilityModifiers: { // Calculated automatically via formula str_mod: 3, dex_mod: 2, con_mod: 2, int_mod: 0, wis_mod: 1, cha_mod: -1 }, resources: { hitPoints: { current: 12, max: 22 }, spellSlots: { current: 3, max: 4 } }, skills: [ { name: "Acrobatics", value: 2, proficient: false }, { name: "Athletics", value: 5, proficient: true }, { name: "Stealth", value: 4, proficient: true } // ... more skills ], statusEffects: [ { name: "Blessed", duration: 10, effect: "+1d4 to attacks" } ], inventory: { carried: ["Longsword", "Shield", "Healing Potion x2"], worn: ["Chain Mail"], gold: 47 }, identity: { name: "Ragnar", race: "Human", class: "Fighter", level: 3, background: "Soldier" } }, // Metadata createdAt: "2025-10-23T12:00:00Z", updatedAt: "2025-10-23T14:30:00Z" }; ``` --- ## Formula Engine ### Formula Syntax ```javascript // @ references components in character instance @coreAbilities.strength // → 16 @abilityModifiers.str_mod // → 3 @resources.hitPoints.max // → 22 // Math operators floor((@coreAbilities.strength - 10) / 2) // → 3 @coreAbilities.strength + 5 // → 21 (@level * 2) + @abilityModifiers.con_mod // → 8 // Conditional (future) @coreAbilities.strength > 15 ? "Strong" : "Weak" ``` ### Safe Expression Parser ```javascript // src/systems/schema/formulaEngine.js export class FormulaEngine { constructor(characterData) { this.data = characterData; this.cache = new Map(); // Memoize calculated values } // Evaluate formula string evaluate(formula) { // Check cache first if (this.cache.has(formula)) { return this.cache.get(formula); } // Replace @ references with actual values const resolved = this.resolveReferences(formula); // Safe eval using Function constructor (sandboxed) try { const result = this.safeEval(resolved); this.cache.set(formula, result); return result; } catch (error) { console.error('[Formula Engine] Error evaluating:', formula, error); return 0; // Fallback } } // Replace @component.path with actual values resolveReferences(formula) { const refRegex = /@([a-zA-Z0-9_.]+)/g; return formula.replace(refRegex, (match, path) => { const value = this.getValueByPath(path); return value !== undefined ? value : 0; }); } // Get nested value from character data getValueByPath(path) { const parts = path.split('.'); let value = this.data; for (const part of parts) { if (value && typeof value === 'object') { value = value[part]; } else { return undefined; } } return value; } // Safe evaluation (whitelist functions) safeEval(expression) { const allowedFunctions = { floor: Math.floor, ceil: Math.ceil, round: Math.round, abs: Math.abs, min: Math.min, max: Math.max }; // Create sandboxed function const func = new Function(...Object.keys(allowedFunctions), `return ${expression}`); // Execute with whitelisted functions return func(...Object.values(allowedFunctions)); } // Clear cache (call when character data changes) invalidateCache() { this.cache.clear(); } } // Usage: const engine = new FormulaEngine(characterInstance.data); const strMod = engine.evaluate("floor((@coreAbilities.strength - 10) / 2)"); console.log('STR Modifier:', strMod); // → 3 ``` --- ## Schema Validation ### JSON Schema Integration Use JSON Schema to validate character instances: ```javascript // src/systems/schema/validator.js import Ajv from 'ajv'; // Lightweight JSON Schema validator export class SchemaValidator { constructor() { this.ajv = new Ajv({ allErrors: true }); } // Convert YAML schema to JSON Schema compileSchema(yamlSchema) { const jsonSchema = { type: 'object', properties: {}, required: [] }; // Convert each component to JSON Schema property for (const [componentName, component] of Object.entries(yamlSchema.components)) { jsonSchema.properties[componentName] = this.convertComponent(component); if (component.required) { jsonSchema.required.push(componentName); } } return this.ajv.compile(jsonSchema); } // Convert component definition to JSON Schema convertComponent(component) { switch (component.type) { case 'object': return { type: 'object', properties: this.convertProperties(component.properties) }; case 'list': return { type: 'array', items: this.convertComponent(component.items) }; case 'resource': return { type: 'object', properties: { current: { type: 'number' }, max: { type: 'number' } }, required: ['current', 'max'] }; case 'number': return { type: 'number', minimum: component.min, maximum: component.max, default: component.default }; case 'text': return { type: 'string', minLength: component.minLength, maxLength: component.maxLength }; case 'formula': // Formulas are always numbers return { type: 'number' }; default: return { type: 'string' }; } } // Validate character instance against schema validate(characterInstance, schema) { const compiled = this.compileSchema(schema); const valid = compiled(characterInstance.data); if (!valid) { return { valid: false, errors: compiled.errors }; } return { valid: true }; } } ``` --- ## Storage Layer ### Hybrid Storage Strategy (Gemini Recommendation) **IndexedDB** for internal operations: - Fast local access - Query capabilities - No size limits (within reason) **File System Access API** for import/export: - User-friendly YAML files - Version control compatible - Shareable with community ```javascript // src/systems/schema/storage.js export class SchemaStorage { constructor() { this.db = null; this.init(); } async init() { // Initialize IndexedDB const request = indexedDB.open('RPGCompanionSchemas', 1); request.onupgradeneeded = (event) => { const db = event.target.result; // Schemas store if (!db.objectStoreNames.contains('schemas')) { const schemaStore = db.createObjectStore('schemas', { keyPath: 'id' }); schemaStore.createIndex('name', 'meta.name'); schemaStore.createIndex('version', 'meta.version'); } // Character instances store if (!db.objectStoreNames.contains('characters')) { const charStore = db.createObjectStore('characters', { keyPath: 'id' }); charStore.createIndex('schemaId', 'schemaId'); charStore.createIndex('name', 'data.identity.name'); } }; request.onsuccess = (event) => { this.db = event.target.result; console.log('[Schema Storage] IndexedDB initialized'); }; } // Save schema to IndexedDB async saveSchema(schema) { const transaction = this.db.transaction(['schemas'], 'readwrite'); const store = transaction.objectStore('schemas'); const schemaWithId = { id: `${schema.meta.name}-v${schema.meta.version}`, ...schema, savedAt: new Date().toISOString() }; await store.put(schemaWithId); return schemaWithId.id; } // Load schema from IndexedDB async loadSchema(schemaId) { const transaction = this.db.transaction(['schemas'], 'readonly'); const store = transaction.objectStore('schemas'); return new Promise((resolve, reject) => { const request = store.get(schemaId); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // List all schemas async listSchemas() { const transaction = this.db.transaction(['schemas'], 'readonly'); const store = transaction.objectStore('schemas'); return new Promise((resolve, reject) => { const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // Export schema to YAML file async exportSchema(schemaId) { const schema = await this.loadSchema(schemaId); // Convert to YAML const yaml = this.toYAML(schema); // Use File System Access API (if available) if ('showSaveFilePicker' in window) { const handle = await window.showSaveFilePicker({ suggestedName: `${schema.meta.name}.yaml`, types: [{ description: 'YAML Schema', accept: { 'text/yaml': ['.yaml', '.yml'] } }] }); const writable = await handle.createWritable(); await writable.write(yaml); await writable.close(); } else { // Fallback: download blob const blob = new Blob([yaml], { type: 'text/yaml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${schema.meta.name}.yaml`; a.click(); URL.revokeObjectURL(url); } } // Import schema from YAML file async importSchema() { // Use File System Access API (if available) if ('showOpenFilePicker' in window) { const [handle] = await window.showOpenFilePicker({ types: [{ description: 'YAML Schema', accept: { 'text/yaml': ['.yaml', '.yml'] } }] }); const file = await handle.getFile(); const yaml = await file.text(); const schema = this.fromYAML(yaml); // Validate and save await this.saveSchema(schema); return schema; } else { // Fallback: file input return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.yaml,.yml'; input.onchange = async (e) => { const file = e.target.files[0]; const yaml = await file.text(); const schema = this.fromYAML(yaml); await this.saveSchema(schema); resolve(schema); }; input.click(); }); } } // Convert schema object to YAML string toYAML(schema) { // Use js-yaml library return jsyaml.dump(schema, { indent: 2, lineWidth: 80, noRefs: true }); } // Parse YAML string to schema object fromYAML(yaml) { // Use js-yaml library return jsyaml.load(yaml); } } ``` --- ## Widget Integration ### Schema-Driven Widget Rendering ```javascript // src/systems/dashboard/schemaWidgets.js export class SchemaWidgetRenderer { constructor(schema, characterInstance, formulaEngine) { this.schema = schema; this.instance = characterInstance; this.formulaEngine = formulaEngine; } // Render component as widget renderComponent(componentName, container, config = {}) { const component = this.schema.components[componentName]; const data = this.instance.data[componentName]; switch (component.type) { case 'object': this.renderObject(component, data, container); break; case 'list': this.renderList(component, data, container, config); break; case 'resource': this.renderResource(component, data, container); break; default: console.warn('Unknown component type:', component.type); } } // Render object component (e.g., coreAbilities) renderObject(component, data, container) { const html = `