Skip to content

Commit a9ad9cf

Browse files
committed
feat: multi-page journal sync, ownership hook, and Phase F planning
Journal sync improvements: - Multi-page support: split entity content by h1/h2 headings into separate Foundry pages; concatenate pages back for Foundry→Chronicle - Ownership changes now pushed to Chronicle (was silently ignored) - New helpers: _splitByHeadings, _collectTextPages, _syncPagesToJournal Phase F planning captured in AI docs for cross-session continuity: - F-1: Journal sync fidelity (this commit) - F-2: Granular permission mapping (Chronicle custom→Foundry per-user) - F-3: System detection & character field templates - F-4: Actor ↔ entity sync with system-specific adapters - F-5: NPC Viewer/Hall campaign page - F-6: Armory/Inventory system - F-7: Shop/Marketplace enhancements https://claude.ai/code/session_01XMwxFR8BCi5XvgaSVMSBZB
1 parent a787358 commit a9ad9cf

4 files changed

Lines changed: 224 additions & 33 deletions

File tree

.ai/status.md

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

1010
## Last Updated
11-
2026-03-11 -- **Foundry module review — bug fixes and feature completion.**
11+
2026-03-12 -- **Foundry enhancements planning + documentation capture.**
12+
13+
32. **Foundry enhancements planning session.** Analyzed gaps in journal sync, permissions, and character sheet support. Captured comprehensive plan in `.ai/todo.md` as Phase F (Sprints F-1 through F-7):
14+
- **F-1: Journal sync fidelity** — Multi-page journal sync (heading-based page splitting), ownership change hook (Foundry→Chronicle permission push).
15+
- **F-2: Granular permission mapping** — Chronicle `visibility: 'custom'` + `entity_permissions` → Foundry per-user ownership. New syncapi permission endpoints.
16+
- **F-3: System detection & character field templates** — Match `game.system.id` to Chronicle systems. `CharacterFields()` on System interface. Per-system field templates for Character entity type.
17+
- **F-4: Actor ↔ entity sync** — New `actor-sync.mjs` with system-specific adapters (dnd5e, pf2e). Bidirectional sync of character stats/attributes.
18+
- **F-5: NPC Viewer / Hall**`/campaigns/:id/npcs` gallery page. Revealed NPCs with portrait/filters. Foundry ownership change → auto-reveal.
19+
- **F-6: Armory / Inventory system** — Items with game-mechanic fields, character inventory tab via relations, system-specific item templates, Foundry actor inventory sync.
20+
- **F-7: Shop / Marketplace enhancements** — Transaction logging, currency tracking, stock management.
21+
Updated `foundry-module/.ai.md` with planned features section.
1222

1323
31. **Foundry module review.** Comprehensive code review of the Foundry VTT sync module found 13 issues. Fixed 9 (deferred ApplicationV2 upgrade):
1424
- **Runtime bugs**: Shop window `{{json}}` helper crash (replaced with data-item-id lookup), drawing coordinate conversion missing percentage↔pixel (tokens had it, drawings didn't), fog reconciliation `_syncing` flag corruption (extracted `_createFogDrawingData`, batch creates), entity_type_id:0 invalid in syncapi handler (added first-type fallback).

.ai/todo.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,18 @@ _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
283+
284+
_Improve Foundry VTT sync fidelity. Add system-aware character sheet sync. Build toward inventory/NPC features._
285+
286+
- [ ] **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). `chronicle-sync.pageMap` flag for page tracking.
287+
- [ ] **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.
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.
289+
- [ ] **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.
290+
- [ ] **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).
291+
- [ ] **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.
292+
- [ ] **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.
293+
282294
### Deferred to Phase S+ (or community contributions)
283295

284296
- [ ] **Module Builder UI** — Guided wizard that helps users create custom game system modules through the web UI. Step-by-step: name/metadata → define categories → define fields per category → paste/upload reference data → preview tooltips → export as module directory. Eliminates need to hand-write manifest.json + data files.

