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
27 changes: 26 additions & 1 deletion .ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ by both files, mirroring the structure guard's session-scoped state
the flag to either class instance. Tested at
`tools/test-realtime-date-signal.mjs`.

### Applied-date confirmation (FM-SYNC-CONFIRMED-DATE)

Pairs with a parallel Chronicle-side change (C-SYNC-APPLIED-BEACON) that lets
Chronicle's sync chip distinguish what Foundry *saw* (the `GET
/calendar/date` beacon) from what it actually *applied* to the local
calendar module. `calendar-sync.mjs`'s `_setLocalDate` — the single choke
point both apply paths funnel through (the poll path, `onInitialSync`'s
`GET /calendar` fetch on connect/reconnect, and the WebSocket-driven path,
`_onChronicaleDateAdvanced` on `calendar.date.advanced`) — now returns a
`Promise<boolean>` reporting whether a real local calendar-module setter ran
without throwing (Calendaria `setDateTime`/legacy `setDate`, or SimpleCalendar
`setDate`, now awaited). Neither calendar module's setter returns an
inspectable success signal, so "no throw" is the strongest available proxy —
flagged as a stop-and-flag rather than invented. Both call sites POST
`scripts/_applied-date-confirm.mjs`'s `confirmAppliedDate(api, {year, month,
day})` → `POST /calendar/date/confirm` ONLY when `_setLocalDate` resolves
`true` — never on a bare fetch, never on apply failure. The confirm call runs
after `_setLocalDate`'s `_syncDepth` guard has already unwound (it only sends
an outbound POST to Chronicle; it triggers no Foundry hooks, so it cannot
re-enter the push handlers' `_syncing` checks). A 404/405 (older Chronicle
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`.

## Files

| File | Purpose |
Expand Down Expand Up @@ -92,6 +116,7 @@ the flag to either class instance. Tested at
| `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/_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/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 @@ -124,7 +149,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` (`tracks_real_time` gates push, RC-4), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs` |
| Calendar | `GET /calendar`, `GET/PUT /calendar/date` (`tracks_real_time` gates push, RC-4), `POST /calendar/date/confirm` (optional, applied-beacon, FM-SYNC-CONFIRMED-DATE), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs`, `_applied-date-confirm.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
32 changes: 32 additions & 0 deletions API-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,38 @@ a retryable sync error.
{ "year": 1492, "month": 3, "day": 1, "hour": 8, "minute": 0 }
```

#### POST /calendar/date/confirm
**Optional — Chronicle ≥ the applied-beacon release (C-SYNC-APPLIED-BEACON).**
Confirms that Foundry successfully *applied* a date pulled from Chronicle to
the active local calendar module (Calendaria or SimpleCalendar), as opposed
to merely having fetched/seen it. Chronicle's sync chip uses this to
distinguish "Foundry SAW this date" (the `GET /calendar/date` beacon) from
"Foundry APPLIED this date."

**Used by:** `calendar-sync.mjs` → `_confirmAppliedDate` via
`scripts/_applied-date-confirm.mjs`, called from both the poll apply path
(`onInitialSync`) and the WebSocket-driven apply path
(`_onChronicaleDateAdvanced`, `calendar.date.advanced`) — **only** after the
local calendar-module setter (`_setLocalDate`) reports it actually ran
without throwing. Never sent on a bare fetch/pull, and never on an apply
failure. An already-current date that still round-trips through a real
setter call counts as applied and is confirmed.

**Request:**
```json
{ "year": 1492, "month": 3, "day": 1 }
```

**Response:** `204 No Content`

