Files
rpg-companion-sillytavern/docs/features/schema-system-architecture.md
T
Lucas 'Paperboy' Rose-Winters 40a1242486 docs: enhance schema architecture with formula engine, custom UI, and migration
- Extend formula engine to 4 levels (math → conditionals → functions → strings)
- Add custom UI override system with data-bind templates
- Implement comprehensive data migration strategy with versioning
- Add MigrationManager class with BFS pathfinding
- Include migration UI flow and best practices

Addresses all architectural review recommendations.
2025-10-23 08:48:59 +11:00

53 KiB

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

# 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"

# Progression tables for lookup() function
tables:
  proficiency_bonus:
    1: 2
    2: 2
    3: 2
    4: 2
    5: 3
    6: 3
    7: 3
    8: 3
    9: 4
    10: 4
    11: 4
    12: 4
    13: 5
    14: 5
    15: 5
    16: 5
    17: 6
    18: 6
    19: 6
    20: 6

  spell_slots:
    # Wizard spell slots by level and spell level
    1: [2, 0, 0, 0, 0, 0, 0, 0, 0]
    2: [3, 0, 0, 0, 0, 0, 0, 0, 0]
    3: [4, 2, 0, 0, 0, 0, 0, 0, 0]
    # ... more levels

# Custom UI overrides (optional - for pixel-perfect layouts)
customUI:
  # Most components use dynamic generation
  # Optionally override specific components with custom HTML/CSS
  coreAbilities:
    template: "custom-templates/dnd5e-abilities.html"
    style: "custom-templates/dnd5e-abilities.css"

# 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.

identity:
  type: object
  properties:
    name:
      type: text
    age:
      type: number

Rendered as: Card with labeled fields

2. List Components

Collections of items.

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.

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.

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:

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 (Enhanced)

The formula engine supports progressively complex expressions to handle the wide range of TTRPG mechanics.

Level 1: Basic Math and References

// @ 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

Level 2: Conditional Logic

// Ternary operator
@coreAbilities.strength > 15 ? "Strong" : "Weak"

// Complex conditions
@coreAbilities.strength >= 13 && @coreAbilities.dexterity >= 13 ? 2 : 0

// Nested conditionals
@level >= 20 ? 6 : (@level >= 17 ? 5 : (@level >= 13 ? 4 : 3))

Level 3: Functions and Lookups

// Built-in functions
min(@coreAbilities.strength, @coreAbilities.dexterity)
max(@resources.hitPoints.current, 0)
clamp(@skills.stealth.value, 0, 20)

// Boolean functions
hasFeature("shield_master")
hasTrait("undead")
isProficient("athletics")

// Table lookups (for complex progression tables)
lookup("proficiency_bonus", @level)  // → Returns value from table
lookup("spell_slots", @level, @spellcaster_class)

// Array operations
sum(@inventory.weapons.*.damage)
count(@statusEffects)

Level 4: String Manipulation

// String concatenation
concat(@identity.firstName, " ", @identity.lastName)

// String formatting
format("Level {0} {1}", @level, @identity.class)

// Conditional text
@resources.hitPoints.current > 0 ? "Alive" : "Unconscious"

Safe Expression Parser

