From 27e79f15d47b804c1721f54a5a947af88723db0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 17:42:05 +0000 Subject: [PATCH 1/3] fix(calendar): extend structure-mismatch guard to the SimpleCalendar path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FM-SYNC-WIRE-FIX-R1 fix 2. onInitialSync's structure guard was gated on `_calendarModule === 'calendaria'`, so a SimpleCalendar world with a structure that differs from Chronicle's fell straight through to `_setLocalDate` and wrote Chronicle's coordinates into an incompatible calendar. Add `_readActiveSimpleCalendarStructure()` (SimpleCalendar's getCurrentCalendar() → numberOfDays/weekdays, with a getAllMonths()/getAllWeekdays() fallback) and a `_readActiveFoundryStructure()` dispatcher; the guard now covers both module paths and still fails OPEN on an unreadable structure. Committed BEFORE fix 1 (initial-sync revival) so the guard is hot in history before revived initial sync can fire date-writes into an unguarded SimpleCalendar world. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018MFs4Q928arZHLwvWWYAQm --- scripts/calendar-sync.mjs | 74 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index 3a26c9e..fc1dd35 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -440,7 +440,7 @@ export class CalendarSync { return; } - // Structure-mismatch guard (B-R2): if the active Calendaria calendar's + // Structure-mismatch guard (B-R2): if the active Foundry calendar's // structure differs from Chronicle's, date coordinates are meaningless // across the wire — pause calendar sync for the session (both directions) // and warn loudly, rather than push a Gregorian date into a custom @@ -449,8 +449,14 @@ export class CalendarSync { // Require BOTH sides readable before comparing — a degraded /calendar // response (no months) must fail OPEN (don't pause), not report a false // 0-vs-N mismatch. - if (this._calendarModule === 'calendaria' && this._chronicleCalendar.months?.length > 0) { - const foundryStruct = this._readActiveCalendariaStructure(); + // + // FM-SYNC-WIRE-FIX: this guard now covers BOTH module paths (was + // Calendaria-only; the SimpleCalendar path fell through to _setLocalDate + // unguarded). It MUST stay hot before fix-1 revives initial sync's + // date-writes into SimpleCalendar worlds. `_readActiveFoundryStructure` + // returns null for an unreadable structure, preserving fail-open. + if (this._chronicleCalendar.months?.length > 0) { + const foundryStruct = this._readActiveFoundryStructure(); if (foundryStruct) { const cmp = compareCalendarStructures(this._chronicleCalendar, foundryStruct); if (!cmp.match) { @@ -519,6 +525,68 @@ export class CalendarSync { } } + /** + * Read the active SimpleCalendar calendar's structure (per-month day counts + + * weekday count) for the mismatch guard — the SimpleCalendar sibling of + * `_readActiveCalendariaStructure`. Before FM-SYNC-WIRE-FIX the structure guard + * was Calendaria-only (`onInitialSync` gated on `_calendarModule === 'calendaria'`), + * so a SimpleCalendar world with a mismatched structure fell through unguarded + * and `_setLocalDate` wrote Chronicle's coordinates into an incompatible calendar. + * + * SimpleCalendar's public API exposes months via `numberOfDays` (base year; the + * `numberOfLeapYearDays` variant is a DOCUMENTED EXCLUSION mirroring the + * Calendaria reader — see `compareCalendarStructures`). Prefer the single + * `getCurrentCalendar()` read; fall back to the flat `getAllMonths()` / + * `getAllWeekdays()` getters for API surfaces that don't expose it. Returns null + * when the structure can't be read — the guard then fails OPEN (sync is only + * paused on a CONFIRMED mismatch, never on inability to compare). + * @returns {{name:string, monthDays:number[], weekdayCount:number}|null} + * @private + */ + _readActiveSimpleCalendarStructure() { + try { + const sc = globalThis.SimpleCalendar?.api; + if (!sc) return null; + let months = []; + let weekdays = []; + let name = 'active Foundry calendar'; + const cal = typeof sc.getCurrentCalendar === 'function' ? sc.getCurrentCalendar() : null; + if (cal) { + months = readArrayLike(cal.months); + weekdays = readArrayLike(cal.weekdays); + name = cal.name || cal.id || name; + } + if (!months.length && typeof sc.getAllMonths === 'function') { + months = readArrayLike(sc.getAllMonths()); + } + if (!weekdays.length && typeof sc.getAllWeekdays === 'function') { + weekdays = readArrayLike(sc.getAllWeekdays()); + } + if (!months.length) return null; + return { + name, + monthDays: months.map((m) => Number(m?.numberOfDays ?? m?.days ?? 0)), + weekdayCount: weekdays.length, + }; + } catch (err) { + console.debug('Chronicle: could not read active SimpleCalendar structure', err?.message); + return null; + } + } + + /** + * Read the active Foundry calendar's structure for the mismatch guard, + * dispatching to the per-module reader. Returns null for an unknown module or + * an unreadable structure (guard fails OPEN). FM-SYNC-WIRE-FIX. + * @returns {{name:string, monthDays:number[], weekdayCount:number}|null} + * @private + */ + _readActiveFoundryStructure() { + if (this._calendarModule === 'calendaria') return this._readActiveCalendariaStructure(); + if (this._calendarModule === 'simple-calendar') return this._readActiveSimpleCalendarStructure(); + return null; + } + /** * Pause calendar sync for the session on a structure mismatch (B-R2): set the * guard flag + detail (which suppresses both push and pull), log, and emit ONE From 886eb2436564e16f5206cdfbea2e96d4026a20e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 17:42:13 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(sync):=20revive=20initial=20sync=20?= =?UTF-8?q?=E2=80=94=20read=20sync.status=20from=20the=20emitted=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FM-SYNC-WIRE-FIX-R1 fix 1 (FM-SYNC-1). api-client emits sync.status UNWRAPPED — `_emit('sync.status', { status: 'connected' })` fans the object straight to listeners — but the SyncManager listener read only `msg.payload?.status`, so its guard was never true. `_performInitialSync` (the full `/sync/pull` since epoch, every module's onInitialSync, the journal duplicate-deletion post-pass) and the reconnect resync (gated on the never-set `_initialSyncDone`) were BOTH dead on every world. Fix the LISTENER, not the emit: extract `_onSyncStatus(msg)` reading `msg.status ?? msg.payload?.status`. The emit stays as-is because client emit shapes are intentionally inconsistent (sync.retryComplete uses {payload}) and the same object feeds the wildcard router, so normalizing at the emit would ripple. The handler is idempotent via the `_initialSyncDone` latch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018MFs4Q928arZHLwvWWYAQm --- scripts/sync-manager.mjs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/sync-manager.mjs b/scripts/sync-manager.mjs index eb57626..9fd4e94 100644 --- a/scripts/sync-manager.mjs +++ b/scripts/sync-manager.mjs @@ -152,12 +152,7 @@ export class SyncManager { this.api.on('*', (msg) => this._routeMessage(msg)); // Listen for connection state changes (must be registered BEFORE connect). - this.api.on('sync.status', async (msg) => { - if (msg.payload?.status === 'connected' && !this._initialSyncDone) { - await this._performInitialSync(); - this._initialSyncDone = true; - } - }); + this.api.on('sync.status', (msg) => this._onSyncStatus(msg)); // Re-pull on reconnect (FM-SYNC-HARDENING §2). Driven off the connection // state machine — a deterministic signal independent of the server's @@ -173,6 +168,35 @@ export class SyncManager { console.debug('Chronicle: Sync manager started'); } + /** + * Handle a `sync.status` event from the API client and fire the one-time + * initial sync on the first 'connected'. + * + * FM-SYNC-WIRE-FIX (FM-SYNC-1): the previous listener read ONLY + * `msg.payload?.status`, but `api-client.mjs` emits `sync.status` UNWRAPPED + * (`_emit('sync.status', { status: 'connected' })` → the listener receives + * `{ status: 'connected' }` directly, with no `payload` envelope). The guard + * was therefore never true, so `_performInitialSync` — and every module's + * `onInitialSync`, plus the journal duplicate-deletion post-pass — never ran + * on first load, and the reconnect resync (gated on the never-set + * `_initialSyncDone`) was dead too. Reading `status` from BOTH shapes revives + * both. The fix is on the LISTENER, not the emit: client emit shapes are + * intentionally inconsistent (`sync.retryComplete` uses `{ payload }`) and the + * same object is fanned out to the wildcard router, so normalizing at the emit + * would ripple. This handler is idempotent: `_initialSyncDone` latches it to a + * single run, and the reconnect resync path (`_onConnectionStateChange`) owns + * subsequent re-pulls. + * @param {{status?: string, payload?: {status?: string}}} msg + * @private + */ + async _onSyncStatus(msg) { + const status = msg?.status ?? msg?.payload?.status; + if (status === 'connected' && !this._initialSyncDone) { + await this._performInitialSync(); + this._initialSyncDone = true; + } + } + /** * React to WebSocket connection-state transitions for the reconnect * re-pull (FM-SYNC-HARDENING §2). From b328f407c961b6318e96beef55c6c861957b4743 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 17:42:30 +0000 Subject: [PATCH 3/3] fix(sync): honest calendar badge, /reveal visibility, flat relation routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FM-SYNC-WIRE-FIX-R1 fixes 3–5 + tests + docs. Fix 3 — honest four-state calendar badge. The dashboard computed "In Sync" from raw y/m/d equality and drove "paused" from a flag the SimpleCalendar path never set. Replace with a pure classifier (scripts/_calendar-sync-state.mjs) producing exactly one of: in-sync / date-drift (with direction) / incompatible-structures (with month & weekday counts) / paused. The dashboard feeds it the live module's pause flag AND its own structure comparison, so an incompatibility surfaces even when the module failed OPEN. Template renders all four states. Fix 4 — visibility toggle. Single + bulk toggles PUT /entities/:id with only {is_private} → UpdateEntity requires a name → 400 (swallowed). Route to POST /entities/:id/reveal (accepts exactly {is_private}). Fix 5 — item relations. DELETE/PUT used nested /entities/:id/relations/:relId[/metadata] (404); create body was camelCase with a null target. Chronicle serves flat DELETE|PUT /relations/:relationId and binds snake_case target_entity_id/relation_type/reverse_relation_type (required target). Fix paths + body (snake_case, object metadata); skip relation-create when the item has no linked Chronicle entity (logged once). Tests: tools/test-calendar-sync-state.mjs (13) + tools/test-sync-wire-fix.mjs (16) cover fixes 1,2,4,5 and the classifier. Full suite green (704). Docs: .ai.md decision log + Files/discrepancies/limitations; API-CONTRACT.md erratum notes on the nonexistent nested relation paths and the reveal body; CLAUDE.md smoke-test checklist. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018MFs4Q928arZHLwvWWYAQm --- .ai.md | 77 +++++++- API-CONTRACT.md | 52 +++++- CLAUDE.md | 11 +- scripts/_calendar-sync-state.mjs | 118 ++++++++++++ scripts/item-sync.mjs | 54 +++++- scripts/sync-dashboard.mjs | 81 ++++++-- templates/sync-dashboard.hbs | 31 ++- tools/test-calendar-sync-state.mjs | 166 ++++++++++++++++ tools/test-sync-wire-fix.mjs | 291 +++++++++++++++++++++++++++++ 9 files changed, 837 insertions(+), 44 deletions(-) create mode 100644 scripts/_calendar-sync-state.mjs create mode 100644 tools/test-calendar-sync-state.mjs create mode 100644 tools/test-sync-wire-fix.mjs diff --git a/.ai.md b/.ai.md index 11f121c..1eb2d0a 100644 --- a/.ai.md +++ b/.ai.md @@ -87,6 +87,68 @@ without the endpoint) is tolerated via a session-singleton debug-log-once, mirroring the RC-4 guard's shared-singleton pattern above. Tested at `tools/test-applied-date-confirm.mjs`. +### Sync wire fixes (FM-SYNC-WIRE-FIX-R1) + +Five defects that made sync silently not work, fixed together. All +module-side; the endpoints/behaviors they target already existed on Chronicle. + +1. **Initial sync never fired (FM-SYNC-1).** `api-client.mjs` emits + `sync.status` UNWRAPPED — `_emit('sync.status', {status:'connected'})`, and + `_emit` fans the object straight to listeners — but the `sync.manager` + listener read only `msg.payload?.status`, so the guard was never true. + `_performInitialSync` (the full `/sync/pull` since epoch, every module's + `onInitialSync`, the journal duplicate-deletion post-pass) and the reconnect + resync (gated on the never-set `_initialSyncDone`) were BOTH dead on every + world. Fix is on the LISTENER — now `_onSyncStatus(msg)` reads + `msg.status ?? msg.payload?.status` — not the emit, because client emit + shapes are intentionally inconsistent (`sync.retryComplete` uses `{payload}`) + and the same object feeds the wildcard router. Pinned: + `tools/test-sync-wire-fix.mjs` (fix 1). +2. **SimpleCalendar skipped the structure guard.** `onInitialSync`'s + mismatch guard was `_calendarModule === 'calendaria'`-only; a SimpleCalendar + world fell through to `_setLocalDate` and wrote Chronicle's coordinates into + a structurally-incompatible calendar. Added + `_readActiveSimpleCalendarStructure()` (SC's `getCurrentCalendar()` → + `numberOfDays`/`weekdays`, with a `getAllMonths`/`getAllWeekdays` fallback) + and a `_readActiveFoundryStructure()` dispatcher; the guard now covers both + paths and still fails OPEN on an unreadable structure. This guard is hot + BEFORE fix 1 in history (it must be, so revived initial sync can't date-jump + an unguarded SC world). Pinned: `tools/test-sync-wire-fix.mjs` (fix 2). +3. **Dishonest sync badge.** The dashboard computed "In Sync" from raw y/m/d + equality and drove "paused" from a flag the SC path never set. Replaced with + a pure four-state classifier `scripts/_calendar-sync-state.mjs` + (`in-sync` · `date-drift` w/ direction · `incompatible-structures` w/ counts + · `paused`); the dashboard feeds it the live module's pause flag AND its own + structure comparison, so an incompatibility surfaces even when the module + failed open. Pinned: `tools/test-calendar-sync-state.mjs`. +4. **Visibility toggle 400'd.** Dashboard single + bulk visibility toggles PUT + `/entities/:id` with only `{is_private}` → Chronicle's UpdateEntity requires + a name → 400 (swallowed). Routed to `POST /entities/:id/reveal` + (ToggleEntityReveal, accepts exactly `{is_private}`). Pinned: + `tools/test-sync-wire-fix.mjs` (fix 4). +5. **Item relations used nonexistent paths + wrong casing.** `item-sync.mjs` + DELETE/PUT used nested `/entities/:id/relations/:relId[/metadata]` (404) and + the create body was camelCase with a null target. Chronicle serves flat + `DELETE|PUT /relations/:relationId` and binds snake_case + `target_entity_id`/`relation_type`/`reverse_relation_type` (requiring a + target). Fixed paths + body (snake_case, object metadata not a JSON string); + relation-create now SKIPS when the item has no linked Chronicle entity (a + custom item has no target to relate to — logged once, not per item). Pinned: + `tools/test-sync-wire-fix.mjs` (fix 5). + +**Disclosures (shipped, not silent):** +- Fix 1 activates code that has NEVER run on any world (full `/sync/pull` since + epoch, every `onInitialSync`, the journal duplicate-deletion pass). Worlds + with pre-existing synced data must be tested, not only fresh worlds. +- Drifted worlds visibly date-jump on first load post-fix (plus a confirm + beacon). Expected. +- **Follow-up (not done here):** Calendaria back-catalog note mappings live in + per-user GM flags (`game.user.setFlag`, `calendar-sync.mjs` ~line 1484). The + back-catalog is idempotent per GM, but a DIFFERENT GM account loading the + world duplicates notes, and hand-authored Calendaria notes duplicate once. + Pre-existing design that fix 1 first EXPOSES (it's what actually runs the + back-catalog). Re-architecting to world-scope flags is deferred. + ## Files | File | Purpose | @@ -117,6 +179,7 @@ mirroring the RC-4 guard's shared-singleton pattern above. Tested at | `scripts/calendar-sync.mjs` | Adapter pattern for Calendaria and SimpleCalendar. 0-indexed↔1-indexed conversion | | `scripts/_realtime-date-guard.mjs` | Pure/session-singleton helper gating `PUT /calendar/date` when Chronicle's `tracks_real_time` is set (RC-4). `shouldSkipDatePush(api)` does the fetch-before-push `GET /calendar/date` check; `isRealTimeRejection(err)` classifies a 422 backstop; `notifyRealTimePushPaused()` shows the once-per-session GM notice. Shared by `calendar-sync.mjs` and `sync-dashboard.mjs`. Unit-tested at `tools/test-realtime-date-signal.mjs`. | | `scripts/_applied-date-confirm.mjs` | FM-SYNC-CONFIRMED-DATE: `confirmAppliedDate(api, {year,month,day})` POSTs `/calendar/date/confirm` (optional endpoint, Chronicle ≥ applied-beacon release) after `calendar-sync.mjs`'s `_setLocalDate` reports a real local apply. `isConfirmNotSupported(err)` classifies a 404/405 (older Chronicle); `notifyConfirmNotSupportedOnce()` debug-logs that tolerance once per session. Never throws — a confirm failure is swallowed so it can't surface as a sync error. Unit-tested at `tools/test-applied-date-confirm.mjs`. | +| `scripts/_calendar-sync-state.mjs` | FM-SYNC-WIRE-FIX fix 3: pure `classifyCalendarSyncState(input)` mapping the dashboard's calendar inputs to one of four honest states — `in-sync` / `date-drift` (w/ direction) / `incompatible-structures` (w/ counts) / `paused`. Priority: module-paused > dashboard-detected structure mismatch > date drift > in-sync. Fails open (never reports incompatible on a missing structure read). Unit-tested at `tools/test-calendar-sync-state.mjs`. | | `scripts/shop-widget.mjs` | Shop window (Application v1). Context menu, drag-to-character-sheet, real-time updates | | `scripts/actor-sync.mjs` | Actor ↔ character entity bidirectional sync. System adapter loading, hook registration. PC-CLAIM-4: addon detection via `isPlayerClaimingEnabled()` (SyncManager), PC sub-type routing (`_resolvePcSubTypeId` matches `preset_category=player_character` or `slug=player-character`), `owner_user_id` gated on addon state, `_maybeShowClaimingHint()` surfaces a one-time advisory when the addon is off but player-owned actors exist. | | `scripts/item-sync.mjs` | Actor inventory ↔ Chronicle "Has Item" relations. System data sync (quantity, equipped) via relation metadata | @@ -171,7 +234,9 @@ See API-CONTRACT.md for the full WebSocket protocol. Summary of handling status: - `calendar.date.advanced` → CalendarSync - `note.created/updated/deleted` → NoteSync - `relation.created/deleted/metadata_updated` → ItemSync -- `sync.status` → API client (connection state) +- `sync.status` → SyncManager `_onSyncStatus` (fires the one-time initial sync + on first `connected`; emitted UNWRAPPED as `{status}`, read as + `msg.status ?? msg.payload?.status` — FM-SYNC-WIRE-FIX fix 1) **Not yet handled (available per contract):** - `calendar.season.changed` — Season boundary crossed @@ -289,17 +354,25 @@ should be verified against the live API and aligned if possible: | Entity visibility (GET) | `visibility: "public"` | `is_private: true/false` + `visibility` | `journal-sync.mjs`, `actor-sync.mjs` | | Sync mapping POST | `type`, `foundry_id` | `chronicle_type`, `external_id`, `external_system`, `sync_direction` | All sync modules | | Sync lookup query | `?foundry_id=X&type=Y` | `?external_system=foundry&external_id=X` | `sync-manager.mjs` | -| Relations (WS payload) | `source_id`, `target_id`, `relation_type_id` | `sourceEntityId`, `targetEntityId`, `relationType` | `item-sync.mjs` | +| Relations (READ / WS payload) | — | `sourceEntityId`, `targetEntityId`, `relationType` (camelCase) | `item-sync.mjs`, `shop-widget.mjs` | +| Relations (WRITE body) | `target_entity_id`, `relation_type`, `reverse_relation_type` (snake_case) | snake_case (fixed FM-SYNC-WIRE-FIX) | `item-sync.mjs` | | Calendar events (POST) | `color`, `icon`, `all_day`, `recurrence_*`, `start_hour`, `start_minute` | Not sent (uses simplified fields) | `calendar-sync.mjs` | **Note:** These may represent legitimate API aliases (the API accepts both forms) or WS vs REST payload differences. Verify with the Chronicle dev before changing. +**Relations are asymmetric on purpose:** Chronicle SERIALIZES relations camelCase +(the read/WS shape — `internal/widgets/relations/model.go`) but BINDS writes +snake_case (`apiCreateRelationRequest`/`apiUpdateRelationRequest`, +`syncapi/api_handler.go`). The module read path was always correct; the write +path was camelCase and 400'd until FM-SYNC-WIRE-FIX. Relation routes are also +FLAT (`/relations/:relationId`), not nested under `/entities/:id`. ## Known Limitations - **Map drawings/tokens/fog/layers**: Managed by Chronicle's web map editor only. Not synced to Foundry. Only markers (pins) sync as Foundry Scene Map Notes - **SimpleCalendar events**: Limited — events managed as journal notes, no CRUD hooks + (the structure-mismatch guard now covers the SC path too, FM-SYNC-WIRE-FIX) - **Shop icon field**: Always returns null (`shop-widget.mjs:146`) - **Single scene**: Only the active Foundry scene syncs (no multi-scene) - **GM only**: Full sync runs only for GM users; players get passive updates via Foundry diff --git a/API-CONTRACT.md b/API-CONTRACT.md index af608dd..89d1238 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -251,9 +251,23 @@ Updates entity permissions. ``` #### POST /entities/:entityId/reveal -Toggles entity reveal state (NPC reveal to players). +Toggles entity reveal state (NPC reveal to players). Body is exactly +`{ "is_private": }` (a `*bool`); an explicit value matching the current +state is a no-op. This is the ONLY correct way to flip visibility from the +module — a bare `PUT /entities/:id` with just `{is_private}` 400s because +UpdateEntity requires a name. -**Used by:** `actor-sync.mjs` +**Request:** +```json +{ "is_private": true } +``` + +**Used by:** `actor-sync.mjs`; `sync-dashboard.mjs` (single + bulk visibility +toggles, routed here as of FM-SYNC-WIRE-FIX-R1) + +> **Erratum (FM-SYNC-WIRE-FIX-R1):** the dashboard visibility toggles +> previously PUT `/entities/:id` with only `{is_private}` → 400 (swallowed), so +> visibility never changed. Now routed to `POST /entities/:id/reveal`. --- @@ -418,32 +432,56 @@ forward/reverse string pairs (17 built-in pairs like "parent of" / "child of"). #### POST /entities/:entityId/relations Create a relation on an entity. Uses the forward label string to identify -the relation type (not a numeric ID). +the relation type (not a numeric ID). The write body binds **snake_case** +(`target_entity_id` / `relation_type` / `reverse_relation_type`) and +`target_entity_id` is **required** (empty → 400). Note the read/WS payload is +camelCase — the shapes are deliberately asymmetric. **Request:** ```json { "target_entity_id": "uuid", "relation_type": "parent of", + "reverse_relation_type": "child of", "metadata": {} } ``` +> **Erratum (FM-SYNC-WIRE-FIX-R1):** `item-sync.mjs` previously sent this body +> camelCase (`targetEntityId`/`relationType`) with a null target, so every +> create 400'd. Now snake_case, and it SKIPS the create when the item has no +> linked Chronicle target entity (a custom Foundry item has nothing to relate +> to). Metadata is sent as a raw object (bound as `json.RawMessage`), not a +> JSON-encoded string. + #### GET /entities/:entityId/relations List all relations on an entity. **Used by:** `item-sync.mjs` → pull inventory relations for actors -#### DELETE /entities/:entityId/relations/:relationId -Delete a relation. +#### DELETE /relations/:relationId +Delete a relation (and its reverse). `relationId` is the numeric relation id. **Used by:** `item-sync.mjs` → remove item from actor inventory -#### PUT /entities/:entityId/relations/:relationId/metadata -Update relation metadata (e.g., item quantity, equipped state). +> **Erratum (FM-SYNC-WIRE-FIX-R1):** the route is FLAT. Earlier revisions of +> this doc (and `item-sync.mjs`) used a nested +> `DELETE /entities/:entityId/relations/:relationId`, which Chronicle does not +> serve (404). Chronicle's real route is `DELETE /relations/:relationId` +> (`syncapi/routes.go`). + +#### PUT /relations/:relationId +Update relation metadata (e.g., item quantity, equipped state). Body is +`{ "metadata": { ... } }` only (bound as `json.RawMessage`); relation type and +target are immutable via this route. **Used by:** `item-sync.mjs` → update inventory item metadata +> **Erratum (FM-SYNC-WIRE-FIX-R1):** the route is FLAT and takes no +> `/metadata` suffix. Earlier revisions used +> `PUT /entities/:entityId/relations/:relationId/metadata` (404). Chronicle's +> real route is `PUT /relations/:relationId` (`syncapi/routes.go`). + --- ### Members diff --git a/CLAUDE.md b/CLAUDE.md index c2fefe3..26335dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,5 +100,12 @@ Integration — Install & Updates". - Recommended once on a live **v14** client (can't be unit-tested headlessly): smoke-test the migrated dialogs (resync / pull / push confirms, the "create entity type" prompt, map pin & marker delete confirms, the calendar cleanup - confirm) and the dashboard Calendar tab (Foundry local date now renders, In/Out - of Sync badge, Push-date button). + confirm) and the dashboard Calendar tab (Foundry local date now renders, the + four-state sync badge — in-sync / date-drift with direction / + incompatible-structures / paused, FM-SYNC-WIRE-FIX — and Push-date button). +- Recommended on a live client after FM-SYNC-WIRE-FIX (can't be unit-tested): + confirm initial sync now fires on a fresh world AND a world with pre-existing + synced data (console shows `_performInitialSync` / "Initial sync complete"); + confirm a SimpleCalendar world with a mismatched structure pauses + badges + "incompatible"; confirm the entity visibility toggle round-trips (via + `/reveal`) and item add/remove/update relations round-trip. diff --git a/scripts/_calendar-sync-state.mjs b/scripts/_calendar-sync-state.mjs new file mode 100644 index 0000000..53d339c --- /dev/null +++ b/scripts/_calendar-sync-state.mjs @@ -0,0 +1,118 @@ +/** + * Chronicle Sync — calendar sync-state classifier (FM-SYNC-WIRE-FIX fix 3). + * + * Pure helper mapping the dashboard's calendar inputs to ONE of four explicit, + * honest states. Extracted from sync-dashboard.mjs so the classification is + * unit-testable in isolation (house pattern: `_overview-model.mjs`, + * `_calendar-probe-state.mjs`). + * + * The pre-fix badge lied twice: it computed "In Sync" from raw y/m/d equality + * alone (no structure awareness, no drift direction) and derived its "paused" + * state from a flag the SimpleCalendar path never set. This classifier replaces + * that with: + * + * - `paused` — the live CalendarSync module has stopped calendar + * sync for the session (`_calendarSyncDisabled`). + * Now fed by BOTH module paths (fix 2 makes the + * SimpleCalendar path set the flag too). + * - `incompatible-structures` — the module has NOT paused, but the dashboard can + * itself read both structures and they differ. This + * is the fail-open case: the module skips pausing on + * an unreadable structure, or isn't running, yet the + * dates on the wire are still meaningless. Carries + * the month/weekday counts. + * - `date-drift` — structures compatible (or not comparable) but the + * dates differ. Carries a direction. + * - `in-sync` — structures compatible and the dates match. + * + * `paused` outranks `incompatible-structures` because a paused module is the + * stronger operational fact (sync is actually off); its detail string already + * spells out the structural reason. Both remain independently reachable, so the + * badge never has to invent a state it can't back with data. + */ + +/** + * Compare two {year, month, day} coordinates. Missing fields sort low. + * @param {{year?:number, month?:number, day?:number}} a + * @param {{year?:number, month?:number, day?:number}} b + * @returns {number} <0 if a0 if a>b + */ +function compareDates(a, b) { + for (const key of ['year', 'month', 'day']) { + const av = Number(a?.[key] ?? 0); + const bv = Number(b?.[key] ?? 0); + if (av !== bv) return av < bv ? -1 : 1; + } + return 0; +} + +/** + * Classify the calendar sync state from the dashboard's already-gathered inputs. + * + * @param {object} input + * @param {boolean} input.paused - CalendarSync `_calendarSyncDisabled` (module + * paused this session). When true the state is `paused` regardless of dates. + * @param {string|null} [input.pausedDetail] - CalendarSync `_calendarMismatchDetail` + * (human-readable reason the module paused). + * @param {{match:boolean, detail:string}|null} [input.structureCmp] - result of + * `compareCalendarStructures` when BOTH structures were readable; null when the + * dashboard could not compare (fail-open — never reports incompatible on a + * missing read). + * @param {string|null} [input.chronicleShape] - e.g. `"12mo/7wd"` (for the + * incompatible-structures detail). + * @param {string|null} [input.foundryShape] - e.g. `"15mo/6wd"`. + * @param {{year:number, month:number, day:number}|null} [input.chronicleDate] + * @param {{year:number, month:number, day:number}|null} [input.foundryDate] + * @returns {{state:('in-sync'|'date-drift'|'incompatible-structures'|'paused'), + * direction:('chronicle-ahead'|'foundry-ahead'|null), detail:string}} + */ +export function classifyCalendarSyncState(input) { + const { + paused = false, + pausedDetail = null, + structureCmp = null, + chronicleShape = null, + foundryShape = null, + chronicleDate = null, + foundryDate = null, + } = input || {}; + + // 1. Module has paused sync for the session — the strongest fact. + if (paused) { + return { + state: 'paused', + direction: null, + detail: pausedDetail || 'Calendar sync is paused for this session.', + }; + } + + // 2. Dashboard-detected structural incompatibility while the module has NOT + // paused (fail-open, or module not running). Only when we actually compared. + if (structureCmp && structureCmp.match === false) { + const shapes = chronicleShape && foundryShape + ? `Chronicle ${chronicleShape} vs Foundry ${foundryShape} — ` + : ''; + return { + state: 'incompatible-structures', + direction: null, + detail: `${shapes}${structureCmp.detail || 'calendar structures differ'}`, + }; + } + + // 3/4. Structures compatible (or not comparable). Compare dates. + // A missing local date can never be confirmed in-sync — report drift with + // an unknown direction rather than claiming synchronization. + if (!foundryDate || !chronicleDate) { + return { state: 'date-drift', direction: null, detail: '' }; + } + const cmp = compareDates(chronicleDate, foundryDate); + if (cmp === 0) { + return { state: 'in-sync', direction: null, detail: '' }; + } + return { + state: 'date-drift', + // Chronicle later than Foundry → Chronicle is ahead (pulling advances Foundry). + direction: cmp > 0 ? 'chronicle-ahead' : 'foundry-ahead', + detail: '', + }; +} diff --git a/scripts/item-sync.mjs b/scripts/item-sync.mjs index 646ff6b..19f2efb 100644 --- a/scripts/item-sync.mjs +++ b/scripts/item-sync.mjs @@ -39,6 +39,14 @@ export class ItemSync { /** @type {number|null} Cached Chronicle entity type ID for items. */ this._itemTypeId = null; + /** + * One-shot guard so the "custom item has no linked Chronicle entity to relate + * to — skipping relation push" notice logs once per session, not per item + * (FM-SYNC-WIRE-FIX fix 5). + * @type {boolean} + */ + this._loggedSkipNoTarget = false; + // Bound hook handlers for cleanup. this._onCreateItem = this._handleCreateItem.bind(this); this._onDeleteItem = this._handleDeleteItem.bind(this); @@ -301,17 +309,37 @@ export class ItemSync { const entityId = actor.getFlag(FLAG_SCOPE, 'entityId'); if (!entityId) return; // Actor not synced. + // FM-SYNC-WIRE-FIX fix 5: Chronicle relations REQUIRE a target entity + // (CreateRelation 400s on an empty target_entity_id). A custom Foundry item + // has no corresponding Chronicle item entity to point at — the pre-fix code + // hard-coded `targetEntityId: null`, so every one of these POSTs 400'd. Only + // an item already linked to a Chronicle entity (its own `entityId` flag, + // e.g. pulled from Chronicle) can carry a relation; skip the rest. Logged + // once per session so bulk-adding custom items doesn't spam the console. + const targetEntityId = item.getFlag(FLAG_SCOPE, 'entityId'); + if (!targetEntityId) { + if (!this._loggedSkipNoTarget) { + this._loggedSkipNoTarget = true; + console.debug(`Chronicle: Item "${item.name}" has no linked Chronicle entity — skipping relation push (custom items aren't stored as relations; further skips silenced this session).`); + } + return; + } + try { - // Create a "Has Item" relation in Chronicle. + // Create a "Has Item" relation in Chronicle. Body is snake_case — Chronicle + // binds target_entity_id/relation_type/reverse_relation_type (the response + // is camelCase, but the write binding is snake_case). Metadata is a raw + // object (json.RawMessage), not a JSON-encoded string, so it round-trips as + // structured JSON rather than a double-encoded string. const relation = await this._api.post(`/entities/${entityId}/relations`, { - targetEntityId: null, // No linked Chronicle item entity (custom item). - relationType: 'Has Item', - reverseRelationType: 'In Inventory Of', - metadata: JSON.stringify({ + target_entity_id: targetEntityId, + relation_type: 'Has Item', + reverse_relation_type: 'In Inventory Of', + metadata: { quantity: item.system?.quantity ?? 1, equipped: item.system?.equipped ?? false, foundry_item_name: item.name, - }), + }, }); if (relation) { @@ -348,7 +376,10 @@ export class ItemSync { if (!entityId) return; try { - await this._api.delete(`/entities/${entityId}/relations/${relationId}`); + // FM-SYNC-WIRE-FIX fix 5: Chronicle serves a FLAT relation route + // (DELETE /relations/:relationId), not the nested + // /entities/:id/relations/:relId the module used to call (which 404'd). + await this._api.delete(`/relations/${relationId}`); console.debug(`Chronicle: Removed item relation for "${item.name}" from Chronicle`); } catch (err) { console.warn(`Chronicle: Failed to remove item relation for "${item.name}"`, err); @@ -382,8 +413,13 @@ export class ItemSync { equipped: item.system?.equipped ?? false, }; - await this._api.put(`/entities/${entityId}/relations/${relationId}/metadata`, { - metadata: JSON.stringify(meta), + // FM-SYNC-WIRE-FIX fix 5: flat route PUT /relations/:relationId (was the + // nested /entities/:id/relations/:relId/metadata, which 404'd), and the + // metadata is a raw object — Chronicle's UpdateRelation binds only + // {metadata} as json.RawMessage, so an object stores structured JSON + // instead of the old double-encoded JSON string. + await this._api.put(`/relations/${relationId}`, { + metadata: meta, }); } catch (err) { console.warn(`Chronicle: Failed to update item metadata for "${item.name}"`, err); diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 8139eba..8b5bfeb 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -26,6 +26,8 @@ import { buildDiagnosticBundle } from './sync-diagnostic-bundle.mjs'; import { buildOverviewModel } from './_overview-model.mjs'; import { log, getLogBuffer } from './logger.mjs'; import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs'; +import { compareCalendarStructures } from './calendar-sync.mjs'; +import { classifyCalendarSyncState } from './_calendar-sync-state.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; /** @@ -664,33 +666,70 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { // Get local Foundry calendar date. const localDate = this._getLocalCalendarDate(calModule); - const inSync = chronicle.current_year === localDate?.year - && chronicle.current_month === localDate?.month - && chronicle.current_day === localDate?.day; - - // Structure-mismatch guard state (FM-CAL-SYNC-HOTFIX item 3, B-R2): surfaced - // from the live CalendarSync instance so the dashboard can show a persistent - // warning + a "paused" badge when calendar sync has been paused this session. + // Honest four-state badge (FM-SYNC-WIRE-FIX fix 3). The pre-fix badge was + // raw y/m/d equality plus a `structureMismatch` flag the SimpleCalendar path + // never set. We now classify into exactly one of: + // in-sync · date-drift (with direction) · incompatible-structures · paused + // + // Inputs, in priority order handled by the classifier: + // - `paused` from the live CalendarSync instance — its + // `_calendarSyncDisabled` guard, now set by BOTH module paths (fix 2). + // - a dashboard-side structure comparison, so an incompatibility is still + // surfaced when the module failed OPEN (unreadable structure at connect) + // or isn't running. Fails open: `structureCmp` stays null unless BOTH + // structures were readable here. const calSync = this._getCalendarSyncModule(); - const structureMismatch = !!calSync?._calendarSyncDisabled; - const mismatchDetail = calSync?._calendarMismatchDetail || null; + const paused = !!calSync?._calendarSyncDisabled; + const pausedDetail = calSync?._calendarMismatchDetail || null; + + const chronicleDate = { + year: chronicle.current_year, + month: chronicle.current_month, + day: chronicle.current_day, + }; + + let structureCmp = null; + let chronicleShape = null; + let foundryShape = null; + if (Array.isArray(chronicle.months) && chronicle.months.length > 0) { + const foundryStruct = calSync?._readActiveFoundryStructure?.() ?? null; + if (foundryStruct) { + structureCmp = compareCalendarStructures(chronicle, foundryStruct); + chronicleShape = `${(chronicle.months || []).length}mo/${(chronicle.weekdays || []).length}wd`; + foundryShape = `${(foundryStruct.monthDays || []).length}mo/${foundryStruct.weekdayCount ?? 0}wd`; + } + } + + const cls = classifyCalendarSyncState({ + paused, + pausedDetail, + structureCmp, + chronicleShape, + foundryShape, + chronicleDate, + foundryDate: localDate ? { year: localDate.year, month: localDate.month, day: localDate.day } : null, + }); return { available: true, enabled: calendarEnabled, module: calModule, chronicleDate: { - year: chronicle.current_year, - month: chronicle.current_month, - day: chronicle.current_day, + ...chronicleDate, hour: chronicle.current_hour ?? 0, minute: chronicle.current_minute ?? 0, calendarName: chronicle.name || 'Campaign Calendar', }, localDate, - inSync, - structureMismatch, - mismatchDetail, + // Four-state model + supporting display fields. + syncState: cls.state, // 'in-sync' | 'date-drift' | 'incompatible-structures' | 'paused' + syncDirection: cls.direction, // 'chronicle-ahead' | 'foundry-ahead' | null + syncStateDetail: cls.detail, // reason string for paused / incompatible-structures + // Convenience booleans for the template (derived from syncState). + inSync: cls.state === 'in-sync', + isPaused: cls.state === 'paused', + isIncompatible: cls.state === 'incompatible-structures', + isDrift: cls.state === 'date-drift', }; } @@ -2011,7 +2050,12 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { */ async _onToggleVisibility(entityId, currentlyPrivate) { try { - await this.api.put(`/entities/${entityId}`, { + // FM-SYNC-WIRE-FIX fix 4: use the purpose-built reveal endpoint. A bare + // PUT /entities/:id with only {is_private} fails Chronicle's UpdateEntity + // name-required validation → 400 (silently swallowed in the catch below), + // so visibility never actually toggled. POST /entities/:id/reveal + // (ToggleEntityReveal) accepts exactly {is_private}. + await this.api.post(`/entities/${entityId}/reveal`, { is_private: !currentlyPrivate, }); @@ -2895,7 +2939,10 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { for (const entityId of ids) { try { - await this.api.put(`/entities/${entityId}`, { is_private: makePrivate }); + // FM-SYNC-WIRE-FIX fix 4: reveal endpoint (see _onToggleVisibility). A + // bare PUT /entities/:id with only {is_private} 400s on UpdateEntity's + // name-required check; POST /entities/:id/reveal accepts {is_private}. + await this.api.post(`/entities/${entityId}/reveal`, { is_private: makePrivate }); const journal = game.journal.find(j => j.getFlag(FLAG_SCOPE, 'entityId') === entityId); if (journal) { diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 9329704..1933517 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -887,15 +887,24 @@