foundry-module/.ai.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,49 @@ 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
99+
100+
## Planned Features (Phase F)
101+
102+
See `.ai/todo.md` Phase F for full sprint breakdown.
103+
104+
### F-1: Journal Sync Fidelity
105+
- Split entity `entry_html` by `<h1>`/`<h2>` headings into multiple Foundry pages
106+
- Concatenate all text pages back into single `entry` for Foundry→Chronicle
107+
- Track page mapping via `chronicle-sync.pageMap` flag
108+
- Detect ownership changes in `updateJournalEntry` hook, push to Chronicle
109+
110+
### F-2: Granular Permission Mapping
111+
- Map Chronicle `visibility: 'custom'` + `entity_permissions` to per-user Foundry ownership
112+
- Chronicle `view` → Foundry `OBSERVER`, `edit``OWNER`
113+
- New syncapi endpoints: `GET/PUT /entities/:eid/permissions`
114+
- Reverse-map Foundry ownership changes to Chronicle entity permissions
115+
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
121+
122+
### F-4: Actor ↔ Entity Sync (new `actor-sync.mjs`)
123+
- Foundry Actors (type: "character") ↔ Chronicle entities (type: "character")
124+
- System-specific adapters: `dnd5e-adapter.mjs`, `pf2e-adapter.mjs`
125+
- Each adapter: `toChronicleFields(actor)` / `fromChronicleFields(entity)`
126+
- Hooks: `createActor`, `updateActor`, `deleteActor`
127+
- WebSocket: `entity.updated` with character type triggers actor update
128+
- Dashboard "Characters" tab
129+
130+
### F-5: NPC Viewer / Hall (website feature)
131+
- Campaign route `/campaigns/:id/npcs` — gallery of revealed NPCs
132+
- Foundry integration: ownership change on NPC journal → auto-reveal on Chronicle
133+
134+
### F-6: Armory / Inventory System
135+
- Items with game-mechanic fields, character "Inventory" tab via relations
136+
- Foundry sync: Actor inventory ↔ Chronicle inventory relations
137+
138+
### F-7: Shop / Marketplace Enhancement
139+
- Transaction logging, currency tracking, stock management
140+
- Foundry: purchase → update character inventory on both sides

foundry-module/scripts/journal-sync.mjs

Lines changed: 155 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,8 @@ export class JournalSync {
165165

166166
await journal.update(updates);
167167

168-
// Update the text page content.
169-
const textPage = journal.pages.find((p) => p.type === 'text');
170-
if (textPage && entity.entry_html) {
171-
await textPage.update({
172-
'text.content': entity.entry_html,
173-
});
174-
}
168+
// Split entity content into pages and sync them.
169+
await this._syncPagesToJournal(journal, entity.entry_html || '');
175170

176171
// Update flags with latest entity data.
177172
await journal.setFlag(FLAG_SCOPE, 'entityType', entity.type_name || '');
@@ -231,27 +226,26 @@ export class JournalSync {
231226
});
232227
}
233228