**Graceful degradation:** a Chronicle deployment that predates this endpoint
returns 404 (route doesn't exist) or 405 (method not recognized). The module
tolerates both silently — one `console.debug` per session, no retries — so
it keeps working unchanged against pre-upgrade Chronicle servers. Any other
failure (network, 5xx) is debug-logged and swallowed; a missed confirmation
only leaves Chronicle's applied-beacon stale, it never blocks or retries
sync. See `scripts/_applied-date-confirm.mjs::isConfirmNotSupported`.

#### POST /calendar/advance
Advances the calendar by N days (1-3650).

Expand Down
97 changes: 97 additions & 0 deletions scripts/_applied-date-confirm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Chronicle Sync — applied-date confirmation (FM-SYNC-CONFIRMED-DATE).
*
* Pairs with a parallel Chronicle-side change (C-SYNC-APPLIED-BEACON).
* Chronicle's sync chip distinguishes what Foundry SAW (the #548 beacon,
* recorded server-side on every `GET /calendar/date`) from what it actually
* APPLIED to the active Foundry calendar module. This module owns the
* Foundry-side half: `POST /calendar/date/confirm` with the applied
* `{year, month, day}`.
*
* The endpoint is OPTIONAL — only present on Chronicle deployments that have
* shipped the applied-beacon release. A 404 (route doesn't exist) or 405
* (method not allowed — an older router that doesn't recognize the path)
* means an un-upgraded Chronicle; that is tolerated silently (one debug log
* per session, no retries) so this module keeps working unchanged against
* pre-upgrade servers. Any other failure is logged and swallowed by the
* caller — a missed confirmation only leaves Chronicle's "applied" beacon
* stale, it never blocks or retries sync.
*/

/**
* Session-scoped "not supported" log state. A module-level singleton
* (mirrors `_realtime-date-guard.mjs`'s `state.noticeShown`) so every call
* site sharing this helper logs the tolerance exactly once per session
* instead of once per apply.
*/
const state = {
notSupportedLogged: false,
};

/**
* Reset session state. Test-only.
*/
export function _resetAppliedDateConfirmForTests() {
state.notSupportedLogged = false;
}

/**
* Classify a thrown api-client error as "the confirm endpoint isn't present
* on this Chronicle deployment" (404 route-not-found, or 405 method-not-
* allowed). Mirrors `_realtime-date-guard.mjs`'s `isRealTimeRejection`:
* 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 isConfirmNotSupported(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 === 404 || status === 405;
}

/**
* Log the "confirm endpoint not supported" tolerance once per session.
* Deliberately `console.debug` only (never `ui.notifications`) — an
* un-upgraded Chronicle is an expected, non-actionable condition for the
* GM, not an error.
*/
export function notifyConfirmNotSupportedOnce() {
if (state.notSupportedLogged) return;
state.notSupportedLogged = true;
console.debug(
'Chronicle: /calendar/date/confirm is not available on this Chronicle deployment '
+ '(older server, applied-beacon release not yet installed) — skipping applied-date confirmation.',
);
}

/**
* POST the applied date to Chronicle. Callers must invoke this ONLY after a
* real, successful local apply — never speculatively, never on a bare
* fetch/pull. Best-effort and non-throwing: a 404/405 is tolerated via
* `notifyConfirmNotSupportedOnce`; any other failure is debug-logged and
* swallowed so a confirmation hiccup never surfaces as a sync error or
* blocks the (already-completed) apply.
* @param {import('./api-client.mjs').ChronicleAPI} api
* @param {{year:number, month:number, day:number}} date
* @returns {Promise<void>}
*/
export async function confirmAppliedDate(api, date) {
if (!api || !date) return;
try {
await api.post('/calendar/date/confirm', {
year: date.year,
month: date.month,
day: date.day,
});
} catch (err) {
if (isConfirmNotSupported(err)) {
notifyConfirmNotSupportedOnce();
return;
}
console.debug('Chronicle: Failed to confirm applied date', err?.message);
}
}
47 changes: 43 additions & 4 deletions scripts/calendar-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { getSetting, getCalendarSyncExclusions } from './settings.mjs';
import { FLAG_SCOPE } from './constants.mjs';
import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs';
import { confirmAppliedDate } from './_applied-date-confirm.mjs';

/**
* Canonical wire-visibility values per the calendar-sync wire contract
Expand Down Expand Up @@ -460,13 +461,24 @@ export class CalendarSync {
}

// Sync the current date from Chronicle to the Foundry calendar module.
await this._setLocalDate({
// This is the "poll" apply path (a fetch of GET /calendar on connect/
// reconnect, as opposed to the WebSocket-driven _onChronicaleDateAdvanced
// below) — confirm back to Chronicle only when _setLocalDate reports a
// real apply (FM-SYNC-CONFIRMED-DATE).
const applied = await this._setLocalDate({
year: this._chronicleCalendar.current_year,
month: this._chronicleCalendar.current_month,
day: this._chronicleCalendar.current_day,
hour: this._chronicleCalendar.current_hour,
minute: this._chronicleCalendar.current_minute,
});
if (applied) {
await confirmAppliedDate(this._api, {
year: this._chronicleCalendar.current_year,
month: this._chronicleCalendar.current_month,
day: this._chronicleCalendar.current_day,
});
}

// Sync Chronicle calendar events to Calendaria notes (if using Calendaria).
if (this._calendarModule === 'calendaria') {
Expand Down Expand Up @@ -616,13 +628,19 @@ export class CalendarSync {
// --- Chronicle → Foundry ---

/**
* Update the local Foundry calendar date from Chronicle.
* Update the local Foundry calendar date from Chronicle. This is the
* WebSocket-driven apply path (`calendar.date.advanced`) — confirm back to
* Chronicle only when `_setLocalDate` reports a real apply
* (FM-SYNC-CONFIRMED-DATE).
* @param {object} data - { year, month, day, hour, minute }
* @private
*/
async _onChronicaleDateAdvanced(data) {
if (!data) return;
await this._setLocalDate(data);
const applied = await this._setLocalDate(data);
if (applied) {
await confirmAppliedDate(this._api, { year: data.year, month: data.month, day: data.day });
}
}

/**
Expand Down Expand Up @@ -1144,11 +1162,24 @@ export class CalendarSync {
/**
* Set the date on the active Foundry calendar module.
* Uses CALENDARIA.api.setDateTime() when available for full hour/minute support.
*
* "Successfully applied" (FM-SYNC-CONFIRMED-DATE): the code offers no ack
* from either calendar module's date setter beyond "the call didn't
* throw" — neither Calendaria's `setDateTime`/`setDate` nor
* SimpleCalendar's `setDate` returns a result we can inspect for success.
* So the return value here is a best-effort signal: `true` means a real
* setter for the detected module was found and invoked without throwing;
* `false` means either no setter was available (module detected but its
* API surface wasn't there — a no-op, nothing was applied) or the setter
* threw (already logged below). Flagged per the dispatch's stop-and-flag:
* there is no stronger "it actually took effect" hook to key off.
* @param {object} data - { year, month, day, hour, minute }
* @returns {Promise<boolean>} true if a local setter was invoked and did not throw.
* @private
*/
async _setLocalDate(data) {
this._syncDepth++;
let applied = false;
try {
if (this._calendarModule === 'calendaria') {
if (this._hasModernCalendariaApi) {
Expand All @@ -1160,18 +1191,23 @@ export class CalendarSync {
hour: data.hour ?? 0,
minute: data.minute ?? 0,
});
applied = true;
} else if (game.Calendaria?.setDate) {
// Legacy Calendaria: setDate only supports date (no time).
await game.Calendaria.setDate({
year: data.year,
month: data.month,
day: data.day,
});
applied = true;
}
} else if (this._calendarModule === 'simple-calendar') {
const sc = SimpleCalendar?.api;
if (sc?.setDate) {
sc.setDate({
// Awaited (unlike the pre-FM-SYNC-CONFIRMED-DATE code) so a
// promise-returning setDate is actually settled before this method
// reports success to its callers.
await sc.setDate({
year: data.year,
// SimpleCalendar months are 0-indexed.
month: (data.month || 1) - 1,
Expand All @@ -1180,13 +1216,16 @@ export class CalendarSync {
minute: data.minute || 0,
seconds: 0,
});
applied = true;
}
}
} catch (err) {
console.error('Chronicle: Failed to set local calendar date', err);
applied = false;
} finally {
this._syncDepth--;
}
return applied;
}

/**
Expand Down
Loading