// 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 = {
      // Math functions
      floor: Math.floor,
      ceil: Math.ceil,
      round: Math.round,
      abs: Math.abs,
      min: Math.min,
      max: Math.max,
      pow: Math.pow,
      sqrt: Math.sqrt,

      // Utility functions
      clamp: (val, min, max) => Math.max(min, Math.min(max, val)),

      // Boolean functions (check character data)
      hasFeature: (featureName) => {
        return this.data.features?.some(f => f.name === featureName) || false;
      },
      hasTrait: (traitName) => {
        return this.data.traits?.includes(traitName) || false;
      },
      isProficient: (skillName) => {
        return this.data.skills?.find(s => s.name === skillName)?.proficient || false;
      },

      // Table lookup functions
      lookup: (tableName, ...keys) => {
        return this.lookupTable(tableName, keys);
      },

      // Array operations
      sum: (array) => Array.isArray(array) ? array.reduce((a, b) => a + b, 0) : 0,
      count: (array) => Array.isArray(array) ? array.length : 0,

      // String functions
      concat: (...strings) => strings.join(''),
      format: (template, ...args) => {
        return template.replace(/\{(\d+)\}/g, (match, index) => args[index] || '');
      }
    };

    // Create sandboxed function
    const func = new Function(...Object.keys(allowedFunctions), `return ${expression}`);

    // Execute with whitelisted functions
    return func(...Object.values(allowedFunctions));
  }

  // Lookup value from a progression table defined in schema
  lookupTable(tableName, keys) {
    const tables = this.schema.tables || {};
    const table = tables[tableName];

    if (!table) {
      console.warn(`[Formula Engine] Table not found: ${tableName}`);
      return 0;
    }

    // Simple single-key lookup
    if (keys.length === 1) {
      return table[keys[0]] || 0;
    }

    // Multi-key lookup (for 2D tables)
    let value = table;
    for (const key of keys) {
      value = value?.[key];
      if (value === undefined) return 0;
    }

    return value;
  }

  // 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:

// 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
// 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);
  }
}

Custom UI Override System

Rationale

While dynamic UI generation from schema definitions is powerful and efficient, some game systems benefit from highly stylized, pixel-perfect layouts that match their official character sheets. The Custom UI Override System allows schema authors to optionally provide custom HTML/CSS for specific components while maintaining the benefits of schema-driven data management.

Hybrid Approach

# In system.yaml

customUI:
  # Override specific components with custom templates
  coreAbilities:
    template: "custom-templates/dnd5e-abilities.html"
    style: "custom-templates/dnd5e-abilities.css"

  # Other components use dynamic generation (default behavior)
  # skills: (auto-generated)
  # resources: (auto-generated)

Custom Template Structure

<!-- custom-templates/dnd5e-abilities.html -->
<div class="dnd5e-abilities-grid">
  <div class="ability-card" data-ability="strength">
    <div class="ability-label">STR</div>
    <div class="ability-score" data-bind="@coreAbilities.strength">10</div>
    <div class="ability-modifier" data-bind="@abilityModifiers.str_mod">+0</div>
  </div>

  <div class="ability-card" data-ability="dexterity">
    <div class="ability-label">DEX</div>
    <div class="ability-score" data-bind="@coreAbilities.dexterity">10</div>
    <div class="ability-modifier" data-bind="@abilityModifiers.dex_mod">+0</div>
  </div>

  <!-- More ability cards... -->
