Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions .ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 45 additions & 7 deletions API-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <bool> }` (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`.

---

Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
118 changes: 118 additions & 0 deletions scripts/_calendar-sync-state.mjs
Original file line number Diff line number Diff line change
@@ -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 a<b, 0 if equal, >0 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: '',
};
}
Loading