|
| 1 | +# System Adapter Architecture |
| 2 | + |
| 3 | +System adapters map between Foundry VTT Actor data and Chronicle entity `fields_data`. |
| 4 | +They enable character sync by translating game-system-specific data paths. |
| 5 | + |
| 6 | +## Adapter Interface |
| 7 | + |
| 8 | +Every adapter (built-in or generic) exposes: |
| 9 | + |
| 10 | +```javascript |
| 11 | +{ |
| 12 | + systemId: string, // Chronicle system ID (e.g., "dnd5e", "drawsteel") |
| 13 | + characterTypeSlug: string, // Entity type slug (e.g., "dnd5e-character") |
| 14 | + actorType: string, // Foundry Actor type (e.g., "character", "hero") |
| 15 | + |
| 16 | + toChronicleFields(actor: Actor): object, |
| 17 | + // Reads Foundry Actor system data, returns Chronicle fields_data object. |
| 18 | + // Example: { str: 18, hp_current: 45, level: 5 } |
| 19 | + |
| 20 | + fromChronicleFields(entity: object): object, |
| 21 | + // Takes Chronicle entity, returns Foundry actor.update() data. |
| 22 | + // Uses dot-notation keys: { "system.abilities.str.value": 18, name: "Aragorn" } |
| 23 | +} |
| 24 | +``` |
| 25 | + |
| 26 | +## Adapter Loading Priority |
| 27 | + |
| 28 | +When `ActorSync.init()` is called with a matched system ID, it loads an adapter: |
| 29 | + |
| 30 | +``` |
| 31 | +1. Check built-in adapters: |
| 32 | + - "dnd5e" → import('./adapters/dnd5e-adapter.mjs') |
| 33 | + - "pf2e" → import('./adapters/pf2e-adapter.mjs') |
| 34 | + (Hardcoded switch in actor-sync.mjs) |
| 35 | +
|
| 36 | +2. Fall back to generic adapter: |
| 37 | + → createGenericAdapter(api, systemId) |
| 38 | + → Fetches GET /systems/:id/character-fields |
| 39 | + → Auto-generates toChronicleFields/fromChronicleFields from foundry_path annotations |
| 40 | +
|
| 41 | +3. If generic adapter returns null (no fields with foundry_path): |
| 42 | + → Character sync is disabled for this system |
| 43 | +``` |
| 44 | + |
| 45 | +## How actorType Works |
| 46 | + |
| 47 | +Different game systems define different Actor types in Foundry VTT: |
| 48 | + |
| 49 | +| Game System | Foundry system.id | Actor Type | Chronicle Preset Slug | |
| 50 | +|-------------|-------------------|-----------|----------------------| |
| 51 | +| D&D 5e | `dnd5e` | `character` | `dnd5e-character` | |
| 52 | +| Pathfinder 2e | `pf2e` | `character` | `pf2e-character` | |
| 53 | +| Draw Steel | `draw-steel` | `hero` | `drawsteel-character` | |
| 54 | + |
| 55 | +The adapter's `actorType` is used to: |
| 56 | +1. **Filter actors** — Only sync actors of the correct type |
| 57 | +2. **Create actors** — Set `type` when creating new Actor documents |
| 58 | +3. **List actors** — Filter the sync dashboard's character list |
| 59 | + |
| 60 | +The generic adapter reads `actorType` from the API response's `foundry_actor_type` field. |
| 61 | +If not specified, it defaults to `"character"`. |
| 62 | + |
| 63 | +## Writing a Built-In Adapter |
| 64 | + |
| 65 | +Create a new file at `scripts/adapters/<system>-adapter.mjs`: |
| 66 | + |
| 67 | +```javascript |
| 68 | +/** |
| 69 | + * Chronicle Sync - <System Name> Adapter |
| 70 | + * |
| 71 | + * Maps fields between Foundry VTT <system> Actor data and Chronicle entity |
| 72 | + * fields_data. Used by ActorSync when the matched system is "<systemId>". |
| 73 | + */ |
| 74 | + |
| 75 | +/** Chronicle system ID. */ |
| 76 | +export const systemId = '<chronicle-system-id>'; |
| 77 | + |
| 78 | +/** Chronicle entity type slug for characters. */ |
| 79 | +export const characterTypeSlug = '<system>-character'; |
| 80 | + |
| 81 | +/** Foundry VTT actor type. */ |
| 82 | +export const actorType = 'character'; // or 'hero', 'npc', etc. |
| 83 | + |
| 84 | +/** |
| 85 | + * Extract Chronicle fields from a Foundry Actor. |
| 86 | + * @param {Actor} actor - Foundry Actor document. |
| 87 | + * @returns {object} Chronicle fields_data. |
| 88 | + */ |
| 89 | +export function toChronicleFields(actor) { |
| 90 | + const sys = actor.system || {}; |
| 91 | + return { |
| 92 | + // Map each Chronicle field key to its Foundry system data path: |
| 93 | + field_key: sys.path?.to?.value ?? null, |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Convert Chronicle fields to a Foundry Actor update. |
| 99 | + * @param {object} entity - Chronicle entity with fields_data. |
| 100 | + * @returns {object} Foundry update data (dot-notation keys). |
| 101 | + */ |
| 102 | +export function fromChronicleFields(entity) { |
| 103 | + const f = entity.fields_data || {}; |
| 104 | + const update = {}; |
| 105 | + |
| 106 | + // Only write non-null values to avoid overwriting Foundry calculations: |
| 107 | + if (f.field_key != null) update['system.path.to.value'] = Number(f.field_key); |
| 108 | + |
| 109 | + // Name syncs at document level: |
| 110 | + if (entity.name) update.name = entity.name; |
| 111 | + |
| 112 | + return update; |
| 113 | +} |
| 114 | +``` |
| 115 | +
|
| 116 | +Then add the import to the switch in `actor-sync.mjs`: |
| 117 | +
|
| 118 | +```javascript |
| 119 | +async _loadAdapter(systemId) { |
| 120 | + switch (systemId) { |
| 121 | + case 'dnd5e': |
| 122 | + return import('./adapters/dnd5e-adapter.mjs'); |
| 123 | + case 'pathfinder2e': |
| 124 | + return import('./adapters/pf2e-adapter.mjs'); |
| 125 | + case 'newsystem': // ADD THIS |
| 126 | + return import('./adapters/newsystem-adapter.mjs'); // ADD THIS |
| 127 | + default: |
| 128 | + return createGenericAdapter(this.api, systemId); |
| 129 | + } |
| 130 | +} |
| 131 | +``` |
| 132 | +
|
| 133 | +## Generic Adapter Internals |
| 134 | +
|
| 135 | +The generic adapter requires NO code changes. It works for any system whose |
| 136 | +manifest includes `foundry_path` annotations on character fields. |
| 137 | +
|
| 138 | +### How It Works |
| 139 | +
|
| 140 | +1. **Fetch field definitions:** |
| 141 | + ``` |
| 142 | + GET /systems/<systemId>/character-fields |
| 143 | + ``` |
| 144 | +
|
| 145 | +2. **Filter fields with `foundry_path`:** |
| 146 | + Only fields that have a non-empty `foundry_path` are mapped. |
| 147 | +
|
| 148 | +3. **Generate `toChronicleFields(actor)`:** |
| 149 | + For each mapped field, reads `actor.<foundry_path>` using dot-notation traversal. |
| 150 | +
|
| 151 | +4. **Generate `fromChronicleFields(entity)`:** |
| 152 | + For each writable field (`foundry_writable !== false`), writes `entity.fields_data[key]` |
| 153 | + to the `foundry_path` using dot-notation keys for `actor.update()`. |
| 154 | +
|
| 155 | +5. **Type casting:** |
| 156 | + Fields with `type: "number"` are cast via `Number()` before writing to Foundry. |
| 157 | +
|
| 158 | +### Example: Draw Steel |
| 159 | +
|
| 160 | +The Draw Steel manifest defines: |
| 161 | +```json |
| 162 | +{ |
| 163 | + "key": "might", |
| 164 | + "label": "Might", |
| 165 | + "type": "number", |
| 166 | + "foundry_path": "system.characteristics.might.value", |
| 167 | + "foundry_writable": true |
| 168 | +} |
| 169 | +``` |
| 170 | +
|
| 171 | +Generic adapter generates: |
| 172 | +```javascript |
| 173 | +// toChronicleFields: |
| 174 | +result.might = actor.system.characteristics.might.value; |
| 175 | + |
| 176 | +// fromChronicleFields: |
| 177 | +update['system.characteristics.might.value'] = Number(entity.fields_data.might); |
| 178 | +``` |
| 179 | +
|
| 180 | +### When to Use Generic vs Built-In |
| 181 | +
|
| 182 | +**Use the generic adapter** (recommended for new systems): |
| 183 | +- System manifest has `foundry_path` annotations on all character fields |
| 184 | +- Field mappings are straightforward (one Chronicle field = one Foundry path) |
| 185 | +- No special transformation logic needed |
| 186 | +
|
| 187 | +**Write a built-in adapter** when: |
| 188 | +- Fields require complex transformations (e.g., combining multiple Foundry paths) |
| 189 | +- You need to read from paths that can't be expressed as simple dot-notation |
| 190 | +- Performance matters (avoids an extra API call to fetch field definitions) |
| 191 | +- The system is widely used and worth maintaining a hand-optimized adapter |
| 192 | +
|
| 193 | +## D&D 5e Adapter Reference |
| 194 | +
|
| 195 | +The `dnd5e-adapter.mjs` maps 15 fields: |
| 196 | +
|
| 197 | +| Chronicle Key | Foundry Path | Writable | Notes | |
| 198 | +|---------------|-------------|----------|-------| |
| 199 | +| `str` | `system.abilities.str.value` | Yes | Ability score | |
| 200 | +| `dex` | `system.abilities.dex.value` | Yes | | |
| 201 | +| `con` | `system.abilities.con.value` | Yes | | |
| 202 | +| `int` | `system.abilities.int.value` | Yes | | |
| 203 | +| `wis` | `system.abilities.wis.value` | Yes | | |
| 204 | +| `cha` | `system.abilities.cha.value` | Yes | | |
| 205 | +| `hp_current` | `system.attributes.hp.value` | Yes | Current HP | |
| 206 | +| `hp_max` | `system.attributes.hp.max` | Yes | Max HP | |
| 207 | +| `ac` | `system.attributes.ac.value` | No | Calculated | |
| 208 | +| `speed` | `system.attributes.movement.walk` | No | | |
| 209 | +| `level` | `system.details.level` | No | | |
| 210 | +| `class` | `system.details.class` | No | | |
| 211 | +| `race` | `system.details.race` | No | | |
| 212 | +| `alignment` | `system.details.alignment` | No | | |
| 213 | +| `proficiency_bonus` | `system.attributes.prof` | No | Calculated | |
| 214 | +
|
| 215 | +Note: The built-in adapter only writes ability scores and HP back to Foundry. |
| 216 | +All other fields are read-only (pulled from Foundry to Chronicle). This prevents |
| 217 | +overwriting Foundry's calculated values. |
0 commit comments