Skip to content

Commit be4d935

Browse files
committed
feat: generic system adapter & dynamic matching (Sprint F-4.5)
Enable any game system (including custom uploads) to participate in Foundry character sync without hand-written adapters. Server-side: - Add foundry_system_id to SystemManifest for automatic system matching - Add foundry_path/foundry_writable annotations to FieldDef for field mapping - New GET /systems/:id/character-fields API endpoint - CampaignSystemLister interface for custom system support Foundry module: - sync-manager.mjs: API-driven system detection (matches by foundry_system_id) - generic-adapter.mjs: data-driven adapter reads field defs from API - actor-sync.mjs: falls back to generic adapter for unknown systems Manifests annotated: dnd5e (15 fields), pf2e (15 fields, most read-only), drawsteel (foundry_system_id only). 7 new tests. https://claude.ai/code/session_01PeB1HsjEYNPSY2iqa7sqqR
1 parent 2acef31 commit be4d935

14 files changed

Lines changed: 572 additions & 77 deletions

File tree

.ai/phases.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,23 @@ Make the Foundry integration robust and debuggable for real-world use.
9494
**Key files:** `internal/systems/campaign_systems.go`, `internal/systems/custom_system.templ`,
9595
`foundry-module/scripts/sync-manager.mjs`, Foundry dashboard templates
9696

97-
#### Sprint F-5: NPC Viewer / Hall
97+
#### Sprint F-5: NPC Viewer / Hall (Plugin + Widget + Foundry Sync)
9898

99-
Campaign route `/campaigns/:id/npcs` — gallery/grid of revealed NPCs (non-private
100-
character entities). Portrait, name, description, location, faction tags. Filters
101-
by location/organization/relation. "Reveal" = DM toggles visibility. Foundry:
102-
ownership change on NPC journal auto-reveals on Chronicle.
99+
Full NPC plugin at `internal/plugins/npcs/` with its own widget. Campaign route
100+
`/campaigns/:id/npcs` — gallery/grid of revealed NPCs (non-private character
101+
entities). Portrait, name, description, location, faction tags. Filters by
102+
location/organization/relation. "Reveal" = DM toggles visibility.
103103

104-
**Key files:** `internal/plugins/entities/`, new NPC templ views, `foundry-module/`
104+
**Widget:** `npc_gallery` layout block type for entity pages and dashboards.
105+
Mounts a filterable NPC card grid. Registered in block registry.
106+
107+
**Foundry Sync:** NPC gallery on Chronicle stays in sync with Foundry. When a
108+
DM reveals/hides an NPC in Foundry (ownership change), the corresponding
109+
Chronicle entity visibility updates automatically and vice versa. The Foundry
110+
module's actor-sync detects NPC-type actors and pushes visibility changes.
111+
112+
**Key files:** `internal/plugins/npcs/` (new), `static/js/widgets/npc_gallery.js`
113+
(new), `foundry-module/scripts/actor-sync.mjs`, entity templates
105114

106115
---
107116

.ai/status.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-12 -- **Phase & sprint plan reorg.**
11+
2026-03-12 -- **Sprint F-4.5: Generic System Adapter & Dynamic Matching.**
12+
13+
37. **Sprint F-4.5: Generic System Adapter (DONE).**
14+
- **Manifest schema** — Added `foundry_system_id` to `SystemManifest`, `foundry_path` and `foundry_writable` to `FieldDef`. `IsFoundryWritable()` helper defaults to true when nil. New `CharacterFieldsForAPI()` builds API response with field annotations.
15+
- **API endpoint**`GET /api/v1/campaigns/:id/systems/:systemId/character-fields` returns field definitions with Foundry path annotations. Supports both built-in and custom campaign systems via `CampaignSystemLister` interface.
16+
- **Manifest annotations** — dnd5e: all 15 character fields annotated with `foundry_path`; AC, speed, proficiency_bonus marked `foundry_writable: false`. PF2e: all 15 fields annotated; only hp_current/hp_max writable (everything else derived). DrawSteel: `foundry_system_id: "draw-steel"` added.
17+
- **Foundry sync-manager.mjs**`_detectSystem()` now API-driven: queries `/systems` and matches by `foundry_system_id` first, falls back to `SYSTEM_MAP_FALLBACK` for legacy support. Custom-uploaded systems with `foundry_system_id` auto-match.
18+
- **Foundry generic-adapter.mjs** — New data-driven adapter that fetches field definitions from `/systems/:id/character-fields` API. Auto-generates `toChronicleFields()` and `fromChronicleFields()` from field annotations. Respects `foundry_writable` and field types for casting.
19+
- **Foundry actor-sync.mjs**`_loadAdapter()` tries built-in adapters (dnd5e, pf2e) first, then falls back to generic adapter for any other system.
20+
- **Tests** — Added 7 tests for `IsFoundryWritable`, `CharacterPreset`, `CharacterFieldsForAPI`, and `LoadManifest` with Foundry annotations. All pass.
21+
- **Impact:** Any game system (including custom uploads) can now participate in character sync by including `foundry_system_id` and `foundry_path` annotations in its manifest.
1222

