diff --git a/docs/features/schema-system-architecture.md b/docs/features/schema-system-architecture.md index ada12a4..94f7423 100644 --- a/docs/features/schema-system-architecture.md +++ b/docs/features/schema-system-architecture.md @@ -260,6 +260,45 @@ components: 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: | @@ -458,7 +497,11 @@ extensionSettings.characterInstance = { ## 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 // @ references components in character instance @@ -470,9 +513,54 @@ extensionSettings.characterInstance = { floor((@coreAbilities.strength - 10) / 2) // → 3 @coreAbilities.strength + 5 // → 21 (@level * 2) + @abilityModifiers.con_mod // → 8 +``` -// Conditional (future) +#### Level 2: Conditional Logic + +```javascript +// 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 + +```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 @@ -536,12 +624,44 @@ export class FormulaEngine { // 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 + 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 @@ -551,6 +671,31 @@ export class FormulaEngine { 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(); @@ -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 + +
+
+
STR
+
10
+
+0
+
+ +
+
DEX
+
10
+
+0
+
+ + +
+``` + +```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 `
Custom template: ${templatePath}
`; + } + + // 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 ### Schema-Driven Widget Rendering