</div>
/* custom-templates/dnd5e-abilities.css */
.dnd5e-abilities-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.ability-card {
  background: linear-gradient(135deg, #8B0000, #4A0000);
  border: 2px solid #FFD700;
  border-radius: 8px;
  padding: 16px;
  text-align: center;
}

.ability-label {
  font-size: 14px;
  font-weight: bold;
  color: #FFD700;
  text-transform: uppercase;
  letter-spacing: 2px;
}

.ability-score {
  font-size: 32px;
  font-weight: bold;
  color: white;
  margin: 8px 0;
}

.ability-modifier {
  font-size: 18px;
  color: #AAA;
}

Data Binding

Custom templates use data-bind attributes with the same @ reference syntax as formulas:

// src/systems/schema/customUIRenderer.js

export class CustomUIRenderer {
  constructor(schema, characterInstance, formulaEngine) {
    this.schema = schema;
    this.instance = characterInstance;
    this.formulaEngine = formulaEngine;
  }

  // Render custom UI component
  renderCustomComponent(componentName, container) {
    const customUI = this.schema.customUI?.[componentName];

    if (!customUI) {
      // Fall back to dynamic generation
      return false;
    }

    // Load custom template
    const template = this.loadTemplate(customUI.template);
    const style = this.loadStyle(customUI.style);

    // Inject HTML
    container.innerHTML = template;

    // Inject CSS (scoped to this component)
    this.injectScopedStyles(style, componentName);

    // Bind data to template
    this.bindData(container);

    // Attach event listeners for editable fields
    this.attachEditHandlers(container);

    return true;
  }

  // Bind character data to [data-bind] attributes
  bindData(container) {
    const bindings = container.querySelectorAll('[data-bind]');

    bindings.forEach(element => {
      const reference = element.dataset.bind;

      // Resolve @ reference
      if (reference.startsWith('@')) {
        const path = reference.substring(1);
        const value = this.getValueByPath(path);

        // Update element content
        if (element.tagName === 'INPUT') {
          element.value = value;
        } else {
          element.textContent = value;
        }

        // Store binding for updates
        element.dataset.boundPath = path;
      }
    });
  }

  // Attach edit handlers to bound elements
  attachEditHandlers(container) {
    const editableElements = container.querySelectorAll('[data-bind][contenteditable]');

    editableElements.forEach(element => {
      element.addEventListener('blur', (e) => {
        const path = e.target.dataset.boundPath;
        const value = e.target.textContent.trim();

        // Update character instance
        this.setValueByPath(path, value);

        // Re-calculate formulas
        this.formulaEngine.invalidateCache();

        // Re-render (to update any derived values)
        this.bindData(container);
      });
    });
  }

  // Get value from character data by path
  getValueByPath(path) {
    const parts = path.split('.');
    let value = this.instance.data;

    for (const part of parts) {
      value = value?.[part];
    }

    return value;
  }

  // Set value in character data by path
  setValueByPath(path, value) {
    const parts = path.split('.');
    let obj = this.instance.data;

    for (let i = 0; i < parts.length - 1; i++) {
      obj = obj[parts[i]];
    }

    obj[parts[parts.length - 1]] = value;
  }

  // Load template file
  loadTemplate(templatePath) {
    // Fetch from schemas directory or bundled templates
    // For now, placeholder implementation
    return `<div>Custom template: ${templatePath}</div>`;
  }

  // Load and inject scoped CSS
  injectScopedStyles(css, componentName) {
    // Scope CSS to this component to avoid conflicts
    const scopedCSS = this.scopeCSS(css, `[data-component="${componentName}"]`);

    const style = document.createElement('style');
    style.textContent = scopedCSS;
    style.dataset.component = componentName;

    // Remove old style if exists
    document.querySelector(`style[data-component="${componentName}"]`)?.remove();

    document.head.appendChild(style);
  }

  // Scope CSS rules to a specific selector
  scopeCSS(css, scope) {
    // Simple CSS scoping (could use postcss for production)
    return css.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|\s*{)/g, `${scope} $1$2`);
  }
}

Benefits of Custom UI

  1. Pixel-Perfect Replication - Match official character sheet layouts exactly
  2. Advanced Styling - Use complex CSS effects, animations, gradients
  3. Brand Identity - Maintain game system's visual identity
  4. Performance - Static HTML faster than complex dynamic generation
  5. Community Templates - Share beautiful designs without coding

When to Use Custom UI

  • Official character sheets - When brand accuracy matters
  • Complex visual layouts - Intricate positioning, overlapping elements
  • Themed experiences - Cyberpunk neon, fantasy parchment, horror gothic
  • Special components - Character portraits, spell cards, inventory grids

When to Use Dynamic Generation

  • Most components - Skills lists, stats, resources
  • Rapid prototyping - Testing new game systems
  • Flexible layouts - Need to work on mobile and desktop
  • Community schemas - Easier to create without HTML/CSS knowledge

Data Migration Strategy

Versioning System

Every schema includes a version number, and character instances track which schema version they were created with. This enables automated migration when schemas are updated.