1323
36. **Phase & Sprint Plan Reorg.**
1424
- Rewrote `.ai/phases.md` with 6 phases in new priority order based on owner direction:
@@ -166,13 +176,15 @@ Branch: `claude/fix-journal-button-placement-UF4hD`.
166176

167177
## Phase & Sprint Plan
168178
See `.ai/phases.md` for the full roadmap. Phases organized by priority:
169-
- **V**: Obsidian-Style Notes & Discovery ← CURRENT
170-
- **W**: Polish, Ecosystem & Delight
171-
- **T**: Game System Modules & Worldbuilding Tools
172-
- **U**: Collaboration & Platform Maturity
179+
1. **F**: Foundry Completion & QoL (F-4.5, F-QoL, F-5) ← CURRENT
180+
2. **X**: System Modularity & Owner Experience (X-1 through X-5)
181+
3. **W**: Maps & Spatial (W-2, W-2.5)
182+
4. **U**: Collaboration & Polish (U-1/2/3/4/5, W-1, W-4)
183+
5. **T**: Content & Integrations (T-3/4, W-3/5/6)
184+
6. **F**: Foundry Advanced (F-6, F-7)
173185

174186
## Current Phase
175-
**Phase V (Obsidian-Style Notes & Discovery) — Sprint V-2 COMPLETE.**
187+
**Phase 1: Foundry Completion — Sprint F-4.5 IN PROGRESS.**
176188

177189
### Sprint V-2: Backlinks Panel & Entity Aliases (COMPLETE)
178190
- **Fixed migration 060**: Removed incorrect first ALTER that tried to drop 'module' from ENUM directly, causing Error 1265. Kept correct 3-step approach.
@@ -613,9 +625,9 @@ Created `.ai/audit.md` — comprehensive feature parity and completeness audit c
613625
- Updated architecture.md directory structure to reflect systems/ path
614626

615627
## Next Session Should
616-
- **Sprint U-2: Invite System**campaign invite links for easier player onboarding
617-
- **Sprint V-1: Quick Capture**Obsidian-style notes rapid entry
618-
- **Sprint T-3: Guided Worldbuilding Prompts**Writing prompts panel on entity edit page (deferred)
628+
- **Sprint F-4.5: Generic System Adapter**Remove hardcoded SYSTEM_MAP, dynamic matching via API
629+
- **Sprint F-QoL: Foundry Sync Diagnostics**Validation report, health dashboard, error recovery
630+
- **Sprint F-5: NPC Viewer / Hall**Gallery of revealed NPCs
619631
- See `.ai/phases.md` for full execution order
620632

621633
## Known Issues Right Now

.ai/todo.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,17 +279,17 @@ _WASM-sandboxed backend logic via Extism/wazero. See ADR-021._
279279
- [x] **Sprint R-3: Write Host Functions** — 6 write host functions (update_entity_fields, create_event, set_entity_tags, get_entity_tags, create_relation, send_message). 5 new capabilities. 4 write adapters. Plugin-to-plugin async messaging. 10 new tests (48 total).
280280
- [x] **Sprint R-4: Plugin SDK & Developer Tools** — Example WASM plugins (Rust auto-tagger, Go session-logger). Go SDK with MockHost test harness (9 tests). Plugin development guide. 7 new manifest tests. **Phase R complete.**
281281

282-
### Phase F: Foundry Sync Enhancements & Character Integration ← CURRENT (F-4.5 next)
282+
### Phase F: Foundry Sync Enhancements & Character Integration ← CURRENT (F-QoL next)
283283

