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.
This commit is contained in:
@@ -260,6 +260,45 @@ components:
|
|||||||
type: text
|
type: text
|
||||||
label: "Background"
|
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
|
# Prompt template for AI generation
|
||||||
prompts:
|
prompts:
|
||||||
stats: |
|
stats: |
|
||||||
@@ -458,7 +497,11 @@ extensionSettings.characterInstance = {
|
|||||||
|
|
||||||
## Formula Engine
|
## Formula Engine
|
||||||
|
|
||||||
### Formula Syntax
|
### Formula Syntax (Enhanced)
|
||||||
|
|
||||||
|
The formula engine supports progressively complex expressions to handle the wide range of TTRPG mechanics.
|
||||||
|
|
||||||
|
#### Level 1: Basic Math and References
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// @ references components in character instance
|
// @ references components in character instance
|
||||||
@@ -470,9 +513,54 @@ extensionSettings.characterInstance = {
|
|||||||
floor((@coreAbilities.strength - 10) / 2) // → 3
|
floor((@coreAbilities.strength - 10) / 2) // → 3
|
||||||
@coreAbilities.strength + 5 // → 21
|
@coreAbilities.strength + 5 // → 21
|
||||||
(@level * 2) + @abilityModifiers.con_mod // → 8
|
(@level * 2) + @abilityModifiers.con_mod // → 8
|
||||||
|
```
|
||||||
|
|
||||||
// Conditional (future)
|
#### Level 2: Conditional Logic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Ternary operator
|
||||||
@coreAbilities.strength > 15 ? "Strong" : "Weak"
|
@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
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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
|
### Safe Expression Parser
|
||||||
@@ -536,12 +624,44 @@ export class FormulaEngine {
|
|||||||
// Safe evaluation (whitelist functions)
|
// Safe evaluation (whitelist functions)
|
||||||
safeEval(expression) {
|
safeEval(expression) {
|
||||||
const allowedFunctions = {
|
const allowedFunctions = {
|
||||||
|
// Math functions
|
||||||
floor: Math.floor,
|
floor: Math.floor,
|
||||||
ceil: Math.ceil,
|
ceil: Math.ceil,
|
||||||
round: Math.round,
|
round: Math.round,
|
||||||
abs: Math.abs,
|
abs: Math.abs,
|
||||||
min: Math.min,
|
min: Math.min,
|
||||||
max: Math.max
|
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
|
// Create sandboxed function
|
||||||
@@ -551,6 +671,31 @@ export class FormulaEngine {
|
|||||||
return func(...Object.values(allowedFunctions));
|
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)
|
// Clear cache (call when character data changes)
|
||||||
invalidateCache() {
|
invalidateCache() {
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
@@ -849,6 +994,541 @@ export class SchemaStorage {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# system.yaml
|
||||||
|
meta:
|
||||||
|
name: "D&D 5th Edition"
|
||||||
|
version: "1.2.0" # Semantic versioning
|
||||||
|
author: "RPG Companion Community"
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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
|
## Widget Integration
|
||||||
|
|
||||||
### Schema-Driven Widget Rendering
|
### Schema-Driven Widget Rendering
|
||||||
|
|||||||
Reference in New Issue
Block a user