# system.yaml
meta:
  name: "D&D 5th Edition"
  version: "1.2.0"  # Semantic versioning
  author: "RPG Companion Community"
// character.json
{
  schemaId: "dnd5e-v1.2.0",
  schemaVersion: "1.2.0",
  data: { /* character data */ }
}

Migration Functions

Each schema can define migration functions to transform character data between versions:

# system.yaml
migrations:
  - from: "1.0.0"
    to: "1.1.0"
    script: "migrations/1.0.0-to-1.1.0.js"

  - from: "1.1.0"
    to: "1.2.0"
    script: "migrations/1.1.0-to-1.2.0.js"

Migration Script Example

// migrations/1.0.0-to-1.1.0.js

/**
 * Migration from v1.0.0 to v1.1.0
 * Changes:
 * - Renamed "classicStats" to "coreAbilities"
 * - Added "abilityModifiers" as derived component
 * - Changed spell slots from single number to object
 */
export function migrate_1_0_0_to_1_1_0(characterData) {
  const migrated = { ...characterData };

  // 1. Rename classicStats to coreAbilities
  if (migrated.classicStats) {
    migrated.coreAbilities = {
      strength: migrated.classicStats.str,
      dexterity: migrated.classicStats.dex,
      constitution: migrated.classicStats.con,
      intelligence: migrated.classicStats.int,
      wisdom: migrated.classicStats.wis,
      charisma: migrated.classicStats.cha
    };
    delete migrated.classicStats;
  }

  // 2. Initialize abilityModifiers (will be calculated by formulas)
  migrated.abilityModifiers = {
    str_mod: 0,
    dex_mod: 0,
    con_mod: 0,
    int_mod: 0,
    wis_mod: 0,
    cha_mod: 0
  };

  // 3. Convert spell slots from number to object
  if (migrated.resources?.spellSlots) {
    const oldValue = migrated.resources.spellSlots;
    migrated.resources.spellSlots = {
      current: typeof oldValue === 'number' ? oldValue : 0,
      max: typeof oldValue === 'number' ? oldValue : 0
    };
  }

  return migrated;
}

Migration Manager

// src/systems/schema/migrationManager.js

export class MigrationManager {
  constructor(schemaStorage) {
    this.schemaStorage = schemaStorage;
  }

  // Check if character needs migration
  async needsMigration(characterInstance, currentSchema) {
    const charVersion = characterInstance.schemaVersion;
    const schemaVersion = currentSchema.meta.version;

    return charVersion !== schemaVersion;
  }

  // Migrate character to latest schema version
  async migrateCharacter(characterInstance, currentSchema) {
    const charVersion = characterInstance.schemaVersion;
    const targetVersion = currentSchema.meta.version;

    console.log(`[Migration] Migrating character from ${charVersion} to ${targetVersion}`);

    // Get all migration steps needed
    const migrationPath = this.getMigrationPath(
      charVersion,
      targetVersion,
      currentSchema.migrations || []
    );

    if (migrationPath.length === 0) {
      console.warn('[Migration] No migration path found');
      return characterInstance; // Can't migrate
    }

    // Apply migrations sequentially
    let migratedData = { ...characterInstance.data };

    for (const migration of migrationPath) {
      console.log(`[Migration] Applying: ${migration.from}${migration.to}`);

      try {
        // Load and execute migration script
        const migrationFn = await this.loadMigration(migration.script);
        migratedData = migrationFn(migratedData);
      } catch (error) {
        console.error('[Migration] Failed:', error);
        throw new Error(`Migration failed at ${migration.from}${migration.to}: ${error.message}`);
      }
    }

    // Update character instance
    return {
      ...characterInstance,
      schemaVersion: targetVersion,
      data: migratedData,
      updatedAt: new Date().toISOString(),
      migratedFrom: charVersion
    };
  }