{{localize "CHRONICLE.Dashboard.Calendar.OpenSyncCalendarHint"}}

{{#if calendar.available}} - {{#if calendar.structureMismatch}} + {{#if calendar.isPaused}}
Calendar sync paused — structure mismatch. -
{{calendar.mismatchDetail}}
+
{{calendar.syncStateDetail}}
Import or author the matching calendar in Chronicle. Journals, characters, and maps still sync.
+ {{else if calendar.isIncompatible}} +
+ +
+ Calendar structures don’t match. +
{{calendar.syncStateDetail}}
+
The dates below aren’t comparable across the wire. Sync will pause on the next world reload — import or author the matching calendar in Chronicle.
+
+
{{/if}}
@@ -932,12 +941,20 @@
- {{#if calendar.structureMismatch}} - Sync Paused - {{else if calendar.inSync}} - In Sync + {{#if calendar.isPaused}} + Sync Paused + {{else if calendar.isIncompatible}} + Incompatible Structures + {{else if calendar.isDrift}} + {{#if (eq calendar.syncDirection "chronicle-ahead")}} + Date Drift — Chronicle Ahead + {{else if (eq calendar.syncDirection "foundry-ahead")}} + Date Drift — Foundry Ahead + {{else}} + Out of Sync + {{/if}} {{else}} - Out of Sync + In Sync {{/if}}
diff --git a/tools/test-calendar-sync-state.mjs b/tools/test-calendar-sync-state.mjs new file mode 100644 index 0000000..0d1a8fd --- /dev/null +++ b/tools/test-calendar-sync-state.mjs @@ -0,0 +1,166 @@ +// test-calendar-sync-state.mjs — FM-SYNC-WIRE-FIX fix 3: the honest four-state +// calendar sync-state classifier. Pure helper, no Foundry globals needed. +// +// Run: node --test tools/test-calendar-sync-state.mjs + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { classifyCalendarSyncState } from '../scripts/_calendar-sync-state.mjs'; + +const D = (year, month, day) => ({ year, month, day }); + +// ── paused (module _calendarSyncDisabled) — highest priority ────────────────── + +test('paused wins over everything, even when dates happen to match', () => { + const r = classifyCalendarSyncState({ + paused: true, + pausedDetail: 'Chronicle: Gregorian 12mo/7wd · Foundry: Therin 15mo/6wd — month count', + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 15), // identical, but the module has stopped syncing + }); + assert.equal(r.state, 'paused'); + assert.equal(r.direction, null); + assert.match(r.detail, /Therin 15mo\/6wd/); +}); + +test('paused with no detail still classifies as paused with a generic reason', () => { + const r = classifyCalendarSyncState({ paused: true, pausedDetail: null }); + assert.equal(r.state, 'paused'); + assert.match(r.detail, /paused/i); +}); + +// ── incompatible-structures (dashboard-detected, module NOT paused) ─────────── + +test('module not paused but structures differ → incompatible-structures with counts', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: false, detail: 'month count (Chronicle 12 vs Foundry 15)' }, + chronicleShape: '12mo/7wd', + foundryShape: '15mo/6wd', + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 15), + }); + assert.equal(r.state, 'incompatible-structures'); + assert.equal(r.direction, null); + assert.match(r.detail, /Chronicle 12mo\/7wd vs Foundry 15mo\/6wd/); + assert.match(r.detail, /month count/); +}); + +test('incompatible-structures is reachable even when the fail-open module never paused (regression: SC path)', () => { + // The exact fail-open scenario fix 2 + fix 3 target: SimpleCalendar world, module + // read the structure too late to pause, but the dashboard can compare now. + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: false, detail: 'weekday count (Chronicle 7 vs Foundry 10)' }, + chronicleShape: '12mo/7wd', + foundryShape: '12mo/10wd', + chronicleDate: D(1, 1, 1), + foundryDate: D(1, 1, 1), + }); + assert.equal(r.state, 'incompatible-structures'); +}); + +test('a matching structureCmp does NOT trigger incompatible — falls through to date logic', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true, detail: '' }, + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 15), + }); + assert.equal(r.state, 'in-sync'); +}); + +test('null structureCmp (could not compare) fails open — never reports incompatible', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: null, + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 16), + }); + assert.equal(r.state, 'date-drift'); // drift, not incompatible +}); + +// ── date-drift (with direction) ─────────────────────────────────────────────── + +test('Chronicle later than Foundry → date-drift, chronicle-ahead', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true, detail: '' }, + chronicleDate: D(1492, 5, 1), + foundryDate: D(1492, 3, 15), + }); + assert.equal(r.state, 'date-drift'); + assert.equal(r.direction, 'chronicle-ahead'); +}); + +test('Foundry later than Chronicle → date-drift, foundry-ahead', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true, detail: '' }, + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 16), + }); + assert.equal(r.state, 'date-drift'); + assert.equal(r.direction, 'foundry-ahead'); +}); + +test('drift detection walks year → month → day (a year difference dominates)', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true, detail: '' }, + chronicleDate: D(1490, 12, 30), + foundryDate: D(1492, 1, 1), + }); + assert.equal(r.state, 'date-drift'); + assert.equal(r.direction, 'foundry-ahead'); // 1490 < 1492 despite larger month/day +}); + +test('unreadable local date can never be called in-sync → date-drift, unknown direction', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true, detail: '' }, + chronicleDate: D(1492, 3, 15), + foundryDate: null, + }); + assert.equal(r.state, 'date-drift'); + assert.equal(r.direction, null); +}); + +// ── in-sync ─────────────────────────────────────────────────────────────────── + +test('compatible structures + equal dates → in-sync', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true, detail: '' }, + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 15), + }); + assert.equal(r.state, 'in-sync'); + assert.equal(r.direction, null); +}); + +test('in-sync also holds when structures were simply not comparable but dates match', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: null, + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 15), + }); + assert.equal(r.state, 'in-sync'); +}); + +// ── all four states are reachable (the honest-badge contract) ───────────────── + +test('the classifier yields exactly the four documented states across inputs', () => { + const states = new Set([ + classifyCalendarSyncState({ paused: true }).state, + classifyCalendarSyncState({ paused: false, structureCmp: { match: false, detail: 'x' } }).state, + classifyCalendarSyncState({ paused: false, structureCmp: { match: true }, chronicleDate: D(1, 1, 1), foundryDate: D(1, 1, 2) }).state, + classifyCalendarSyncState({ paused: false, structureCmp: { match: true }, chronicleDate: D(1, 1, 1), foundryDate: D(1, 1, 1) }).state, + ]); + assert.deepEqual( + [...states].sort(), + ['date-drift', 'in-sync', 'incompatible-structures', 'paused'], + ); +}); diff --git a/tools/test-sync-wire-fix.mjs b/tools/test-sync-wire-fix.mjs new file mode 100644 index 0000000..c3e21f7 --- /dev/null +++ b/tools/test-sync-wire-fix.mjs @@ -0,0 +1,291 @@ +// test-sync-wire-fix.mjs — FM-SYNC-WIRE-FIX-R1 behavioral pins for the wire fixes +// that can't be exercised through the pure helpers: +// fix 1 — sync.status listener revives initial sync (accepts both emit shapes) +// fix 2 — SimpleCalendar structure reader + the guard now covering the SC path +// fix 4 — visibility toggle routes to POST /entities/:id/reveal (not a bare PUT) +// fix 5 — item relations use the flat /relations/:id routes + snake_case body +// +// Run: node --test tools/test-sync-wire-fix.mjs + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// --- Foundry global stubs shared by every import chain below --- +globalThis.foundry = globalThis.foundry || { + applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (b) => b } }, +}; +globalThis.game = globalThis.game || { + settings: { get: () => '', set: () => {}, register: () => {}, registerMenu: () => {} }, + i18n: { localize: (k) => k, format: (k) => k }, + modules: { get: () => null }, + users: [], + user: { id: 'u1', isGM: true }, + journal: { find: () => null }, +}; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, once: () => {}, off: () => {} }; +globalThis.CONST = globalThis.CONST || { DOCUMENT_OWNERSHIP_LEVELS: { OBSERVER: 2, NONE: 0 } }; +globalThis.Actor = globalThis.Actor || class {}; + +const { SyncManager } = await import('../scripts/sync-manager.mjs'); +const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); +const { ItemSync } = await import('../scripts/item-sync.mjs'); +const { SyncDashboard } = await import('../scripts/sync-dashboard.mjs'); + +// ═══════════════════════════════════════════════════════════════════════════ +// Fix 1 — sync.status listener revives initial sync (FM-SYNC-1) +// ═══════════════════════════════════════════════════════════════════════════ + +function makeManagerSpy() { + const sm = new SyncManager(); + let count = 0; + sm._performInitialSync = async () => { count += 1; }; + return { sm, calls: () => count }; +} + +test('fix1: the UNWRAPPED emit shape {status:"connected"} fires initial sync (the actual bug)', async () => { + const { sm, calls } = makeManagerSpy(); + await sm._onSyncStatus({ status: 'connected' }); + assert.equal(calls(), 1, 'initial sync fired for the real emit shape'); + assert.equal(sm._initialSyncDone, true, 'latch set so the reconnect resync path is now live'); +}); + +test('fix1: the wrapped shape {payload:{status:"connected"}} still fires it (defensive)', async () => { + const { sm, calls } = makeManagerSpy(); + await sm._onSyncStatus({ payload: { status: 'connected' } }); + assert.equal(calls(), 1); + assert.equal(sm._initialSyncDone, true); +}); + +test('fix1: initial sync is one-shot — a second connected event does not re-run it', async () => { + const { sm, calls } = makeManagerSpy(); + await sm._onSyncStatus({ status: 'connected' }); + await sm._onSyncStatus({ status: 'connected' }); + assert.equal(calls(), 1, 'the _initialSyncDone latch prevents a double initial sync'); +}); + +test('fix1: a non-connected status never fires initial sync', async () => { + const { sm, calls } = makeManagerSpy(); + await sm._onSyncStatus({ status: 'disconnected' }); + await sm._onSyncStatus({ payload: { status: 'reconnecting' } }); + await sm._onSyncStatus({}); + await sm._onSyncStatus(null); + assert.equal(calls(), 0); + assert.equal(sm._initialSyncDone, false); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Fix 2 — SimpleCalendar structure reader + guard now covers the SC path +// ═══════════════════════════════════════════════════════════════════════════ + +function makeCalendarSync(overrides) { + return Object.assign( + Object.create(CalendarSync.prototype), + { _hasModernCalendariaApi: false, _syncDepth: 0, _calendarSyncDisabled: false }, + overrides, + ); +} + +test('fix2: _readActiveSimpleCalendarStructure reads getCurrentCalendar (numberOfDays + weekdays)', () => { + globalThis.SimpleCalendar = { + api: { + getCurrentCalendar: () => ({ + name: 'Calendar of Harptos', + months: [{ numberOfDays: 30 }, { numberOfDays: 30 }, { numberOfDays: 31 }], + weekdays: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}], // 10-day tenday + }), + }, + }; + const cs = makeCalendarSync({ _calendarModule: 'simple-calendar' }); + const s = cs._readActiveSimpleCalendarStructure(); + assert.equal(s.name, 'Calendar of Harptos'); + assert.deepEqual(s.monthDays, [30, 30, 31]); + assert.equal(s.weekdayCount, 10); + delete globalThis.SimpleCalendar; +}); + +test('fix2: _readActiveSimpleCalendarStructure falls back to getAllMonths/getAllWeekdays', () => { + globalThis.SimpleCalendar = { + api: { + getAllMonths: () => [{ numberOfDays: 31 }, { numberOfDays: 28 }], + getAllWeekdays: () => [{}, {}, {}, {}, {}, {}, {}], + }, + }; + const cs = makeCalendarSync({ _calendarModule: 'simple-calendar' }); + const s = cs._readActiveSimpleCalendarStructure(); + assert.deepEqual(s.monthDays, [31, 28]); + assert.equal(s.weekdayCount, 7); + delete globalThis.SimpleCalendar; +}); + +test('fix2: an unreadable SimpleCalendar structure returns null (guard fails OPEN)', () => { + globalThis.SimpleCalendar = { api: {} }; // no readers at all + const cs = makeCalendarSync({ _calendarModule: 'simple-calendar' }); + assert.equal(cs._readActiveSimpleCalendarStructure(), null); + delete globalThis.SimpleCalendar; +}); + +test('fix2: _readActiveFoundryStructure dispatches by module', () => { + globalThis.SimpleCalendar = { api: { getAllMonths: () => [{ numberOfDays: 20 }], getAllWeekdays: () => [{}] } }; + const sc = makeCalendarSync({ _calendarModule: 'simple-calendar' }); + assert.deepEqual(sc._readActiveFoundryStructure().monthDays, [20]); + const unknown = makeCalendarSync({ _calendarModule: null }); + assert.equal(unknown._readActiveFoundryStructure(), null); + delete globalThis.SimpleCalendar; +}); + +test('fix2: onInitialSync PAUSES a SimpleCalendar world whose structure mismatches (was unguarded)', async () => { + game.settings.get = (_scope, key) => (key === 'syncCalendar' ? true : ''); + globalThis.SimpleCalendar = { + api: { getCurrentCalendar: () => ({ name: 'SC 2mo', months: [{ numberOfDays: 30 }, { numberOfDays: 30 }], weekdays: [{}, {}, {}, {}, {}, {}, {}] }) }, + }; + let setLocalCalled = false; + const cs = makeCalendarSync({ + _calendarModule: 'simple-calendar', + _api: { get: async () => ({ current_year: 1, current_month: 1, current_day: 1, months: new Array(12).fill({ days: 30 }), weekdays: new Array(7).fill({}) }) }, + _setLocalDate: async () => { setLocalCalled = true; return false; }, + }); + await cs.onInitialSync(); + assert.equal(cs._calendarSyncDisabled, true, 'SC structure mismatch pauses calendar sync'); + assert.equal(setLocalCalled, false, 'no date was written into the incompatible SC calendar'); + assert.match(cs._calendarMismatchDetail, /month count/); + delete globalThis.SimpleCalendar; +}); + +test('fix2: onInitialSync does NOT pause a matching SimpleCalendar world (writes the date)', async () => { + game.settings.get = (_scope, key) => (key === 'syncCalendar' ? true : ''); + globalThis.SimpleCalendar = { + api: { getCurrentCalendar: () => ({ name: 'SC match', months: new Array(12).fill({ numberOfDays: 30 }), weekdays: new Array(7).fill({}) }) }, + }; + let setLocalCalled = false; + const cs = makeCalendarSync({ + _calendarModule: 'simple-calendar', + _api: { get: async () => ({ current_year: 1, current_month: 1, current_day: 1, months: new Array(12).fill({ days: 30 }), weekdays: new Array(7).fill({}) }) }, + _setLocalDate: async () => { setLocalCalled = true; return false; }, + }); + await cs.onInitialSync(); + assert.equal(cs._calendarSyncDisabled, false, 'a compatible SC structure does not pause'); + assert.equal(setLocalCalled, true, 'the date apply path runs when structures match'); + delete globalThis.SimpleCalendar; +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Fix 4 — visibility toggle routes to POST /entities/:id/reveal +// ═══════════════════════════════════════════════════════════════════════════ + +function recordingApi() { + const calls = []; + const rec = (method) => async (path, body) => { calls.push({ method, path, body }); return {}; }; + return { calls, get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE') }; +} + +test('fix4: _onToggleVisibility POSTs /entities/:id/reveal with {is_private} (never a bare PUT)', async () => { + const api = recordingApi(); + // `api` is a getter over `_syncManager.api`, so inject through the manager. + const dash = Object.assign(Object.create(SyncDashboard.prototype), { + _syncManager: { api }, _cache: { entities: [] }, render: () => {}, + }); + await dash._onToggleVisibility('ent-42', false); + assert.equal(api.calls.length, 1); + assert.equal(api.calls[0].method, 'POST'); + assert.equal(api.calls[0].path, '/entities/ent-42/reveal'); + assert.deepEqual(api.calls[0].body, { is_private: true }); + assert.equal(api.calls.some((c) => c.method === 'PUT'), false, 'no bare entity PUT'); +}); + +test('fix4: _onBulkSetVisibility POSTs /reveal for each selected entity', async () => { + const api = recordingApi(); + const dash = Object.assign(Object.create(SyncDashboard.prototype), { + _syncManager: { api }, + _selectedEntities: new Set(['a', 'b']), + _cache: {}, + _logActivity: () => {}, + render: () => {}, + }); + await dash._onBulkSetVisibility(true); + assert.equal(api.calls.length, 2); + assert.ok(api.calls.every((c) => c.method === 'POST' && /\/reveal$/.test(c.path))); + assert.ok(api.calls.every((c) => c.body.is_private === true)); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Fix 5 — item relations: flat /relations/:id routes + snake_case create body +// ═══════════════════════════════════════════════════════════════════════════ + +function makeItemSync(api) { + const is = new ItemSync(); + is._api = api; + is._syncing = false; + return is; +} + +function makeItem({ flags = {}, name = 'Sword', system = {} } = {}, actorFlags = {}) { + const actor = new globalThis.Actor(); + actor.name = 'Hero'; + actor.getFlag = (_scope, key) => actorFlags[key]; + return { + name, + system, + parent: actor, + getFlag: (_scope, key) => flags[key], + setFlag: async (_scope, key, val) => { flags[key] = val; }, + }; +} + +test('fix5: create SKIPS (no POST) when the item has no linked Chronicle entity, logging once', async () => { + const api = recordingApi(); + const is = makeItemSync(api); + const dbg = console.debug; + let logs = 0; + console.debug = () => { logs += 1; }; + try { + // Two custom items, actor synced (source entity present), no item entityId flag. + await is._handleCreateItem(makeItem({}, { entityId: 'actor-1' }), {}, 'u1'); + await is._handleCreateItem(makeItem({}, { entityId: 'actor-1' }), {}, 'u1'); + } finally { + console.debug = dbg; + } + assert.equal(api.calls.length, 0, 'no relation POST for a target-less custom item'); + assert.equal(logs, 1, 'the skip notice is logged once per session, not per item'); +}); + +test('fix5: create sends snake_case body + object metadata when a target entity IS linked', async () => { + const api = recordingApi(); + const is = makeItemSync(api); + const item = makeItem({ flags: { entityId: 'item-entity-9' }, name: 'Rope', system: { quantity: 3, equipped: false } }, { entityId: 'actor-1' }); + await is._handleCreateItem(item, {}, 'u1'); + assert.equal(api.calls.length, 1); + const call = api.calls[0]; + assert.equal(call.method, 'POST'); + assert.equal(call.path, '/entities/actor-1/relations'); + // snake_case binding — the whole point of the fix. + assert.equal(call.body.target_entity_id, 'item-entity-9'); + assert.equal(call.body.relation_type, 'Has Item'); + assert.equal(call.body.reverse_relation_type, 'In Inventory Of'); + assert.equal('targetEntityId' in call.body, false, 'no camelCase key leaks through'); + // metadata is a raw OBJECT, not a JSON-encoded string. + assert.equal(typeof call.body.metadata, 'object'); + assert.equal(call.body.metadata.quantity, 3); +}); + +test('fix5: delete uses the FLAT route DELETE /relations/:relationId', async () => { + const api = recordingApi(); + const is = makeItemSync(api); + const item = makeItem({ flags: { relationId: 42 } }, { entityId: 'actor-1' }); + await is._handleDeleteItem(item, {}, 'u1'); + assert.equal(api.calls.length, 1); + assert.equal(api.calls[0].method, 'DELETE'); + assert.equal(api.calls[0].path, '/relations/42', 'flat route, not /entities/:id/relations/:id'); +}); + +test('fix5: update uses PUT /relations/:relationId with an object metadata body', async () => { + const api = recordingApi(); + const is = makeItemSync(api); + const item = makeItem({ flags: { relationId: 42 }, system: { quantity: 5, equipped: true } }, { entityId: 'actor-1' }); + await is._handleUpdateItem(item, { system: { quantity: 5 } }, {}, 'u1'); + assert.equal(api.calls.length, 1); + assert.equal(api.calls[0].method, 'PUT'); + assert.equal(api.calls[0].path, '/relations/42', 'flat route, not /entities/:id/relations/:id/metadata'); + assert.equal(typeof api.calls[0].body.metadata, 'object'); + assert.equal(api.calls[0].body.metadata.quantity, 5); + assert.equal(api.calls[0].body.metadata.equipped, true); +});