284284
_Improve Foundry VTT sync fidelity. Add system-aware character sheet sync. Build toward inventory/NPC features._
285285

286286
- [x] **Sprint F-1: Journal Sync Fidelity** — Multi-page journal sync (split entity `entry_html` by headings into Foundry pages, concatenate pages back on Foundry→Chronicle). Ownership change hook (detect ownership changes in `updateJournalEntry` hook, push to Chronicle). Helpers: `_splitByHeadings`, `_collectTextPages`, `_syncPagesToJournal`.
287287
- [x] **Sprint F-2: Granular Permission Mapping** — Map Chronicle `visibility: 'custom'` + `entity_permissions` to Foundry per-user ownership levels (view→OBSERVER, edit→OWNER). New syncapi endpoints: `GET /entities/:eid/permissions`, `PUT /entities/:eid/permissions`. Reverse-map Foundry ownership changes back to Chronicle. Helpers: `_buildOwnership`, `_pushPermissions`. User-specific grants stored in flags but not mapped to Foundry users (requires user ID mapping table — deferred).
288288
- [x] **Sprint F-3: System Detection & Character Field Templates** — Expanded dnd5e character preset (15 fields: class, level, race, alignment + 6 ability scores + HP/AC/speed/proficiency). Added pf2e character preset (15 fields: class, level, ancestry, heritage + 6 ability mods + HP/AC/perception/speed). `CharacterPreset()` helper on `SystemManifest`. New `GET /api/v1/campaigns/:id/systems` endpoint returns available systems with enabled flag. Foundry module: `syncCharacters` + `detectedSystem` settings, `SYSTEM_MAP` table, `_detectSystem()` on start, `getMatchedSystem()` accessor. Dashboard Status tab shows system match info and character sync availability.
289289
- [x] **Sprint F-4: Actor ↔ Entity Sync**`actor-sync.mjs` with bidirectional Actor ↔ entity sync. System adapters: `dnd5e-adapter.mjs` (15 fields), `pf2e-adapter.mjs` (HP/name back only). Dashboard Characters tab with Push button. Registered in module.mjs. TESTING.md updated. **Note: adapters and SYSTEM_MAP are hardcoded — see F-4.5.**
290-
- [ ] **Sprint F-4.5: Generic System Adapter & Dynamic Matching**Remove hardcoded `SYSTEM_MAP` and adapter switch. Instead: (1) Add `foundry_system_id` field to system manifest schema so custom-uploaded systems can declare Foundry compatibility. (2) `_detectSystem()` queries API and matches by `foundry_system_id` instead of static JS map. (3) Add `foundry_path` annotation on character preset field definitions (e.g., `"foundry_path": "system.abilities.str.value"`). (4) New `generic-adapter.mjs` reads character preset fields from API, auto-generates `toChronicleFields()`/`fromChronicleFields()` using `foundry_path` annotations. Fields without `foundry_path` are read-only (pushed to Chronicle but not written back to Foundry). (5) dnd5e/pf2e adapters remain as overrides for edge cases. **Result: any user-uploaded custom game system with a character preset and foundry_path annotations gets automatic character sync.**
290+
- [x] **Sprint F-4.5: Generic System Adapter & Dynamic Matching**Added `foundry_system_id` to manifest, `foundry_path`/`foundry_writable` to FieldDef. New `GET /systems/:id/character-fields` API. `_detectSystem()` now API-driven (matches by `foundry_system_id`). New `generic-adapter.mjs` auto-generates field mappings from API. dnd5e (15 fields), pf2e (15 fields, most read-only), drawsteel annotated. actor-sync.mjs falls back to generic adapter. 7 new tests.
291291
- [ ] **Sprint F-QoL: Foundry Sync Diagnostics & Error Handling** — Validation report UI on system upload (field count, presets, Foundry compatibility). System preview before enabling. Foundry sync health dashboard (connection uptime, last sync timestamps, error log, retry buttons). Graceful error recovery (queue pending changes on disconnect, retry on reconnect). Field mapping debug view in Foundry dashboard.
292-
- [ ] **Sprint F-5: NPC Viewer / Hall** — Campaign route `/campaigns/:id/npcs`. Gallery/grid of revealed NPCs (non-private character entities). Portrait, name, description, location, faction. Filters by location/organization/relation. "Reveal" = DM toggles `is_private`. Foundry integration: ownership change on NPC journal → auto-reveal on Chronicle. Long-term: NPC relationship map (filtered relation graph).
292+
- [ ] **Sprint F-5: NPC Viewer / Hall (Plugin + Widget + Foundry Sync)**Full NPC plugin at `internal/plugins/npcs/` with handler/service/repo/templates. Campaign route `/campaigns/:id/npcs` — gallery/grid of revealed NPCs (non-private character entities). Portrait, name, description, location, faction. Filters by location/organization/relation. "Reveal" = DM toggles visibility. `npc_gallery` layout block type for entity pages and dashboards. Foundry sync: NPC visibility changes sync bidirectionally — DM reveals/hides NPC in Foundry → Chronicle entity visibility updates and vice versa via actor-sync. Long-term: NPC relationship map (filtered relation graph).
293293
- [ ] **Sprint F-6: Armory / Inventory System** — Items as entities with game-mechanic fields (weight, cost, rarity, damage, properties). Character "Inventory" tab/block via entity relations. Relation metadata: equipped, quantity, attunement. System-specific item templates (dnd5e ≠ pf2e). Foundry sync: Actor inventory ↔ Chronicle inventory relations. "Armory" campaign page showing all catalogued items.
294294
- [ ] **Sprint F-7: Shop / Marketplace Enhancement** — Transaction logging (who bought what, when). Currency tracking per character. Stock management (auto-deplete on purchase). Foundry: purchase from shop window → update character inventory on both sides.
295295

