Skip to content

Commit 890586a

Browse files
committed
feat: system detection and character field templates (F-3)
Enable Foundry module to detect game system and match against Chronicle. Prerequisite for actor/character sheet sync in F-4. Server: - Expand dnd5e character preset from 4 to 15 fields (ability scores, HP, AC, speed, proficiency) - Add pf2e character preset with 15 PF2e-specific fields (ancestry, heritage, ability mods, etc) - Add CharacterPreset() helper on SystemManifest for finding character presets - New GET /api/v1/campaigns/:id/systems endpoint returning systems with enabled flag - Inject AddonChecker into APIHandler via SetAddonChecker() Foundry module: - SYSTEM_MAP table mapping Foundry game.system.id to Chronicle system IDs - SyncManager._detectSystem() queries systems API on start, stores matched system - New syncCharacters boolean setting and detectedSystem internal setting - Dashboard Status tab shows system match info and character sync availability - i18n keys for system detection UI https://claude.ai/code/session_01XMwxFR8BCi5XvgaSVMSBZB
1 parent 111e99a commit 890586a

13 files changed

Lines changed: 306 additions & 19 deletions

File tree

.ai/status.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-12 -- **Foundry enhancements planning + documentation capture.**
11+
2026-03-12 -- **Sprint F-3: System Detection & Character Field Templates.**
12+
13+
33. **Sprint F-3: System detection & character field templates (DONE).**
14+
- **Server: Manifest expansion** — dnd5e character preset expanded from 4 to 15 fields (added ability scores, HP, AC, speed, proficiency_bonus). New pf2e character preset with 15 PF2e-specific fields (ancestry, heritage, ability mods, perception, etc). Added `CharacterPreset()` method on `SystemManifest`.
15+
- **Server: Systems API** — New `GET /api/v1/campaigns/:id/systems` endpoint returning all registered systems with `enabled` flag per campaign (via `AddonChecker`). `addonChecker` injected into `APIHandler` via `SetAddonChecker()`.
16+
- **Foundry: System detection**`SYSTEM_MAP` maps Foundry `game.system.id` → Chronicle system IDs (`dnd5e`, `pf2e`, `drawsteel`). `SyncManager._detectSystem()` queries systems API on start, stores matched system in `detectedSystem` setting. New `syncCharacters` boolean setting (gated on system match).
17+
- **Foundry: Dashboard** — Status tab shows Foundry system, Chronicle system match (green check/red X), and character sync availability.
18+
- **Next:** F-4 (Actor ↔ Entity Sync) — new `actor-sync.mjs` with system-specific adapters.
1219

1320
32. **Foundry enhancements — planning + F-1/F-2 implementation.**
1421
- **Planning:** Captured Phase F roadmap (F-1 through F-7) in `.ai/todo.md` and `foundry-module/.ai.md`.

.ai/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ _Improve Foundry VTT sync fidelity. Add system-aware character sheet sync. Build
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).
288-
- [ ] **Sprint F-3: System Detection & Character Field Templates**Foundry module reads `game.system.id`, matches against Chronicle campaign systems via `GET /campaigns/:id/systems`. System ID mapping table (`dnd5e→dnd5e`, `pf2e→pathfinder2e`). `CharacterFields()` on System interface. Per-system `character_fields.json` (D&D 5e: abilities, HP, AC, level, class, skills; PF2e: equivalent). Apply field template to "Character" entity type on system enable.
288+
- [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
- [ ] **Sprint F-4: Actor ↔ Entity Sync** — New `actor-sync.mjs` module. Foundry Actor (type: "character") ↔ Chronicle entity (type: "character"). System-specific adapters (`dnd5e-adapter.mjs`, `pf2e-adapter.mjs`) with `toChronicleFields(actor)` / `fromChronicleFields(entity)`. Hooks: `createActor`, `updateActor`, `deleteActor`. WebSocket: `entity.updated` with character type. Same `_syncing` guard pattern. Dashboard "Characters" tab.
290290
- [ ] **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).
291291
- [ ] **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.

