Files
rpg-companion-sillytavern/docs/features/schema-system-architecture.md
T
Lucas 'Paperboy' Rose-Winters c56ce72a9b docs: add v2.0 architecture and implementation plan
- Add comprehensive widget dashboard system design
- Add schema system architecture with ECS pattern
- Add detailed implementation plan with 8 epics
- Include task breakdown with checkboxes for progress tracking
- Document widget development guide
- Document formula engine and YAML schema format
- Add migration strategy and backward compatibility plan
- Estimate 12-14 weeks total development time

This branch will contain all v2.0 development work:
- Widget dashboard with drag-and-drop
- Schema system with YAML definitions
- Formula engine with @ references
- Schema-driven widgets
- AI integration updates
- Mobile responsive improvements

Each epic builds on the previous with clear dependencies.
All features designed for progressive enhancement without modes.
2025-10-23 08:42:16 +11:00

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

# 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

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

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

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

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