foundry-module/scripts/actor-sync.mjs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
import { getSetting } from './settings.mjs';
17+
import { createGenericAdapter } from './adapters/generic-adapter.mjs';
1718

1819
// Flag namespace for Chronicle data stored on Foundry documents.
1920
const FLAG_SCOPE = 'chronicle-sync';
@@ -418,27 +419,40 @@ export class ActorSync {
418419

419420
/**
420421
* Load the appropriate system adapter based on the matched Chronicle system.
422+
* Tries hand-written adapters first (best quality), then falls back to the
423+
* generic API-driven adapter for custom or unknown systems.
421424
* @returns {Promise<object|null>} Adapter module or null.
422425
* @private
423426
*/
424427
async _loadAdapter() {
425428
const matchedSystem = getSetting('detectedSystem');
426429
if (!matchedSystem) return null;
427430

431+
// Try hand-written adapters first for known systems.
428432
try {
429433
switch (matchedSystem) {
430434
case 'dnd5e':
431435
return await import('./adapters/dnd5e-adapter.mjs');
432436
case 'pathfinder2e':
433437
return await import('./adapters/pf2e-adapter.mjs');
434-
default:
435-
console.warn(`Chronicle: No adapter for system "${matchedSystem}"`);
436-
return null;
437438
}
438439
} catch (err) {
439-
console.error(`Chronicle: Failed to load adapter for "${matchedSystem}"`, err);
440-
return null;
440+
console.warn(`Chronicle: Failed to load built-in adapter for "${matchedSystem}", trying generic`, err);
441441
}
442+
443+
// Fall back to generic adapter (reads field defs from API).
444+
try {
445+
const generic = await createGenericAdapter(this._api, matchedSystem);
446+
if (generic) {
447+
console.log(`Chronicle: Using generic adapter for "${matchedSystem}"`);
448+
return generic;
449+
}
450+
} catch (err) {
451+
console.error(`Chronicle: Failed to create generic adapter for "${matchedSystem}"`, err);
452+
}
453+
454+
console.warn(`Chronicle: No adapter available for system "${matchedSystem}"`);
455+
return null;
442456
}
443457

444458
/**
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Chronicle Sync - Generic System Adapter
3+
*
4+
* A data-driven adapter that reads field definitions from the Chronicle
5+
* /systems/:id/character-fields API. This allows any game system — including
6+
* custom-uploaded ones — to sync character fields between Chronicle and Foundry
7+
* without a hand-written adapter, as long as the system manifest includes
8+
* foundry_path annotations on its character fields.
9+
*
10+
* Field definitions specify:
11+
* - key: Chronicle field key (e.g. "hp_current")
12+
* - foundry_path: dot-notation path on actor.system (e.g. "system.attributes.hp.value")
13+
* - foundry_writable: whether Chronicle may write back to this Foundry path (default true)
14+
* - type: field type ("number", "string", etc.) for casting
15+
*/
16+
17+
/**
18+
* Create a generic adapter instance by fetching field definitions from the API.
19+
*
20+
* @param {import('../api-client.mjs').ChronicleAPI} api - Chronicle API client.
21+
* @param {string} chronicleSystemId - The Chronicle system ID (e.g. "dnd5e").
22+
* @returns {Promise<{systemId: string, characterTypeSlug: string, toChronicleFields: function, fromChronicleFields: function}|null>}
23+
*/
24+
export async function createGenericAdapter(api, chronicleSystemId) {
25+
let fieldDefs;
26+
try {
27+
const resp = await api.get(`/systems/${chronicleSystemId}/character-fields`);
28+
if (!resp || !resp.fields || resp.fields.length === 0) {
29+
console.warn(`Chronicle: Generic adapter — no character fields for system "${chronicleSystemId}"`);
30+
return null;
31+
}
32+
fieldDefs = resp;
33+
} catch (err) {
34+
console.error(`Chronicle: Generic adapter — failed to load field defs for "${chronicleSystemId}"`, err);
35+
return null;
36+
}
37+
38+
// Only include fields that have a foundry_path annotation.
39+
const mappedFields = fieldDefs.fields.filter((f) => f.foundry_path);
40+
if (mappedFields.length === 0) {
41+
console.warn(`Chronicle: Generic adapter — no fields with foundry_path for "${chronicleSystemId}"`);
42+
return null;
43+
}
44+
45+
const writableFields = mappedFields.filter((f) => f.foundry_writable !== false);
46+
47+
console.log(
48+
`Chronicle: Generic adapter loaded for "${chronicleSystemId}" — ` +
49+
`${mappedFields.length} fields mapped, ${writableFields.length} writable`
50+
);
51+
52+
return {
53+
/** Chronicle system ID. */
54+
systemId: chronicleSystemId,
55+
56+
/** Character entity type slug from the manifest. */
57+
characterTypeSlug: fieldDefs.preset_slug || `${chronicleSystemId}-character`,
58+
59+
/**
60+
* Extract Chronicle-compatible fields_data from a Foundry Actor.
61+
* Reads each mapped field from the actor using its foundry_path.
62+
*
63+
* @param {Actor} actor - Foundry Actor document.
64+
* @returns {object} Chronicle fields_data object.
65+
*/
66+
toChronicleFields(actor) {
67+
const result = {};
68+
for (const field of mappedFields) {
69+
const value = _getNestedValue(actor, field.foundry_path);
70+
result[field.key] = value ?? null;
71+
}
72+
return result;
73+
},
74+
75+
/**
76+
* Convert Chronicle entity fields_data into a Foundry Actor update.
77+
* Only writes to fields marked as foundry_writable (or defaulting to true).
78+
* Returns dot-notation keys for actor.update().
79+
*
80+
* @param {object} entity - Chronicle entity with fields_data.
81+
* @returns {object} Foundry Actor update data.
82+
*/
83+
fromChronicleFields(entity) {
84+
const f = entity.fields_data || {};
85+
const update = {};
86+
87+
for (const field of writableFields) {
88+
const value = f[field.key];
89+
if (value == null) continue;
90+
91+
// Cast to appropriate type.
92+
if (field.type === 'number') {
93+
update[field.foundry_path] = Number(value);
94+
} else {
95+
update[field.foundry_path] = value;
96+
}
97+
}
98+
99+
// Name is synced at document level.
100+
if (entity.name) update.name = entity.name;
101+
102+
return update;
103+
},
104+
};
105+
}
106+
107+
/**
108+
* Read a nested value from an object using dot-notation path.
109+
* Supports both nested objects and Foundry's system data.
110+
* e.g., _getNestedValue(actor, "system.abilities.str.value")
111+
*
112+
* @param {object} obj
113+
* @param {string} path
114+
* @returns {*}
115+
*/
116+
function _getNestedValue(obj, path) {
117+
const keys = path.split('.');
118+
let current = obj;
119+
for (const key of keys) {
120+
if (current == null || typeof current !== 'object') return undefined;
121+
current = current[key];
122+
}
123+
return current;
124+
}

0 commit comments

Comments
 (0)