foundry-module/.ai.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops:
3939
|------|---------|
4040
| `module.json` | Module manifest. Foundry v12-v13 compatible. Optional deps: Calendaria, Monk's Enhanced Journal |
4141
| `scripts/module.mjs` | Entry point. Registers settings on `init`, starts SyncManager on `ready` (GM only) |
42-
| `scripts/settings.mjs` | 7 world-scoped settings: apiUrl, apiKey, campaignId, syncEnabled, feature toggles |
42+
| `scripts/settings.mjs` | 9 world-scoped settings: apiUrl, apiKey, campaignId, syncEnabled, syncJournals, syncMaps, syncCalendar, syncCharacters + 3 internal: lastSyncTime, syncExclusions, detectedSystem |
4343
| `scripts/api-client.mjs` | REST client (Bearer auth) + WebSocket (auto-reconnect, message queue, event emitter) |
4444
| `scripts/sync-manager.mjs` | Orchestrator. Owns API client, routes WS messages, manages sync mapping lookups |
4545
| `scripts/journal-sync.mjs` | Entity ↔ JournalEntry bidirectional sync. Monk's Enhanced Journal support |
@@ -92,32 +92,33 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops:
9292
- **Shop icon field**: Always returns null (`shop-widget.mjs:146`)
9393
- **Single scene**: Only the active Foundry scene syncs (no multi-scene)
9494
- **GM only**: Full sync runs only for GM users; players get passive updates via Foundry
95-
- **Single text page**: Only first text page synced; multi-page journals lose content
96-
- **Binary permissions**: Only `is_private` mapped to ownership; granular permissions ignored
97-
- **No ownership push**: Foundry ownership changes not pushed to Chronicle
98-
- **No actor/character sync**: No character sheet sync between Foundry actors and Chronicle entities
95+
- **No actor/character sync**: Character sheet sync requires matching game system (F-3 detection done, F-4 sync pending)
9996

10097
## Planned Features (Phase F)
10198

10299
See `.ai/todo.md` Phase F for full sprint breakdown.
103100

104-
### F-1: Journal Sync Fidelity
101+
### F-1: Journal Sync Fidelity (DONE)
105102
- Split entity `entry_html` by `<h1>`/`<h2>` headings into multiple Foundry pages
106103
- Concatenate all text pages back into single `entry` for Foundry→Chronicle
107104
- Track page mapping via `chronicle-sync.pageMap` flag
108105
- Detect ownership changes in `updateJournalEntry` hook, push to Chronicle
109106

110-
### F-2: Granular Permission Mapping
107+
### F-2: Granular Permission Mapping (DONE)
111108
- Map Chronicle `visibility: 'custom'` + `entity_permissions` to per-user Foundry ownership
112109
- Chronicle `view` → Foundry `OBSERVER`, `edit``OWNER`
113110
- New syncapi endpoints: `GET/PUT /entities/:eid/permissions`
114111
- Reverse-map Foundry ownership changes to Chronicle entity permissions
115112

116-
### F-3: System Detection & Character Field Templates
117-
- Read `game.system.id` on init, match against Chronicle campaign systems
118-
- System ID mapping: `{ dnd5e: "dnd5e", pf2e: "pathfinder2e" }`
119-
- Only enable actor sync when systems match
120-
- Per-system `character_fields.json` defining expected Character entity fields
113+
### F-3: System Detection & Character Field Templates (DONE)
114+
- `SYSTEM_MAP` in sync-manager.mjs: `{ dnd5e: "dnd5e", pf2e: "pathfinder2e", drawsteel: "drawsteel" }`
115+
- `SyncManager._detectSystem()` queries `GET /api/v1/campaigns/:id/systems` on start
116+
- Matches Foundry `game.system.id` to Chronicle system, stores in `detectedSystem` setting
117+
- `syncCharacters` boolean setting (gated on system match)
118+
- Dashboard Status tab shows system match info and character sync availability
119+
- Server: Expanded dnd5e character preset (15 fields), added pf2e character preset (15 fields)
120+
- Server: `CharacterPreset()` helper on `SystemManifest`
121+
- Server: New `GET /systems` API endpoint with `enabled` flag per campaign
121122