234-
// Text page with entity content.
235-
if (isMonksActive) {
236-
// Monk's Enhanced Journal uses enhanced page type.
237-
pages.push({
238-
name: entity.name,
239-
type: 'text',
240-
text: { content: entity.entry_html || '' },
241-
sort: 1,
242-
flags: {
243-
'monks-enhanced-journal': {
244-
type: 'base',
245-
},
246-
},
247-
});
248-
} else {
249-
pages.push({
250-
name: entity.name,
229+
// Split entity content into pages by top-level headings.
230+
const sections = this._splitByHeadings(entity.entry_html || '');
231+
232+
let sortIndex = 1;
233+
for (const section of sections) {
234+
const pageData = {
235+
name: section.title,
251236
type: 'text',
252-
text: { content: entity.entry_html || '' },
253-
sort: 1,
254-
});
237+
text: { content: section.content },
238+
sort: sortIndex++,
239+
};
240+
241+
// Monk's Enhanced Journal uses enhanced page flags.
242+
if (isMonksActive) {
243+
pageData.flags = {
244+
'monks-enhanced-journal': { type: 'base' },
245+
};
246+
}
247+
248+
pages.push(pageData);
255249
}
256250

257251
// Determine ownership.
@@ -321,14 +315,14 @@ export class JournalSync {
321315

322316
// Create entity in Chronicle from this new journal.
323317
try {
324-
const textPage = journal.pages.find((p) => p.type === 'text');
325-
const imagePage = journal.pages.find((p) => p.type === 'image');
318+
// Concatenate all text pages into a single entry for Chronicle.
319+
const entryHtml = this._collectTextPages(journal);
326320

327321
const entity = await this._api.post('/entities', {
328322
name: journal.name,
329323
entity_type_id: 0, // Default type — will use first available.
330324
is_private: (journal.ownership?.default ?? 0) < CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
331-
entry: textPage?.text?.content || '',
325+
entry: entryHtml,
332326
});
333327

334328
if (entity) {
@@ -358,6 +352,7 @@ export class JournalSync {
358352

359353
/**
360354
* Handle Foundry JournalEntry update — push changes to Chronicle.
355+
* Detects name, content, and ownership changes and pushes all to Chronicle.
361356
* @param {JournalEntry} journal
362357
* @param {object} change
363358
* @param {object} options
@@ -372,12 +367,13 @@ export class JournalSync {
372367
if (!entityId) return;
373368

374369
try {
375-
const textPage = journal.pages.find((p) => p.type === 'text');
370+
// Concatenate all text pages into a single entry for Chronicle.
371+
const entryHtml = this._collectTextPages(journal);
376372

377373
await this._api.put(`/entities/${entityId}`, {
378374
name: journal.name,
379375
is_private: (journal.ownership?.default ?? 0) < CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
380-
entry: textPage?.text?.content || '',
376+
entry: entryHtml,
381377
});
382378

383379
this._syncing = true;
@@ -428,4 +424,131 @@ export class JournalSync {
428424
if (entity.entity_type_id && exclusions.excludedTypes.includes(entity.entity_type_id)) return true;
429425
return false;
430426
}
427+
428+
// --- Multi-Page Helpers ---
429+
430+
/**
431+
* Split HTML content by top-level headings (h1/h2) into named sections.
432+
* Each section becomes a separate Foundry journal page.
433+
* If no headings are found, returns a single section with the entity name.
434+
* @param {string} html - The entity entry_html content.
435+
* @returns {Array<{title: string, content: string}>}
436+
* @private
437+
*/
438+
_splitByHeadings(html) {
439+
if (!html) return [{ title: 'Content', content: '' }];
440+
441+
// Match h1 or h2 tags to use as page break points.
442+
const headingRegex = /<h[12][^>]*>(.*?)<\/h[12]>/gi;
443+
const matches = [...html.matchAll(headingRegex)];
444+
445+
// No headings found — return as single page.
446+
if (matches.length === 0) {
447+
return [{ title: 'Content', content: html }];
448+
}
449+
450+
const sections = [];
451+
452+
// Content before the first heading (if any).
453+
const preContent = html.substring(0, matches[0].index).trim();
454+
if (preContent) {
455+
sections.push({ title: 'Overview', content: preContent });
456+
}
457+
458+
// Each heading starts a new section, ending at the next heading or end of string.
459+
for (let i = 0; i < matches.length; i++) {
460+
const match = matches[i];
461+
const startAfterHeading = match.index + match[0].length;
462+
const endIndex = i + 1 < matches.length ? matches[i + 1].index : html.length;
463+
const sectionContent = html.substring(startAfterHeading, endIndex).trim();
464+
465+
// Strip HTML tags from heading text for the page title.
466+
const title = match[1].replace(/<[^>]*>/g, '').trim() || `Section ${i + 1}`;
467+
468+
// Include the heading in the page content for context.
469+
sections.push({
470+
title,
471+
content: match[0] + sectionContent,
472+
});
473+
}
474+
475+
return sections;
476+
}
477+
478+
/**
479+
* Collect all text pages from a Foundry JournalEntry and concatenate
480+
* them into a single HTML string for Chronicle. Pages are joined in
481+
* sort order.
482+
* @param {JournalEntry} journal
483+
* @returns {string} Combined HTML content.
484+
* @private
485+
*/
486+
_collectTextPages(journal) {
487+
const textPages = journal.pages
488+
.filter((p) => p.type === 'text')
489+
.sort((a, b) => a.sort - b.sort);
490+
491+
if (textPages.length === 0) return '';
492+
if (textPages.length === 1) return textPages[0].text?.content || '';
493+
494+
// Multiple pages: concatenate with the page name as a heading separator.
495+
return textPages
496+
.map((page) => {
497+
const content = page.text?.content || '';
498+
// If the page content already starts with a heading, use it as-is.
499+
if (/^<h[12][^>]*>/i.test(content.trim())) return content;
500+
// Otherwise, wrap the page name as an h2 heading.
501+
return `<h2>${page.name}</h2>\n${content}`;
502+
})
503+
.join('\n');
504+
}
505+
506+
/**
507+
* Sync entity HTML content to journal pages. Splits by headings and
508+
* updates existing pages or creates/removes pages as needed.
509+
* @param {JournalEntry} journal
510+
* @param {string} html - Entity entry_html content.
511+
* @private
512+
*/
513+
async _syncPagesToJournal(journal, html) {
514+
const sections = this._splitByHeadings(html);
515+
const existingTextPages = journal.pages
516+
.filter((p) => p.type === 'text')
517+
.sort((a, b) => a.sort - b.sort);
518+
519+
// Update existing pages and create new ones as needed.
520+
for (let i = 0; i < sections.length; i++) {
521+
const section = sections[i];
522+
523+
if (i < existingTextPages.length) {
524+
// Update existing page.
525+
const page = existingTextPages[i];
526+
const updates = { 'text.content': section.content };
527+
if (page.name !== section.title) {
528+
updates.name = section.title;
529+
}
530+
await page.update(updates);
531+
} else {
532+
// Create new page.
533+
await journal.createEmbeddedDocuments('JournalEntryPage', [
534+
{
535+
name: section.title,
536+
type: 'text',
537+
text: { content: section.content },
538+
sort: (existingTextPages.length + i) * 100,
539+
},
540+
]);
541+
}
542+
}
543+
544+
// Remove excess pages if entity has fewer sections than journal has pages.
545+
if (sections.length < existingTextPages.length) {
546+
const pagesToDelete = existingTextPages
547+
.slice(sections.length)
548+
.map((p) => p.id);
549+
if (pagesToDelete.length > 0) {
550+
await journal.deleteEmbeddedDocuments('JournalEntryPage', pagesToDelete);
551+
}
552+
}
553+
}
431554
}

0 commit comments

Comments
 (0)