  // Find shortest migration path between versions
  getMigrationPath(fromVersion, toVersion, migrations) {
    // Build graph of migrations
    const graph = new Map();

    migrations.forEach(m => {
      if (!graph.has(m.from)) {
        graph.set(m.from, []);
      }
      graph.get(m.from).push(m);
    });

    // BFS to find shortest path
    const queue = [[fromVersion, []]];
    const visited = new Set([fromVersion]);

    while (queue.length > 0) {
      const [currentVersion, path] = queue.shift();

      if (currentVersion === toVersion) {
        return path; // Found path
      }

      const neighbors = graph.get(currentVersion) || [];

      for (const migration of neighbors) {
        if (!visited.has(migration.to)) {
          visited.add(migration.to);
          queue.push([migration.to, [...path, migration]]);
        }
      }
    }

    return []; // No path found
  }

  // Load migration script
  async loadMigration(scriptPath) {
    // Dynamic import of migration script
    const module = await import(`/schemas/${scriptPath}`);

    // Migration script should export a function named migrate_X_X_X_to_Y_Y_Y
    const fnName = Object.keys(module).find(key => key.startsWith('migrate_'));

    if (!fnName) {
      throw new Error(`Migration script ${scriptPath} does not export a migration function`);
    }

    return module[fnName];
  }

  // Backup character before migration
  async backupCharacter(characterInstance) {
    const backup = {
      ...characterInstance,
      backedUpAt: new Date().toISOString()
    };

    // Save to separate backup store in IndexedDB
    await this.schemaStorage.saveBackup(backup);

    return backup;
  }

  // Restore character from backup
  async restoreCharacter(backupId) {
    return await this.schemaStorage.loadBackup(backupId);
  }
}

Migration UI Flow

User loads character with old schema version
    ↓
App detects version mismatch
    ↓
Show migration prompt:
┌─────────────────────────────────────────┐
│ Character Migration Required            │
├─────────────────────────────────────────┤
│ Your character "Ragnar" was created     │
│ with schema version 1.0.0.              │
│                                         │
│ The current schema is version 1.2.0.   │
│                                         │
│ Changes in new version:                │
│ • Renamed stats for consistency        │
│ • Added ability modifiers              │
│ • Improved spell slot tracking         │
│                                         │
│ A backup will be created automatically.│
│                                         │
│ [Cancel] [Migrate Character]           │
└─────────────────────────────────────────┘
    ↓
Create backup
    ↓
Apply migration(s)
    ↓
Validate migrated data against new schema
    ↓
Show success message
┌─────────────────────────────────────────┐
│ Migration Successful! ✓                 │
├─────────────────────────────────────────┤
│ "Ragnar" has been updated to v1.2.0.   │
│                                         │
│ Backup saved: backup-2025-10-23.json   │
│                                         │
│ [View Backup] [Continue]               │
└─────────────────────────────────────────┘

Migration Best Practices

  1. Always Create Backups - Automatic backup before any migration
  2. Sequential Migrations - Chain small migrations (1.0→1.1→1.2) instead of big jumps
  3. Validate After Migration - Ensure migrated data passes schema validation
  4. Provide Rollback - Allow users to restore from backup if issues arise
  5. Document Changes - Clear changelog in migration prompt
  6. Test Thoroughly - Test migrations with real character data before release

Version Compatibility Matrix

// In schema metadata
compatibility:
  minAppVersion: "2.0.0"    // Minimum RPG Companion version required
  maxAppVersion: null       // No maximum (null = any version)
  breaking: false           // Whether this is a breaking change

Widget Integration

Schema-Driven Widget Rendering