122123
### F-4: Actor ↔ Entity Sync (new `actor-sync.mjs`)
123124
- Foundry Actors (type: "character") ↔ Chronicle entities (type: "character")

foundry-module/lang/en.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
"SyncCalendar": {
3030
"Name": "Sync Calendar",
3131
"Hint": "Sync Chronicle calendar with Calendaria or Simple Calendar module"
32+
},
33+
"SyncCharacters": {
34+
"Name": "Sync Characters",
35+
"Hint": "Sync Foundry actors with Chronicle character entities (requires matching game system)"
3236
}
3337
},
3438
"Status": {
@@ -113,6 +117,16 @@
113117
"EntitiesSynced": "Entities Synced",
114118
"MapsLinked": "Maps Linked",
115119
"LastSync": "Last sync",
120+
"GameSystem": "Game System",
121+
"FoundrySystem": "Foundry System",
122+
"ChronicleSystem": "Chronicle System",
123+
"SystemMatched": "Matched",
124+
"SystemNoMatch": "No Match",
125+
"SystemNone": "None detected",
126+
"CharacterSync": "Character Sync",
127+
"CharacterSyncEnabled": "Enabled",
128+
"CharacterSyncDisabled": "Disabled",
129+
"CharacterSyncNoSystem": "Requires matching game system",
116130
"RecentActivity": "Recent Activity",
117131
"NoActivity": "No recent activity.",
118132
"ClearLog": "Clear Log",

foundry-module/scripts/settings.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,24 @@ export function registerSettings() {
8383
default: false,
8484
});
8585

86+
// Character sync toggle (requires matching game system).
87+
game.settings.register(MODULE_ID, 'syncCharacters', {
88+
name: game.i18n.localize('CHRONICLE.Settings.SyncCharacters.Name'),
89+
hint: game.i18n.localize('CHRONICLE.Settings.SyncCharacters.Hint'),
90+
scope: 'world',
91+
config: true,
92+
type: Boolean,
93+
default: false,
94+
});
95+
96+
// Internal: detected Chronicle system ID matched from Foundry's game.system.id.
97+
game.settings.register(MODULE_ID, 'detectedSystem', {
98+
scope: 'world',
99+
config: false,
100+
type: String,
101+
default: '',
102+
});
103+
86104
// Internal: last sync timestamp (not shown in UI).
87105
game.settings.register(MODULE_ID, 'lastSyncTime', {
88106
scope: 'world',

foundry-module/scripts/sync-dashboard.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,12 +543,22 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
543543
s => s.getFlag(FLAG_SCOPE, 'mapId')
544544
).length;
545545

546+
// System detection info.
547+
const foundrySystem = this._syncManager?.getFoundrySystemId() || null;
548+
const matchedSystem = this._syncManager?.getMatchedSystem() || null;
549+
const syncCharacters = getSetting('syncCharacters');
550+
546551
return {
547552
connectionState: state,
548553
connectionLabel: this._connectionLabel(state),
549554
lastSyncTime: getSetting('lastSyncTime') || 'Never',
550555
syncedEntities,
551556
linkedScenes,
557+
foundrySystem,
558+
matchedSystem,
559+
systemMatched: !!matchedSystem,
560+
syncCharacters,
561+
characterSyncAvailable: !!matchedSystem,
552562
activityLog: activityLog.slice(0, 50),
553563
};
554564
}

foundry-module/scripts/sync-manager.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
import { ChronicleAPI } from './api-client.mjs';
1010
import { getSetting, setSetting, isConfigured } from './settings.mjs';
1111

12+
/**
13+
* Maps Foundry game.system.id values to Chronicle system IDs.
14+
* Only systems with an explicit mapping can enable character sync.
15+
*/
16+
const SYSTEM_MAP = {
17+
dnd5e: 'dnd5e',
18+
pf2e: 'pathfinder2e',
19+
drawsteel: 'drawsteel',
20+
};
21+
1222
/**
1323
* SyncManager coordinates all Chronicle sync operations.
1424
* It owns the API client and delegates to feature-specific sync modules.
@@ -29,6 +39,12 @@ export class SyncManager {
2939

3040
/** @type {number} Maximum activity log entries. */
3141
this._maxLogEntries = 100;
42+
43+
/** @type {string|null} Matched Chronicle system ID, or null if no match. */
44+
this._matchedSystem = null;
45+
46+
/** @type {string|null} Foundry's game.system.id. */
47+
this._foundrySystemId = null;
3248
}
3349

