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
24 changes: 23 additions & 1 deletion .ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops:
- Foundry's change hooks fire but see `_syncing === true` → skip pushing back
- Reset to `false` after the change is applied

### Real-time calendar date-push guard (RC-4, FM-REALTIME-DATE-SIGNAL)

`GET /calendar/date` carries `tracks_real_time` — the composed
`UsesRealTime()` predicate (`syncapi/calendar_api_handler.go:95`); `GET
/calendar` never does. When true, Chronicle's calendar is wall-clock
authoritative, so the module treats dates as **read-only**: it pauses only
its own `PUT /calendar/date` pushes (pull and event sync are untouched).
`scripts/_realtime-date-guard.mjs` centralizes this so `calendar-sync.mjs`'s
three hook-triggered pushes (`_onCalendariaDateTimeChange`,
`_onLocalDateChange`, `_onSimpleCalendarDateChange`) and
`sync-dashboard.mjs`'s manual push button (`_onPushDate`) share one
implementation: a fetch-before-push `GET` re-run on every push attempt (push
is rare enough that the round trip is cheap, and it self-heals a mid-session
flag flip — never trust a cached value alone), plus treating a `PUT` 422 as
the identical condition (Chronicle's W3 write guard), never a retryable sync
error. The one-time-per-session GM notice is a module-level singleton shared
by both files, mirroring the structure guard's session-scoped state
(`calendar-sync.mjs`'s `_calendarSyncDisabled`, ~line 327) but without tying
the flag to either class instance. Tested at
`tools/test-realtime-date-signal.mjs`.

## Files

| File | Purpose |
Expand Down Expand Up @@ -70,6 +91,7 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops:
| `tools/test-sync-dashboard-calendar-diagnostics.mjs` | Static source pin + pure-function tests for the dashboard's Copy Calendar Diagnostics button. Asserts the action is registered in DEFAULT_OPTIONS, the import from sync-calendar-diagnostics.mjs is present, the template has the button and feedback element, and buildCalendarDiagnostics produces a valid report from dashboard-shaped input. Run: `node --test tools/test-sync-dashboard-calendar-diagnostics.mjs`. |
| `tools/test-actor-sync-pc-claim.mjs` | Structural + behavioral tests for PC-CLAIM-4 addon-awareness. Verifies SyncManager._fetchAddonState / isPlayerClaimingEnabled, ActorSync._resolvePcSubTypeId / _maybeShowClaimingHint, entity_type_id selection ternary, and the _claimHintShown once-per-session guard. Run: `node --test tools/test-actor-sync-pc-claim.mjs`. |
| `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/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 @@ -102,7 +124,7 @@ The table below summarizes which module uses which API area:
| Relations | `GET /entities/:id/relations`, `POST/PUT/DELETE .../relations/*` | `item-sync.mjs`, `shop-widget.mjs` |
| Maps | `GET /maps` | `map-sync.mjs` |
| Map Markers | `GET/POST/PUT/DELETE /maps/:id/markers/*` | `map-sync.mjs` |
| Calendar | `GET /calendar`, `GET/PUT /calendar/date`, `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs` |
| Calendar | `GET /calendar`, `GET/PUT /calendar/date` (`tracks_real_time` gates push, RC-4), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs` |
| Calendar Events | `GET/POST/PUT/DELETE /calendar/events/*` | `calendar-sync.mjs` |
| Calendar Sub-Resources | `GET/PUT /calendar/{seasons,moons,eras,event-categories,cycles,festivals,weather}` | Not yet used |
| Calendar Structure | `GET /calendar/structure`, `PUT /calendar/{settings,months,weekdays}`, `GET /calendar/export`, `POST /calendar/import` | Not yet used |
Expand Down
21 changes: 19 additions & 2 deletions API-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,8 @@ moons, seasons, eras, event_categories, cycles, festivals.
#### GET /calendar/date
Returns current date/time with computed state: current season, moon phases, era, weather.

**Used by:** `calendar-sync.mjs` → poll current state
**Used by:** `calendar-sync.mjs` → poll current state; `_realtime-date-guard.mjs` →
fetch-before-push real-time check (see below)

**Response:**
```json
Expand All @@ -641,6 +642,7 @@ Returns current date/time with computed state: current season, moon phases, era,
"day": 15,
"hour": 14,
"minute": 30,
"tracks_real_time": false,
"current_season": { "id": 1, "name": "Winter", "color": "#a0c4ff" },
"current_moon_phases": [
{ "moon_id": 1, "moon_name": "Selûne", "phase_name": "Full Moon", "phase_position": 0.5, "phase_icon": "moon" }
Expand All @@ -664,8 +666,23 @@ Returns current date/time with computed state: current season, moon phases, era,
**Key:** `current_season`, `current_moon_phases`, `current_era`, and `current_weather` are
computed server-side. They may be `null`/absent if no data is configured.

**`tracks_real_time`** (RC-4, FM-REALTIME-DATE-SIGNAL): the composed
`UsesRealTime()` predicate (`mode == reallife AND` the real-time flag), so the
wire signal can never disagree with the write-guard below. Read it
defensively — `payload?.tracks_real_time === true` — never assume the field
is present (older Chronicle deployments and `GET /calendar`, the structure
endpoint, never carry it). When `true`, the module treats dates as
**read-only**: it skips its own `PUT /calendar/date` pushes (fetch-before-push
check via `scripts/_realtime-date-guard.mjs`, re-probed on every push attempt
so a mid-session enable self-heals) and shows one GM notice per session. Pull
and event sync are unaffected.

#### PUT /calendar/date
Sets current calendar date/time to an absolute value.
Sets current calendar date/time to an absolute value. Rejected with **422**
(Chronicle's W3 guard) when the target calendar's `tracks_real_time` is true —
the module treats a 422 here identically to a pre-emptive `tracks_real_time`
read (sets the same session guard, shows the same one-time notice), never as
a retryable sync error.

**Request:**
```json
Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ Integration — Install & Updates".
caller that consumes an envelope endpoint as a bare array is a silent no-op
(the #77 back-catalog bug). All call sites were audited under
FM-ENVELOPE-AUDIT (see `tools/test-envelope-audit.mjs`).
- **Real-time calendars are read-only for dates.** `GET /calendar/date` carries
`tracks_real_time` (the composed `UsesRealTime()` predicate; RC-4,
FM-REALTIME-DATE-SIGNAL) — `GET /calendar` never does. When true, the module
pauses its own date-**push** only (pull/event sync unaffected). All four push
sites (`calendar-sync.mjs`'s three hook-triggered pushes,
`sync-dashboard.mjs`'s manual push button) route through the shared
`scripts/_realtime-date-guard.mjs`: a fetch-before-push `GET` re-probed on
every push (never trust a session-long cached value — pushes are rare, so
the extra round trip is cheap and self-heals a mid-session enable), plus a
422-from-`PUT`-is-the-same-condition backstop (never a retryable sync
error). The GM notice fires once per session, shared across both files via
a module-level singleton. See `tools/test-realtime-date-signal.mjs`.
- WebSocket messages are routed by type through `SyncManager`.
- Chronicle-side serving rules live in `chronicle-package.json` at repo root; CI validates it against `module.json` via `tools/check-package-descriptor.mjs`.

Expand Down
98 changes: 98 additions & 0 deletions scripts/_realtime-date-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Chronicle Sync — real-time calendar date-push guard (RC-4, FM-REALTIME-DATE-SIGNAL).
*
* When a Chronicle calendar tracks real-world time, `GET /calendar/date` carries
* `tracks_real_time: true` — the composed `UsesRealTime()` predicate (mode ==
* reallife AND flag; `syncapi/calendar_api_handler.go:95`) — and Chronicle's W3
* guard rejects date writes with a 422. This module centralizes the push-side
* reaction so calendar-sync.mjs's three hook-triggered pushes and
* sync-dashboard.mjs's manual push button share ONE guard implementation
* instead of forking the same logic twice (dispatch stop-and-flag).
*
* Pull/event sync is untouched — this only gates `PUT /calendar/date`.
* `GET /calendar` (the structure payload) does NOT carry this field; never
* read it from there.
*/

/**
* Session-scoped notice state. A module-level singleton (not a class field)
* so calendar-sync.mjs and sync-dashboard.mjs — two independent classes —
* share one "shown already" flag without either owning the other. Same
* lifetime as the structure guard's per-instance session state
* (calendar-sync.mjs's `_calendarSyncDisabled`, set up around line 327).
*/
const state = {
noticeShown: false,
};

/**
* Reset session state. Test-only — production code never needs to un-show
* the notice mid-session.
*/
export function _resetRealtimeDateGuardForTests() {
state.noticeShown = false;
}

/**
* Read the `tracks_real_time` field defensively (FM-ENVELOPE-AUDIT
* convention — never assume a payload shape). `/calendar/date` is a bare
* single-object endpoint, but a network hiccup or an old Chronicle deploy
* can still hand back `null`/`undefined`/something unexpected.
* @param {{tracks_real_time?: boolean}|null|undefined} payload
* @returns {boolean}
*/
export function tracksRealTime(payload) {
return payload?.tracks_real_time === true;
}

/**
* Classify a thrown api-client error as Chronicle's W3 real-time rejection
* (422). Mirrors `_calendar-probe-state.mjs`'s `calendarStateFromError`:
* prefer an explicit numeric `err.status`, else parse the api-client's
* authoritative "Chronicle API error <status>:" prefix — never key on a bare
* digit run in the message body.
* @param {{status?: number, message?: string}|null|undefined} err
* @returns {boolean}
*/
export function isRealTimeRejection(err) {
const msg = String(err?.message || '');
const status = (typeof err?.status === 'number' && err.status)
|| Number(msg.match(/Chronicle API error (\d{3})\b/)?.[1])
|| 0;
return status === 422;
}

/**
* Show the one-time-per-session GM notice that date pushes are paused.
* Idempotent — safe to call from every gated push site (including the 422
* backstop path); only the first call in a session actually notifies.
*/
export function notifyRealTimePushPaused() {
if (state.noticeShown) return;
state.noticeShown = true;
const msg = 'Chronicle Sync: this calendar tracks real-world time — '
+ 'Foundry date changes are not pushed to Chronicle. Pulls still work.';
console.warn(msg);
try { globalThis.ui?.notifications?.warn(msg); } catch { /* headless */ }
}

/**
* Fetch-before-push guard for `PUT /calendar/date`. Date pushes are rare
* (GM advances), so the extra `GET /calendar/date` is cheap and self-heals a
* mid-session flag flip to enabled — never rely on a session-long cached
* value alone. A probe failure is not this guard's concern; let the push
* attempt proceed and fail (or succeed) on its own terms.
* @param {import('./api-client.mjs').ChronicleAPI} api
* @returns {Promise<boolean>} true if the caller should SKIP the push.
*/
export async function shouldSkipDatePush(api) {
let payload;
try {
payload = await api.get('/calendar/date');
} catch {
return false;
}
if (!tracksRealTime(payload)) return false;
notifyRealTimePushPaused();
return true;
}
7 changes: 7 additions & 0 deletions scripts/calendar-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import { getSetting, getCalendarSyncExclusions } from './settings.mjs';
import { FLAG_SCOPE } from './constants.mjs';
import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs';

/**
* Canonical wire-visibility values per the calendar-sync wire contract
Expand Down Expand Up @@ -728,6 +729,7 @@ export class CalendarSync {
if (!date) return;

try {
if (await shouldSkipDatePush(this._api)) return;
await this._api.put('/calendar/date', {
year: date.year,
month: date.month,
Expand All @@ -736,6 +738,7 @@ export class CalendarSync {
minute: src.minute ?? 0,
});
} catch (err) {
if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; }
console.error('Chronicle: Failed to push Calendaria date/time to Chronicle', err);
}
}
Expand Down Expand Up @@ -879,6 +882,7 @@ export class CalendarSync {
if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs

try {
if (await shouldSkipDatePush(this._api)) return;
await this._api.put('/calendar/date', {
year: dateData.year,
month: dateData.month,
Expand All @@ -887,6 +891,7 @@ export class CalendarSync {
minute: dateData.minute || 0,
});
} catch (err) {
if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; }
console.error('Chronicle: Failed to push date to Chronicle', err);
}
}
Expand All @@ -908,6 +913,7 @@ export class CalendarSync {
if (!date) return;

try {
if (await shouldSkipDatePush(this._api)) return;
await this._api.put('/calendar/date', {
year: date.year,
// SimpleCalendar months are 0-indexed; Chronicle is 1-indexed.
Expand All @@ -917,6 +923,7 @@ export class CalendarSync {
minute: date.minute || 0,
});
} catch (err) {
if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; }
console.error('Chronicle: Failed to push SimpleCalendar date to Chronicle', err);
}
}
Expand Down
3 changes: 3 additions & 0 deletions scripts/sync-dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
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';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;

/**
Expand Down Expand Up @@ -2054,6 +2055,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
if (!localDate) return;

try {
if (await shouldSkipDatePush(this.api)) return;
await this.api.put('/calendar/date', {
year: localDate.year,
month: localDate.month,
Expand All @@ -2064,6 +2066,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
this._logActivity('push', 'Pushed calendar date to Chronicle');
this.render({ force: true });
} catch (err) {
if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; }
console.error('Chronicle Dashboard: Push date failed', err);
}
}
Expand Down
Loading