// 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 = `
      <div class="schema-component schema-object">
        <h4>${component.label || 'Component'}</h4>
        <div class="schema-properties">
          ${Object.entries(component.properties).map(([key, prop]) => {
            const value = data?.[key] ?? prop.default ?? '';
            const displayValue = prop.type === 'formula'
              ? this.formulaEngine.evaluate(prop.formula)
              : value;

            return `
              <div class="schema-property">
                <label>${prop.label || key}:</label>
                ${prop.type === 'formula'
                  ? `<span class="schema-value-readonly">${displayValue}</span>`
                  : `<input type="${prop.type === 'number' ? 'number' : 'text'}"
                       value="${displayValue}"
                       data-component="${component.label}"
                       data-property="${key}"
                       ${prop.min ? `min="${prop.min}"` : ''}
                       ${prop.max ? `max="${prop.max}"` : ''} />`
                }
                ${prop.abbr ? `<span class="schema-abbr">(${prop.abbr})</span>` : ''}
              </div>
            `;
          }).join('')}
        </div>
      </div>
    `;

    container.innerHTML = html;

    // Add event listeners for editable fields
    container.querySelectorAll('input').forEach(input => {
      input.addEventListener('change', (e) => {
        this.updateProperty(
          e.target.dataset.component,
          e.target.dataset.property,
          e.target.value
        );
      });
    });
  }

  // Render list component (e.g., skills)
  renderList(component, data, container, config) {
    const filter = config.filter; // Optional filter for specific items

    const items = Array.isArray(data) ? data : [];
    const filteredItems = filter
      ? items.filter(item => filter.includes(item.name))
      : items;

    const html = `
      <div class="schema-component schema-list">
        <h4>${component.icon || ''} ${component.label || 'List'}</h4>
        <div class="schema-list-items">
          ${filteredItems.map((item, index) => `
            <div class="schema-list-item">
              ${Object.entries(component.items).map(([key, itemProp]) => {
                const value = item[key] ?? '';
                return `
                  <span class="schema-item-${key}">
                    ${itemProp.label ? `<label>${itemProp.label}:</label>` : ''}
                    ${itemProp.type === 'number'
                      ? `<input type="number" value="${value}" data-index="${index}" data-key="${key}" />`
                      : `<span>${value}</span>`
                    }
                  </span>
                `;
              }).join('')}
            </div>
          `).join('')}
        </div>
        <button class="schema-add-item">+ Add ${component.label}</button>
      </div>
    `;

    container.innerHTML = html;

    // Event listeners for list item editing
    container.querySelectorAll('input').forEach(input => {
      input.addEventListener('change', (e) => {
        this.updateListItem(
          component.label,
          parseInt(e.target.dataset.index),
          e.target.dataset.key,
          e.target.value
        );
      });
    });

    // Add item button
    container.querySelector('.schema-add-item').addEventListener('click', () => {
      this.addListItem(component.label);
    });
  }

  // Render resource component (e.g., HP)
  renderResource(component, data, container) {
    const current = data?.current ?? 0;
    const max = typeof component.max === 'object' && component.max.type === 'formula'
      ? this.formulaEngine.evaluate(component.max.formula)
      : (data?.max ?? 0);

    const percentage = max > 0 ? (current / max) * 100 : 0;
    const color = component.color || '#3366cc';

    const html = `
      <div class="schema-component schema-resource">
        <div class="schema-resource-header">
          <h4>${component.label || 'Resource'}</h4>
          <span class="schema-resource-values">
            <input type="number" class="schema-current" value="${current}" min="0" max="${max}" />
            / ${max}
          </span>
        </div>
        ${component.display === 'bar'
          ? `<div class="schema-resource-bar" style="background: linear-gradient(to right, ${color}, ${color});">
               <div class="schema-resource-fill" style="width: ${100 - percentage}%"></div>
             </div>`
          : `<div class="schema-resource-dots">
               ${Array(max).fill('').map((_, i) => `
                 <span class="schema-dot ${i < current ? 'filled' : ''}" style="background: ${color}"></span>
               `).join('')}
             </div>`
        }
      </div>
    `;

    container.innerHTML = html;

    // Update current value
    container.querySelector('.schema-current').addEventListener('change', (e) => {
      this.updateResource(component.label, 'current', parseInt(e.target.value));
    });
  }

  // Update character property
  updateProperty(componentName, propertyName, value) {
    if (!this.instance.data[componentName]) {
      this.instance.data[componentName] = {};
    }

    this.instance.data[componentName][propertyName] = value;

    // Invalidate formula cache
    this.formulaEngine.invalidateCache();

    // Save character instance
    this.saveInstance();
  }

  // Update list item
  updateListItem(componentName, index, key, value) {
    if (!Array.isArray(this.instance.data[componentName])) {
      this.instance.data[componentName] = [];
    }

    if (!this.instance.data[componentName][index]) {
      this.instance.data[componentName][index] = {};
    }

    this.instance.data[componentName][index][key] = value;

    this.saveInstance();
  }

  // Add new list item
  addListItem(componentName) {
    if (!Array.isArray(this.instance.data[componentName])) {
      this.instance.data[componentName] = [];
    }

    // Create empty item based on component definition
    const component = this.schema.components[componentName];
    const newItem = {};

    for (const [key, prop] of Object.entries(component.items)) {
      newItem[key] = prop.default ?? '';
    }

    this.instance.data[componentName].push(newItem);

    this.saveInstance();

    // Re-render component
    this.renderComponent(componentName, container);
  }

  // Update resource value
  updateResource(componentName, field, value) {
    if (!this.instance.data[componentName]) {
      this.instance.data[componentName] = {};
    }

    this.instance.data[componentName][field] = value;

    this.saveInstance();
  }

  // Save character instance
  saveInstance() {
    // Update timestamp
    this.instance.updatedAt = new Date().toISOString();

    // Save to extension settings
    updateExtensionSettings({ characterInstance: this.instance });

    // Persist to storage
    saveSettings();
  }
}

AI Prompt Generation

Dynamic Prompt Builder

// src/systems/generation/schemaPromptBuilder.js

export function generateSchemaPrompt(schema, characterInstance) {
  let prompt = '';

  // Use schema's prompt templates
  if (schema.prompts) {
    for (const [section, template] of Object.entries(schema.prompts)) {
      // Replace [@component.path] with actual values
      const resolved = resolvePromptTemplate(template, characterInstance.data);
      prompt += resolved + '\n\n';
    }
  } else {
    // Fallback: auto-generate from components
    for (const [name, component] of Object.entries(schema.components)) {
      prompt += generateComponentPrompt(name, component, characterInstance.data[name]);
      prompt += '\n\n';
    }
  }

  return prompt.trim();
}

// Resolve [@reference] syntax in prompt templates
function resolvePromptTemplate(template, data) {
  const refRegex = /\[@([a-zA-Z0-9_.]+)\]/g;

  return template.replace(refRegex, (match, path) => {
    const value = getValueByPath(data, path);
    return value !== undefined ? value : '[Unknown]';
  });
}

// Auto-generate prompt for a component
function generateComponentPrompt(name, component, data) {
  let prompt = `${component.label || name}\n---\n`;

  switch (component.type) {
    case 'object':
      for (const [key, prop] of Object.entries(component.properties)) {
        const value = data?.[key] ?? prop.default ?? '';
        prompt += `${prop.label || key}: ${value}\n`;
      }
      break;

    case 'list':
      if (Array.isArray(data)) {
        data.forEach(item => {
          const values = Object.entries(component.items)
            .map(([key, prop]) => `${prop.label || key}: ${item[key]}`)
            .join(' | ');
          prompt += `${values}\n`;
        });
      }
      break;

    case 'resource':
      prompt += `${component.label}: ${data?.current ?? 0}/${data?.max ?? 0}\n`;
      break;
  }

  return prompt;
}

Migration from Hardcoded to Schema

Backward Compatibility Strategy

  1. Keep existing hardcoded mode as fallback
  2. Detect schema presence to switch modes
  3. Provide migration wizard to convert existing characters
// src/systems/schema/migration.js

export async function migrateToSchema() {
  const currentStats = extensionSettings.userStats;
  const currentClassicStats = extensionSettings.classicStats;
  const currentLevel = extensionSettings.level;

  // Load D&D 5e schema as default
  const dnd5eSchema = await schemaStorage.loadSchema('dnd5e-v1.0.0');

  // Map existing data to schema
  const characterInstance = {
    schemaId: 'dnd5e-v1.0.0',
    schemaVersion: '1.0.0',
    data: {
      coreAbilities: {
        strength: currentClassicStats.str,
        dexterity: currentClassicStats.dex,
        constitution: currentClassicStats.con,
        intelligence: currentClassicStats.int,
        wisdom: currentClassicStats.wis,
        charisma: currentClassicStats.cha
      },

      resources: {
        hitPoints: {
          current: Math.round(currentStats.health),
          max: 100 // Default, user can change
        }
      },

      identity: {
        name: getContext().name1,
        level: currentLevel
      },

      inventory: currentStats.inventory
    },

    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  };

  // Save migrated instance
  await schemaStorage.saveCharacterInstance(characterInstance);

  // Enable schema mode
  updateExtensionSettings({
    schemaMode: true,
    activeSchemaId: 'dnd5e-v1.0.0',
    characterInstance
  });

  console.log('[Schema Migration] Successfully migrated to D&D 5e schema');
}

Schema Editor UI

Visual Builder (Future)

┌─────────────────────────────────────────────────────────┐
│ Schema Editor: D&D 5e                          [Save] [×]│
├─────────────────────────────────────────────────────────┤
│ ┌─ Components ──────┐ ┌─ Editor ───────────────────────┐│
│ │ + Core Abilities  │ │ Component: Core Abilities      ││
│ │ + Ability Mods    │ │                                ││
│ │ + Resources       │ │ Type: [Object ▼]               ││
│ │ + Skills          │ │ Label: [Core Abilities]        ││
│ │ + Status Effects  │ │ Icon: [🎲]                     ││
│ │ + Inventory       │ │                                ││
│ │ + Identity        │ │ Properties:                    ││
│ │                   │ │ ┌──────────────────────────────┐││
│ │ [+ Add Component] │ │ │ strength                     │││
│ └───────────────────┘ │ │  Type: number                │││
│                       │ │  Label: "Strength"           │││
│                       │ │  Min: 1, Max: 30             │││
│                       │ │  Default: 10                 │││
│                       │ │ [Edit] [Delete]              │││
│                       │ │                              │││
│                       │ │ dexterity                    │││
│                       │ │  Type: number                │││
│                       │ │  ...                         │││
│                       │ └──────────────────────────────┘││
│                       │ [+ Add Property]                ││
│                       └────────────────────────────────┘│
│                                                          │
│ [YAML View] [Visual Builder]                            │
└─────────────────────────────────────────────────────────┘

Success Criteria

  • Users can import D&D 5e schema YAML
  • Character instance validates against schema
  • Formula engine calculates derived stats correctly
  • Schema-driven widgets render dynamically
  • Users can edit character data through widgets
  • AI prompts generate based on schema
  • Export/import workflows work reliably
  • Backward compatibility maintained (hardcoded mode still works)
  • Migration wizard converts existing characters to schema

Open Questions

  1. Schema Marketplace: Should we host community schemas on GitHub?
  2. Version Compatibility: How to handle schema version upgrades?
  3. Formula Complexity: Limit formula depth to prevent infinite loops?
  4. Multi-Character: Support multiple character instances with different schemas?
  5. Real-Time Sync: Should formulas recalculate on every input change or debounced?

Next Steps

  1. Implement YAML parser and validator
  2. Build formula engine with safe evaluation
  3. Create IndexedDB storage layer
  4. Develop schema-driven widget renderer
  5. Design schema editor UI (YAML + visual builder)
  6. Create D&D 5e reference schema
  7. Build migration wizard
  8. Write documentation and tutorials