3450
/**
@@ -61,6 +77,9 @@ export class SyncManager {
6177
return;
6278
}
6379

80+
// Detect game system and match against Chronicle systems.
81+
await this._detectSystem();
82+
6483
// Initialize all registered modules.
6584
for (const mod of this._modules) {
6685
if (typeof mod.init === 'function') {
@@ -100,6 +119,68 @@ export class SyncManager {
100119
console.log('Chronicle: Sync manager stopped');
101120
}
102121

122+
/**
123+
* Returns the matched Chronicle system ID, or null if no match.
124+
* @returns {string|null}
125+
*/
126+
getMatchedSystem() {
127+
return this._matchedSystem;
128+
}
129+
130+
/**
131+
* Returns Foundry's game.system.id.
132+
* @returns {string|null}
133+
*/
134+
getFoundrySystemId() {
135+
return this._foundrySystemId;
136+
}
137+
138+
/**
139+
* Detect the Foundry game system and match it against Chronicle systems.
140+
* Queries the /systems API endpoint to verify the match is enabled
141+
* for this campaign. Stores the result in the detectedSystem setting.
142+
* @private
143+
*/
144+
async _detectSystem() {
145+
this._foundrySystemId = game.system?.id || null;
146+
147+
if (!this._foundrySystemId) {
148+
console.log('Chronicle: No Foundry game system detected');
149+
return;
150+
}
151+
152+
const chronicleId = SYSTEM_MAP[this._foundrySystemId];
153+
if (!chronicleId) {
154+
console.log(`Chronicle: Foundry system "${this._foundrySystemId}" has no Chronicle mapping`);
155+
await setSetting('detectedSystem', '');
156+
return;
157+
}
158+
159+
try {
160+
// Query Chronicle for available systems and check if the mapped one is enabled.
161+
const result = await this.api.get('/systems');
162+
const systems = result.data || [];
163+
const match = systems.find((s) => s.id === chronicleId && s.enabled);
164+
165+
if (match) {
166+
this._matchedSystem = chronicleId;
167+
await setSetting('detectedSystem', chronicleId);
168+
this.logActivity('connect', `Game system matched: ${match.name}`);
169+
console.log(`Chronicle: System matched — Foundry "${this._foundrySystemId}" → Chronicle "${chronicleId}"`);
170+
} else {
171+
await setSetting('detectedSystem', '');
172+
console.log(`Chronicle: System "${chronicleId}" not enabled for this campaign`);
173+
}
174+
} catch (err) {
175+
console.warn('Chronicle: Failed to detect system match', err);
176+
// Fall back to local setting if API call fails.
177+
const cached = getSetting('detectedSystem');
178+
if (cached) {
179+
this._matchedSystem = cached;
180+
}
181+
}
182+
}
183+
103184
/**
104185
* Perform initial sync: pull all changes since last sync time.
105186
* @private

foundry-module/templates/sync-dashboard.hbs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,41 @@
377377
</div>
378378
</div>
379379

380+
{{!-- Game system detection --}}
381+
<div class="system-detection">
382+
<h3 class="section-heading">Game System</h3>
383+
<div class="system-info-grid">
384+
<div class="system-info-row">
385+
<span class="system-label">Foundry System:</span>
386+
<span class="system-value">{{#if foundrySystem}}{{foundrySystem}}{{else}}<em>None</em>{{/if}}</span>
387+
</div>
388+
<div class="system-info-row">
389+
<span class="system-label">Chronicle System:</span>
390+
{{#if systemMatched}}
391+
<span class="system-value system-matched">
392+
<i class="fa-solid fa-check-circle" style="color: #4ade80"></i> {{matchedSystem}} — Matched
393+
</span>
394+
{{else}}
395+
<span class="system-value system-no-match">
396+
<i class="fa-solid fa-circle-xmark" style="color: #f87171"></i> No Match
397+
</span>
398+
{{/if}}
399+
</div>
400+
<div class="system-info-row">
401+
<span class="system-label">Character Sync:</span>
402+
{{#if characterSyncAvailable}}
403+
{{#if syncCharacters}}
404+
<span class="system-value"><i class="fa-solid fa-toggle-on" style="color: #4ade80"></i> Enabled</span>
405+
{{else}}
406+
<span class="system-value"><i class="fa-solid fa-toggle-off"></i> Disabled</span>
407+
{{/if}}
408+
{{else}}
409+
<span class="system-value hint">Requires matching game system</span>
410+
{{/if}}
411+
</div>
412+
</div>
413+
</div>
414+
380415
<div class="last-sync-info">
381416
Last sync: <span>{{lastSyncTime}}</span>
382417
</div>

internal/plugins/syncapi/api_handler.go

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@ import (
1212
"github.com/keyxmakerx/chronicle/internal/apperror"
1313
"github.com/keyxmakerx/chronicle/internal/plugins/campaigns"
1414
"github.com/keyxmakerx/chronicle/internal/plugins/entities"
15+
"github.com/keyxmakerx/chronicle/internal/systems"
1516
"github.com/keyxmakerx/chronicle/internal/widgets/relations"
1617
)
1718

1819
// APIHandler serves the versioned REST API for external tool integration.
1920
// External clients (Foundry VTT, custom scripts) use these endpoints to
2021
// read and write campaign data programmatically via API key authentication.
2122
type APIHandler struct {
22-
syncSvc SyncAPIService
23-
entitySvc entities.EntityService
24-
campaignSvc campaigns.CampaignService
25-
relationSvc relations.RelationService
23+
syncSvc SyncAPIService
24+
entitySvc entities.EntityService
25+
campaignSvc campaigns.CampaignService
26+
relationSvc relations.RelationService
27+
addonChecker AddonChecker
2628
}
2729

2830
// NewAPIHandler creates a new API handler with the required service dependencies.
@@ -641,3 +643,56 @@ func (h *APIHandler) SetEntityPermissions(c echo.Context) error {
641643

642644
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
643645
}
646+
647+
// SetAddonChecker injects the addon checker for system-aware endpoints.
648+
// Called after construction because the addon service is wired separately.
649+
func (h *APIHandler) SetAddonChecker(ac AddonChecker) {
650+
h.addonChecker = ac
651+
}
652+
653+
// --- Systems ---
654+
655+
// systemInfoResponse is the API-safe representation of a game system.
656+
type systemInfoResponse struct {
657+
ID string `json:"id"`
658+
Name string `json:"name"`
659+
Status string `json:"status"`
660+
HasCharacterFields bool `json:"has_character_fields"`
661+
Enabled bool `json:"enabled"`
662+
}
663+
664+
// ListSystems returns game systems available for the campaign.
665+
// Includes built-in systems from the global registry with an enabled flag
666+
// based on per-campaign addon state. Used by the Foundry module to detect
667+
// whether the current game system matches a Chronicle system.
668+
// GET /api/v1/campaigns/:id/systems
669+
func (h *APIHandler) ListSystems(c echo.Context) error {
670+
campaignID := c.Param("id")
671+
ctx := c.Request().Context()
672+
673+
registry := systems.Registry()
674+
result := make([]systemInfoResponse, 0, len(registry))
675+
676+
for _, manifest := range registry {
677+
enabled := false
678+
if h.addonChecker != nil {
679+
ok, err := h.addonChecker.IsEnabledForCampaign(ctx, campaignID, manifest.ID)
680+
if err == nil && ok {
681+
enabled = true
682+
}
683+
}
684+
685+
result = append(result, systemInfoResponse{
686+
ID: manifest.ID,
687+
Name: manifest.Name,
688+
Status: string(manifest.Status),
689+
HasCharacterFields: manifest.CharacterPreset() != nil,
690+
Enabled: enabled,
691+
})
692+
}
693+
694+
return c.JSON(http.StatusOK, map[string]any{
695+
"data": result,
696+
"total": len(result),
697+
})
698+
}

0 commit comments

Comments
 (0)