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

1319 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = `
<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
```javascript
// 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
```javascript
// 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