From 33f3992a8de4baffa535def2ac385d2826410a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 23:26:14 +0000 Subject: [PATCH] feat(calendar): land weather/season/era/moon/structure sub-resources in Foundry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chronicle broadcasts eleven calendar.* WebSocket types. The module's handler switch matched four (date + event CRUD) and had no `default:`, so the other seven fell off the end of the switch and vanished with no console breadcrumb — the reason the operator's "celestial events unfindable/unsyncable" report had nothing to trace. FM-SYNC-SUBRESOURCES-P1. P1 is display-level and non-destructive by design: - weather.changed → dashboard world-state panel; applied to the calendar module when it exposes a weather setter, else a GM-only chat whisper. A null payload (the weather-zone "refetch me" ping) triggers one GET /calendar/weather. - season/era/moon → panel + optional GM whisper, per-type world setting (season/era on, moon off — moons change phase every few in-world days). - worldstate → panel + GM whisper. Wired and tested but DORMANT: see the Chronicle-side gap noted below. - structure.updated (+ cycle/festival) → refetch /calendar, re-run the structure comparison, badge the result. Never auto-applies the structure: rewriting months/weekdays would silently re-date every Calendaria note. Routed AHEAD of the _calendarSyncDisabled guard because fixing the calendar in Chronicle is both the remedy for a mismatch pause and the thing that emits this broadcast — behind the guard the pause would outlive its own cause. It is also the only path that can clear that pause. - everything else → `default:` logs one console.debug line per type per session. No more silent drops. Announcements are GM whispers, never public chat. Chronicle gates dm_only traffic server-side (Message.RequiresDM), but "cleared for the GM" is not "cleared for the table" — re-broadcasting would launder a server-side permission decision into a player-visible one. Adds a fifth honest badge state, `structure-changed`, ranked below paused and incompatible-structures (which describe real breakage) and above date-drift. Chronicle-side gap found in Step 0 and NOT fixed here (module repo only): calendar.worldstate.changed never reaches the wire. Its publisher adapter in internal/app/routes.go has no case for it, so it hits `default: return` before the bus (calendar.weather.zones.changed is dropped identically); the payload carries no celestial detail ({date, moodTint}); and GET /calendar/world-state is not on the syncapi group. Full trace in API-CONTRACT.md. New pins: tools/test-calendar-subresources.mjs (25 pure-helper tests), tools/test-calendar-subresource-routing.mjs (22 routing/dm_only/re-compare tests), plus 4 classifier tests. Suite: 755 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B8JUBq46gCQ7sem7YcHeaj --- .ai.md | 67 ++- API-CONTRACT.md | 55 ++- CLAUDE.md | 32 ++ lang/en.json | 21 +- scripts/_calendar-subresources.mjs | 383 ++++++++++++++++ scripts/_calendar-sync-state.mjs | 38 +- scripts/calendar-sync.mjs | 399 ++++++++++++++++ scripts/settings.mjs | 47 ++ scripts/sync-dashboard.mjs | 28 +- styles/chronicle-sync.css | 40 ++ templates/sync-dashboard.hbs | 49 ++ tools/test-calendar-subresource-routing.mjs | 483 ++++++++++++++++++++ tools/test-calendar-subresources.mjs | 289 ++++++++++++ tools/test-calendar-sync-state.mjs | 64 ++- 14 files changed, 1977 insertions(+), 18 deletions(-) create mode 100644 scripts/_calendar-subresources.mjs create mode 100644 tools/test-calendar-subresource-routing.mjs create mode 100644 tools/test-calendar-subresources.mjs diff --git a/.ai.md b/.ai.md index 1eb2d0a..67cc112 100644 --- a/.ai.md +++ b/.ai.md @@ -238,15 +238,72 @@ See API-CONTRACT.md for the full WebSocket protocol. Summary of handling status: on first `connected`; emitted UNWRAPPED as `{status}`, read as `msg.status ?? msg.payload?.status` — FM-SYNC-WIRE-FIX fix 1) +- `calendar.weather.changed` → CalendarSync (FM-SYNC-SUBRESOURCES-P1) — dashboard + world-state panel + Calendaria setter probe, GM-whisper fallback +- `calendar.season.changed` / `.era.changed` / `.moon.phase_changed` → CalendarSync + — panel + optional GM-whisper chat line, per-type world setting +- `calendar.worldstate.changed` → CalendarSync — wired + tested, but **dormant**: + Chronicle drops it before the bus (see below) +- `calendar.structure.updated` / `.cycle.changed` / `.festival.changed` → + CalendarSync `_onChronicleStructureUpdated` — re-compare + badge, **never + auto-applies the structure** + **Not yet handled (available per contract):** -- `calendar.season.changed` — Season boundary crossed -- `calendar.moon.phase_changed` — Moon phase changed -- `calendar.weather.changed` — Weather set or generated -- `calendar.structure.updated` — Calendar structure modified (months, weekdays, etc.) -- `calendar.era.changed` — Era boundary crossed - `sync.error` — Synchronization error - `sync.conflict` — Data conflict detected +Every other `calendar.*` type now hits a `default:` branch that logs one +`console.debug` line per type per session. Before FM-SYNC-SUBRESOURCES-P1 the +switch had no `default:` and seven of Chronicle's eleven calendar broadcasts +vanished with no console breadcrumb — the reason the operator's "celestial +events unfindable/unsyncable" report had nothing to trace. + +### Calendar sub-resources (FM-SYNC-SUBRESOURCES-P1, 2026-07-25) + +**Decision: P1 is display-level and non-destructive.** No sub-resource handler +writes into a Foundry document. Three specific abstentions, each deliberate: + +1. **No structure auto-apply on `calendar.structure.updated`.** Rewriting the + Foundry calendar's months/weekdays from a broadcast is the most destructive + write available to this module: Calendaria stores notes against month/day + coordinates, so re-shaping the calendar silently re-dates every note in the + world. The handler re-compares and badges instead. Non-destructive + auto-merge is a later arc (calendar-remodel requirements §1 item 10). +2. **No auto-created celestial note on `calendar.worldstate.changed`.** The + payload carries no celestial detail and no stable event id, so a + re-broadcast or reconnect would create duplicate notes with nothing to + dedupe on. Announcing what the payload contains is honest; inventing a + "Meteor Shower" note from a mood tint is not. +3. **No speculative Calendaria weather setter.** Calendaria publishes weather + READS; no shipped build exposes a documented setter. The module probes + `setWeather` / `setCurrentWeather` / `setWeatherForDate` and degrades to a + GM chat line, with the probe result mirrored into the diagnostics bundle so + a build that does expose one shows up in an operator's bug report. + +**Decision: announcements are GM whispers, never public chat.** Chronicle gates +dm_only traffic server-side (`Message.RequiresDM`, `internal/websocket/hub.go`), +but "cleared for the GM" is not "cleared for the table" — weather zones and +world state routinely encode what the DM is holding back. Re-broadcasting would +launder a server-side permission decision into a player-visible one, the leak +class cordinator#32 hardened against. `CalendarSync._announceToGM` whispers to +the GM user ids and is the module's only chat surface. + +**Decision: structure signals are processed while paused.** `onMessage` routes +them ahead of the `_calendarSyncDisabled` guard, because fixing the calendar in +Chronicle — the documented remedy for a mismatch pause — is precisely what emits +the broadcast. Behind the guard, a pause would survive its own cause until a +world reload, the same dead-on-arrival shape FM-SYNC-WIRE-FIX removed from +initial sync. This is also the only path that can CLEAR a mismatch pause. + +**Chronicle-side gap: `calendar.worldstate.changed` never reaches the wire.** +Three blockers, all Chronicle-side, verified against `main` 2026-07-25 — see +API-CONTRACT.md → "Gap: `calendar.worldstate.changed` never reaches the wire" +for the full trace. In short: the publisher adapter in `internal/app/routes.go` +has no `case` for it (so it hits `default: return` before the bus), the payload +carries no celestial detail, and `GET /calendar/world-state` is not on the +syncapi group. The module handler ships wired and tested; it lights up when +Chronicle closes blocker 1. + ## Security posture This section consolidates the security invariants this module relies on after FM-SEC Phase 4 (audit: `cordinator/reports/foundry/2026-05-22-fm-security-audit.md`; chunks #52, #53, #54, #55, #56, #57, #58). diff --git a/API-CONTRACT.md b/API-CONTRACT.md index 89d1238..8283fbf 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -1221,15 +1221,66 @@ If the token is invalid, the server rejects the upgrade. | `calendar.event.updated` | Full event object | Calendar event modified | | `calendar.event.deleted` | `{ id }` | Calendar event deleted | | `calendar.date.advanced` | `{ year, month, day, hour, minute }` | Date/time changed | -| `calendar.season.changed` | `{ id, name, color }` | Season boundary crossed | +| `calendar.season.changed` | `{ id, name, color }` — **or `null`** when the date left a season without entering another | Season boundary crossed | | `calendar.moon.phase_changed` | `{ moon_id, moon_name, phase_name, phase_position }` | Moon phase changed | -| `calendar.weather.changed` | Weather input object | Weather set or generated | +| `calendar.weather.changed` | merged `WeatherInput` (FLAT snake_case) — **or `null`** from the weather-zone paths, where it is a "refetch me" ping | Weather set or generated | | `calendar.structure.updated` | `null` | Calendar structure modified | +| `calendar.cycle.changed` | `null` | Cycle edited (always fires alongside `structure.updated`) | +| `calendar.festival.changed` | `null` | Festival edited (always fires alongside `structure.updated`) | | `calendar.era.changed` | `{ id, name, color }` | Era boundary crossed | +| `calendar.worldstate.changed` | `{ date: {year, month, day}, moodTint: {color, intensity} }` | World state changed — **see the gap note below; does not currently reach the wire** | | `sync.status` | `{ connected: bool }` | Connection state change | | `sync.error` | `{ message }` | Synchronization error | | `sync.conflict` | Conflict details | Data conflict detected | +### What the module does with each `calendar.*` type + +Wired by FM-SYNC-SUBRESOURCES-P1 (`scripts/calendar-sync.mjs` `onMessage` + +`scripts/_calendar-subresources.mjs`). Before that dispatch only the first four +rows were handled and the switch had no `default:`, so every other type was +dropped with no trace. + +P1 is **display-level and non-destructive**: no branch below writes a Chronicle +value into the Foundry calendar's stored structure, and none creates a note. +Chat announcements are **GM whispers only** — never public chat, so a payload +Chronicle gated to the DM is not laundered into a player-visible one. + +| Type | Module behavior | Calendaria | Simple Calendar | +|------|-----------------|-----------|-----------------| +| `calendar.date.advanced` | Applies the date, confirms it back | `CALENDARIA.api.setDateTime` | `SimpleCalendar.api` date set | +| `calendar.event.created/updated/deleted` | Mirrors to a calendar note | Full (notes API) | Full (journal-flag notes) | +| `calendar.weather.changed` | Updates the dashboard world-state panel; applies to the calendar module if it exposes a weather **setter**, else whispers a GM chat line. A `null` payload triggers one `GET /calendar/weather` refetch. | Reads only on shipped builds — the module probes `setWeather` / `setCurrentWeather` / `setWeatherForDate` and falls back to chat when absent (the probe result is reported in the diagnostics bundle) | No weather surface → chat fallback | +| `calendar.season.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | Display only | Display only | +| `calendar.era.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | Display only | Display only | +| `calendar.moon.phase_changed` | Panel + GM chat line (`calendarAnnounceMoon`, default **off** — moons change phase every few in-world days) | Display only | Display only | +| `calendar.worldstate.changed` | Panel + GM chat line (`calendarAnnounceWorldstate`, default **on**). Handler is wired and tested but **currently unreachable** — see the gap note. | Display only | Display only | +| `calendar.structure.updated`, `calendar.cycle.changed`, `calendar.festival.changed` | Refetches `GET /calendar`, re-runs the structure comparison, and sets the badge: pause if now incompatible, clear a prior mismatch pause if now compatible, otherwise raise the advisory `structure-changed` state. **Never auto-applies the structure** — rewriting months/weekdays would silently re-date every existing note. Processed even while sync is paused (the only recovery path). | Both | Both | +| any other `calendar.*` | `default:` branch logs one `console.debug` line **per type per session** — no more silent drops | — | — | + +#### Gap: `calendar.worldstate.changed` never reaches the wire + +Verified against Chronicle `main` on 2026-07-25 (FM-SYNC-SUBRESOURCES-P1 +Step 0). Three independent blockers, all Chronicle-side: + +1. **Not published.** `worldstate_service.go:265` calls + `PublishCalendarEvent("calendar.worldstate.changed", …)`, but the adapter + that translates internal event names to `ws.MessageType` + (`calendarEventPublisherAdapter.PublishCalendarEvent`, + `internal/app/routes.go`) has no `case` for it and hits `default: return`. + The event is dropped before the bus. `calendar.weather.zones.changed` is + dropped the same way. +2. **No celestial detail in the payload.** Even once published, the payload is + `{date, moodTint}` — the meteor/eclipse rows in `calendar_celestial_events` + that motivated the dispatch are not in it. +3. **No syncapi read path.** `GET /calendar/world-state` is registered on the + web plugin group, not the Bearer-token `syncapi` group, so the module cannot + fetch the detail either (`internal/plugins/calendar/routes.go:157` vs + `internal/plugins/syncapi/routes.go`). + +The module-side handler ships wired and tested so the feature lights up the +moment Chronicle closes (1); until then it is dormant. Closing (2) and (3) is +Chronicle-side work. + ### Reconnection The API client automatically reconnects on WebSocket disconnection: diff --git a/CLAUDE.md b/CLAUDE.md index 26335dc..ce5dcac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ scripts/ # ES modules (.mjs) map-sync.mjs # Chronicle map + sub-resources ↔ JournalEntry (image page); markers/drawings/tokens/fog/layers rendered as overlays via MapViewerSheet map-viewer.mjs # MapViewerSheet (ApplicationV2): image + SVG overlay calendar-sync.mjs # Calendar adapter (Calendaria/SimpleCalendar) + _calendar-subresources.mjs # Pure: weather/season/era/moon payload normalization, GM chat lines, dashboard snapshot reducer actor-sync.mjs # Character entity ↔ Actor sync item-sync.mjs # Item sync note-sync.mjs # Chronicle Notes ↔ JournalEntry sync @@ -89,6 +90,18 @@ Integration — Install & Updates". 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`. +- **Calendar sub-resources are display-only.** `calendar.weather/season/era/ + moon/worldstate` land on the dashboard's world-state panel and (per-type world + setting) a **GM-whispered** chat line — never public chat, since Chronicle's + dm_only gating is server-side and re-broadcasting would launder it into a + player-visible decision. `calendar.structure.updated` (+ its `cycle`/`festival` + siblings) re-runs the structure comparison and badges the result but **never + auto-applies the structure** — that would silently re-date every Calendaria + note. It is routed AHEAD of the `_calendarSyncDisabled` guard because it is + the only signal that can clear a mismatch pause. Every other `calendar.*` type + hits a `default:` that logs once per type per session. See + `scripts/_calendar-subresources.mjs`, `tools/test-calendar-subresources.mjs`, + `tools/test-calendar-subresource-routing.mjs` (FM-SYNC-SUBRESOURCES-P1). - 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`. @@ -103,6 +116,25 @@ Integration — Install & Updates". 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). +- **Blocked on Chronicle (FM-SYNC-SUBRESOURCES-P1 Step 0):** + `calendar.worldstate.changed` is published by + `internal/plugins/calendar/worldstate_service.go` but has no `case` in + `calendarEventPublisherAdapter.PublishCalendarEvent` + (`internal/app/routes.go`), so it hits `default: return` and never reaches + the bus (`calendar.weather.zones.changed` is dropped the same way). The + payload also carries no celestial detail (`{date, moodTint}`) and + `GET /calendar/world-state` isn't on the syncapi group. The module handler + is wired + tested and dormant until Chronicle closes these. See + API-CONTRACT.md → "Gap: `calendar.worldstate.changed` never reaches the wire". +- Recommended once on a live client after FM-SYNC-SUBRESOURCES-P1 (can't be + unit-tested): set weather / cross a season or era boundary in Chronicle and + confirm the GM whisper lands and the Calendar tab's "Chronicle world state" + panel fills; edit the calendar structure in Chronicle and confirm the badge + flips to "Structure Changed — Re-check" without the Foundry calendar being + modified; confirm a structure-mismatch pause CLEARS when the Chronicle + calendar is fixed (no world reload needed); check the diagnostics bundle's + `CALENDARIA.api methods available` block for whether the build exposes any of + `setWeather` / `setCurrentWeather` / `setWeatherForDate`. - 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"); diff --git a/lang/en.json b/lang/en.json index 818958b..8fb90f5 100644 --- a/lang/en.json +++ b/lang/en.json @@ -30,6 +30,22 @@ "Name": "Sync Calendar", "Hint": "Sync Chronicle calendar with Calendaria or Simple Calendar module" }, + "CalendarAnnounceWeather": { + "Name": "Announce Chronicle weather in chat", + "Hint": "Whisper a GM-only chat line when weather changes in Chronicle. Used when the active Foundry calendar module has no weather setter to apply the reading to (Simple Calendar, and Calendaria builds with read-only weather). Never posted to players." + }, + "CalendarAnnounceWorldstate": { + "Name": "Announce Chronicle world-state changes in chat", + "Hint": "Whisper a GM-only chat line when Chronicle's world state changes (date + ambient mood tint). Never posted to players." + }, + "CalendarAnnounceSeasonEra": { + "Name": "Announce season and era changes in chat", + "Hint": "Whisper a GM-only chat line when the Chronicle calendar crosses a season or era boundary. Never posted to players." + }, + "CalendarAnnounceMoon": { + "Name": "Announce moon phase changes in chat", + "Hint": "Whisper a GM-only chat line each time a moon crosses a phase boundary. Off by default — moons change phase every few in-world days and can flood the chat log. The Calendar tab's world-state panel shows current phases regardless." + }, "SyncCharacters": { "Name": "Sync Characters", "Hint": "Sync Foundry actors with Chronicle character entities (requires matching game system)" @@ -170,7 +186,10 @@ "NoModuleHint": "Install Calendaria or Simple Calendar to enable calendar sync.", "UnableToRead": "Unable to read", "OpenSyncCalendar": "Open Sync Calendar", - "OpenSyncCalendarHint": "Open the full Sync Calendar editor — import calendars, edit events, view moon phases." + "OpenSyncCalendarHint": "Open the full Sync Calendar editor — import calendars, edit events, view moon phases.", + "WorldStateTitle": "Chronicle world state", + "WorldStateHint": "Latest weather, season, era and moon phases received from Chronicle this session. Display only — the Foundry calendar is never modified from these.", + "WorldStateEmpty": "No world-state broadcasts received yet this session." }, "Notes": { "Synced": "Synced", diff --git a/scripts/_calendar-subresources.mjs b/scripts/_calendar-subresources.mjs new file mode 100644 index 0000000..7099c87 --- /dev/null +++ b/scripts/_calendar-subresources.mjs @@ -0,0 +1,383 @@ +/** + * Chronicle Sync — calendar sub-resource projection (FM-SYNC-SUBRESOURCES-P1). + * + * Chronicle broadcasts eleven `calendar.*` WebSocket types; before this file + * the module's handler switch (`calendar-sync.mjs` `onMessage`) matched four + * of them — date + the three event-CRUD types — and every other type fell off + * the end of the switch with no `default:`, silently dropped. Weather, season, + * era, moon phase, cycle, festival and structure changes authored in Chronicle + * never reached the GM's table. + * + * This module holds the PURE half of the fix: payload normalization, the + * human-readable one-liners the chat/dashboard surfaces render, and the + * reducer that maintains the "last known sub-resource state" snapshot the + * dashboard's Calendar tab reads. Everything that touches Foundry globals + * (ChatMessage, CALENDARIA.api, ui.notifications) stays in `calendar-sync.mjs` + * so this file is unit-testable off-DOM (house pattern: `_overview-model.mjs`, + * `_calendar-sync-state.mjs`). + * + * ## Payload shapes (verified against Chronicle main, 2026-07-25) + * + * Producers live in `internal/plugins/calendar/service.go` + + * `worldstate_service.go`; the wire names are assigned by + * `calendarEventPublisherAdapter.PublishCalendarEvent` + * (`internal/app/routes.go`). + * + * | Type | Payload | + * |-------------------------------|------------------------------------------------------| + * | `calendar.season.changed` | `{id, name, color}` — **or `null`** when the date left a season without entering another (service.go:2544) | + * | `calendar.era.changed` | `{id, name, color}` | + * | `calendar.moon.phase_changed` | `{moon_id, moon_name, phase_name, phase_position}` | + * | `calendar.weather.changed` | merged `WeatherInput` (FLAT snake_case) — **or `null`** from the weather-zone paths (service.go:1490, :1523) | + * | `calendar.structure.updated` | `null` | + * | `calendar.cycle.changed` | `null` | + * | `calendar.festival.changed` | `null` | + * | `calendar.worldstate.changed` | `{date:{year,month,day}, moodTint:{color,intensity}}` | + * + * Two consequences drive the defensive shape of the code below: + * + * 1. **A null payload is normal, not an error.** Four of the eight types can + * arrive with `payload: null` by design. Handlers must treat null as "this + * changed — refetch if you care", never as a malformed message. + * 2. **Weather arrives in two different shapes.** The WS payload is the FLAT + * `WeatherInput` (`wind_speed_kph`, `precipitation_type`); a refetch of + * `GET /calendar/weather` returns the NESTED `Weather` model + * (`wind: {speed_kph}`, `precipitation: {type}`). `normalizeWeather` accepts + * both so the refetch path and the push path render identically. + */ + +/** + * Every `calendar.*` WebSocket type this module knowingly routes. Used by the + * `default:` log-once branch to distinguish "a type we deliberately ignore" + * from "a type Chronicle grew that nobody wired up" — the second is the class + * of silent drop this whole dispatch exists to end. + * + * `calendar.cycle.changed` and `calendar.festival.changed` are listed as + * *routed* because they are handled by the structure re-compare path: both + * always fire alongside `calendar.structure.updated` from the same service + * call (service.go:1594-1595, :1629-1630), so treating them as a second + * structure signal is correct and avoids a duplicate re-compare. + */ +export const ROUTED_CALENDAR_TYPES = Object.freeze([ + 'calendar.date.advanced', + 'calendar.event.created', + 'calendar.event.updated', + 'calendar.event.deleted', + 'calendar.season.changed', + 'calendar.era.changed', + 'calendar.moon.phase_changed', + 'calendar.weather.changed', + 'calendar.worldstate.changed', + 'calendar.structure.updated', + 'calendar.cycle.changed', + 'calendar.festival.changed', +]); + +/** + * Types that mean "Chronicle's calendar structure moved — re-compare". All + * three fire from the same service calls; `structure.updated` is the umbrella + * and the other two are the granular siblings added by C-CAL-WS-DOTTED. + */ +export const STRUCTURE_SIGNAL_TYPES = Object.freeze([ + 'calendar.structure.updated', + 'calendar.cycle.changed', + 'calendar.festival.changed', +]); + +/** + * Map a sub-resource WS type to the world setting that gates its chat + * announcement. Returns null for types that never announce. + * + * Defaults (registered in `settings.mjs`) follow the dispatch: season/era ON, + * moon OFF (a moon phase changes every few in-world days — announcing each one + * is chat spam), weather ON, worldstate ON. + * + * @param {string} type + * @returns {string|null} setting key, or null when the type never announces + */ +export function announceSettingFor(type) { + switch (type) { + case 'calendar.weather.changed': return 'calendarAnnounceWeather'; + case 'calendar.worldstate.changed': return 'calendarAnnounceWorldstate'; + case 'calendar.season.changed': return 'calendarAnnounceSeasonEra'; + case 'calendar.era.changed': return 'calendarAnnounceSeasonEra'; + case 'calendar.moon.phase_changed': return 'calendarAnnounceMoon'; + default: return null; + } +} + +/** + * Coerce a possibly-absent value to a trimmed non-empty string, else null. + * @param {unknown} v + * @returns {string|null} + */ +function str(v) { + if (v === null || v === undefined) return null; + const s = String(v).trim(); + return s.length ? s : null; +} + +/** + * Coerce to a finite number, else null. `Number(null)` is 0, so the explicit + * null/'' rejection matters: a missing temperature must not render as "0°C". + * @param {unknown} v + * @returns {number|null} + */ +function num(v) { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** + * Normalize either weather shape into one flat record for rendering. + * + * Accepts BOTH: + * - the WS payload: flat merged `WeatherInput` + * (`wind_speed_kph`, `wind_speed_tier`, `wind_direction`, + * `precipitation_type`, `precipitation_intensity`) + * - a `GET /calendar/weather` response: nested `Weather` + * (`wind: {speed_kph, speed_tier, direction}`, + * `precipitation: {type, intensity}`) + * + * Returns null when the input carries no renderable field at all — the caller + * treats that as "nothing to announce" rather than posting an empty line. + * + * @param {object|null|undefined} raw + * @returns {{presetLabel:string|null, temperatureC:number|null, windTier:string|null, + * windSpeedKph:number|null, windDirection:string|null, precipType:string|null, + * precipIntensity:number|null, zoneName:string|null, description:string|null, + * icon:string|null}|null} + */ +export function normalizeWeather(raw) { + if (!raw || typeof raw !== 'object') return null; + const wind = (raw.wind && typeof raw.wind === 'object') ? raw.wind : {}; + const precip = (raw.precipitation && typeof raw.precipitation === 'object') ? raw.precipitation : {}; + + const out = { + presetLabel: str(raw.preset_label) ?? str(raw.preset_id), + temperatureC: num(raw.temperature_celsius), + windTier: str(raw.wind_speed_tier) ?? str(wind.speed_tier), + windSpeedKph: num(raw.wind_speed_kph) ?? num(wind.speed_kph), + windDirection: str(raw.wind_direction) ?? str(wind.direction), + precipType: str(raw.precipitation_type) ?? str(precip.type), + precipIntensity: num(raw.precipitation_intensity) ?? num(precip.intensity), + zoneName: str(raw.zone_name) ?? str(raw.zone_id), + description: str(raw.description), + icon: str(raw.icon), + }; + const hasAny = Object.entries(out).some(([k, v]) => k !== 'icon' && v !== null); + return hasAny ? out : null; +} + +/** + * Render a normalized weather record as one GM-facing line. + * + * Example: `Chronicle weather: Heavy snow, -8°C, gale from the north, + * snow (heavy) — North Reach` + * + * @param {ReturnType} w + * @returns {string|null} null when there is nothing worth saying + */ +export function formatWeatherLine(w) { + if (!w) return null; + const parts = []; + if (w.presetLabel) parts.push(w.presetLabel); + if (w.temperatureC !== null) parts.push(`${w.temperatureC}°C`); + const windBits = [w.windTier, w.windSpeedKph !== null ? `${w.windSpeedKph} kph` : null] + .filter(Boolean).join(' '); + if (windBits) parts.push(w.windDirection ? `${windBits} from ${w.windDirection}` : windBits); + else if (w.windDirection) parts.push(`wind from ${w.windDirection}`); + if (w.precipType) { + parts.push(w.precipIntensity !== null ? `${w.precipType} (${w.precipIntensity})` : w.precipType); + } + if (w.description) parts.push(w.description); + if (!parts.length) return null; + const zone = w.zoneName ? ` — ${w.zoneName}` : ''; + return `Chronicle weather: ${parts.join(', ')}${zone}`; +} + +/** + * Render `calendar.worldstate.changed` as one GM-facing line. + * + * **Known Chronicle-side gap (see this file's header + the PR body):** the + * payload carries only the date and the ambient mood tint — no meteor/eclipse + * detail, despite `calendar_celestial_events` being the feature that motivated + * the dispatch. The line therefore reports what the payload actually contains + * and never invents a celestial event it cannot see. + * + * @param {object|null|undefined} payload + * @returns {string|null} + */ +export function formatWorldstateLine(payload) { + if (!payload || typeof payload !== 'object') return null; + const d = payload.date; + const dateBit = (d && typeof d === 'object' && num(d.year) !== null) + ? `${d.year}-${String(d.month ?? 1).padStart(2, '0')}-${String(d.day ?? 1).padStart(2, '0')}` + : null; + const tint = payload.moodTint ?? payload.mood_tint; + const tintBit = (tint && typeof tint === 'object' && str(tint.color)) + ? `mood ${str(tint.color)}${num(tint.intensity) !== null ? ` @ ${num(tint.intensity)}` : ''}` + : null; + const bits = [dateBit, tintBit].filter(Boolean); + if (!bits.length) return null; + return `Chronicle world state changed: ${bits.join(' · ')}`; +} + +/** + * Render `calendar.season.changed`. A null payload is the documented "left a + * season without entering a new one" case (service.go:2544), which is still + * worth announcing — the seasonal modifiers the table was using no longer + * apply. + * @param {object|null|undefined} payload + * @returns {string|null} + */ +export function formatSeasonLine(payload) { + const name = str(payload?.name); + return name ? `Chronicle season: ${name}` : 'Chronicle season: no season currently in effect'; +} + +/** + * Render `calendar.era.changed`. Unlike season, Chronicle only publishes this + * with a populated payload, so a nameless payload means a malformed message + * and renders nothing. + * @param {object|null|undefined} payload + * @returns {string|null} + */ +export function formatEraLine(payload) { + const name = str(payload?.name); + return name ? `Chronicle era: ${name}` : null; +} + +/** + * Render `calendar.moon.phase_changed`. + * @param {object|null|undefined} payload + * @returns {string|null} + */ +export function formatMoonLine(payload) { + const moon = str(payload?.moon_name) ?? str(payload?.moonName); + const phase = str(payload?.phase_name) ?? str(payload?.phaseName); + if (!moon && !phase) return null; + if (!phase) return `Chronicle moon: ${moon} phase changed`; + return `Chronicle moon: ${moon || 'Moon'} is now ${phase}`; +} + +/** + * Render the announcement line for any sub-resource type, dispatching to the + * per-type formatter. Returns null when the type never announces or the + * payload carried nothing renderable. + * + * @param {string} type + * @param {object|null|undefined} payload + * @returns {string|null} + */ +export function formatSubresourceLine(type, payload) { + switch (type) { + case 'calendar.weather.changed': return formatWeatherLine(normalizeWeather(payload)); + case 'calendar.worldstate.changed': return formatWorldstateLine(payload); + case 'calendar.season.changed': return formatSeasonLine(payload); + case 'calendar.era.changed': return formatEraLine(payload); + case 'calendar.moon.phase_changed': return formatMoonLine(payload); + default: return null; + } +} + +/** + * The empty sub-resource snapshot. Kept as a factory (not a frozen singleton) + * because the reducer returns a NEW object each call and callers hold the + * previous one — a shared mutable default would alias across CalendarSync + * instances in tests. + * @returns {{weather:object|null, weatherLine:string|null, season:object|null, + * era:object|null, moons:object, lastType:string|null}} + */ +export function emptySubresourceState() { + return { + weather: null, + weatherLine: null, + season: null, + era: null, + /** Keyed by moon id (falling back to name) so N moons coexist. */ + moons: {}, + lastType: null, + }; +} + +/** + * Fold one sub-resource message into the snapshot the dashboard renders. + * Pure: returns a new state object, never mutates `prev`. + * + * A null payload PRESERVES the prior value for that slot rather than blanking + * it — the weather-zone paths publish `calendar.weather.changed` with a null + * payload (service.go:1490, :1523), and treating that as "weather is now + * unknown" would wipe a perfectly good reading off the dashboard. The one + * exception is season, where Chronicle uses a null payload to mean the + * specific, meaningful state "no season in effect". + * + * @param {ReturnType|null} prev + * @param {string} type + * @param {object|null|undefined} payload + * @returns {ReturnType} + */ +export function reduceSubresourceState(prev, type, payload) { + const next = { ...(prev || emptySubresourceState()) }; + next.moons = { ...(prev?.moons || {}) }; + next.lastType = type; + + switch (type) { + case 'calendar.weather.changed': { + const w = normalizeWeather(payload); + if (w) { + next.weather = w; + next.weatherLine = formatWeatherLine(w); + } + break; + } + case 'calendar.season.changed': + // Null is meaningful here: "left a season without entering another". + next.season = payload && typeof payload === 'object' + ? { name: str(payload.name), color: str(payload.color) } + : null; + break; + case 'calendar.era.changed': + if (payload && typeof payload === 'object' && str(payload.name)) { + next.era = { name: str(payload.name), color: str(payload.color) }; + } + break; + case 'calendar.moon.phase_changed': { + const id = str(payload?.moon_id) ?? str(payload?.moon_name); + if (id) { + next.moons[id] = { + name: str(payload?.moon_name) ?? id, + phase: str(payload?.phase_name), + position: num(payload?.phase_position), + }; + } + break; + } + default: + break; + } + return next; +} + +/** + * Project the snapshot into the flat, template-friendly shape the dashboard's + * Calendar tab renders. Returns `{has:false}` when nothing has arrived yet so + * the template can skip the panel entirely rather than render empty rows. + * + * @param {ReturnType|null} state + * @returns {{has:boolean, weatherLine:string|null, seasonName:string|null, + * eraName:string|null, moons:Array<{name:string, phase:string|null}>}} + */ +export function projectSubresourcePanel(state) { + const moons = Object.values(state?.moons || {}) + .map((m) => ({ name: m?.name || 'Moon', phase: m?.phase ?? null })) + .sort((a, b) => a.name.localeCompare(b.name)); + const weatherLine = state?.weatherLine ?? null; + const seasonName = state?.season?.name ?? null; + const eraName = state?.era?.name ?? null; + // `season: null` after a season.changed(null) is a real state, not "unset" — + // `lastType` tells us something arrived, so the panel still shows. + const has = Boolean(weatherLine || seasonName || eraName || moons.length || state?.lastType); + return { has, weatherLine, seasonName, eraName, moons }; +} diff --git a/scripts/_calendar-sync-state.mjs b/scripts/_calendar-sync-state.mjs index 53d339c..47cfa19 100644 --- a/scripts/_calendar-sync-state.mjs +++ b/scripts/_calendar-sync-state.mjs @@ -21,14 +21,29 @@ * an unreadable structure, or isn't running, yet the * dates on the wire are still meaningless. Carries * the month/weekday counts. + * - `structure-changed` — FM-SYNC-SUBRESOURCES-P1. Chronicle broadcast + * `calendar.structure.updated` (or its granular + * `cycle`/`festival` siblings) THIS SESSION, the + * module re-ran the comparison, and it came back + * compatible. Advisory, not an error: the counts + * we compare (month count, per-month day counts, + * weekday count) still match, but month NAMES, + * cycles, festivals and era boundaries are outside + * the comparison, so the operator should eyeball + * the calendar. We deliberately do NOT auto-apply + * the new structure — auto-merge is a later arc. * - `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. + * spells out the structural reason. `structure-changed` sits BELOW both — it is + * an advisory that only makes sense once we've established nothing is actually + * broken — and ABOVE `date-drift`/`in-sync`, because "the structure moved under + * you" is more important than a one-day date delta. All four remain + * independently reachable, so the badge never has to invent a state it can't + * back with data. */ /** @@ -63,7 +78,13 @@ function compareDates(a, b) { * @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'), + * @param {string|null} [input.structureChangedDetail] - set by CalendarSync when + * a `calendar.structure.updated` (or cycle/festival) broadcast arrived this + * session and the re-compare found the structures still compatible + * (FM-SYNC-SUBRESOURCES-P1). A truthy value raises the advisory + * `structure-changed` state; it is deliberately outranked by `paused` and + * `incompatible-structures`, which describe actual breakage. + * @returns {{state:('in-sync'|'date-drift'|'structure-changed'|'incompatible-structures'|'paused'), * direction:('chronicle-ahead'|'foundry-ahead'|null), detail:string}} */ export function classifyCalendarSyncState(input) { @@ -75,6 +96,7 @@ export function classifyCalendarSyncState(input) { foundryShape = null, chronicleDate = null, foundryDate = null, + structureChangedDetail = null, } = input || {}; // 1. Module has paused sync for the session — the strongest fact. @@ -99,7 +121,15 @@ export function classifyCalendarSyncState(input) { }; } - // 3/4. Structures compatible (or not comparable). Compare dates. + // 3. Chronicle's structure moved this session and the re-compare came back + // compatible (FM-SYNC-SUBRESOURCES-P1). Advisory — sync keeps running, but + // the badge stops claiming a clean "In Sync" the operator hasn't verified. + // Reached only after the two breakage states above have been ruled out. + if (structureChangedDetail) { + return { state: 'structure-changed', direction: null, detail: structureChangedDetail }; + } + + // 4/5. 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) { diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index fc1dd35..fa709e0 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -23,6 +23,15 @@ 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'; +import { + ROUTED_CALENDAR_TYPES, + STRUCTURE_SIGNAL_TYPES, + announceSettingFor, + emptySubresourceState, + formatSubresourceLine, + normalizeWeather, + reduceSubresourceState, +} from './_calendar-subresources.mjs'; /** * Canonical wire-visibility values per the calendar-sync wire contract @@ -83,6 +92,42 @@ export function isWireVisibilityGmOnly(wireValue) { */ export const CALENDARIA_FLAG_SCOPE = 'calendaria'; +/** + * Candidate Calendaria weather-setter method names, probed in order by + * `_applyWeatherToCalendaria`. Calendaria's published API surface exposes + * weather READS (`getCurrentWeather`, `getWeatherForDate` — the two the + * diagnostics bundle already probes) but no documented setter; its weather is + * generated from zone/preset tables rather than assigned. Probing a short list + * and degrading to chat beats hard-coding one speculative name that silently + * no-ops on every build. Mirrored into the diagnostics probe list so an + * operator whose build DOES expose one shows up in their bug report. + * FM-SYNC-SUBRESOURCES-P1 Step 0. + */ +export const CALENDARIA_WEATHER_SETTERS = Object.freeze([ + 'setWeather', + 'setCurrentWeather', + 'setWeatherForDate', +]); + +/** + * Escape a plain-text line for safe interpolation into a ChatMessage body. + * Chronicle-authored strings (weather descriptions, season names) are operator + * content, but they arrive over the wire and land in innerHTML-rendered chat — + * so they get escaped at the boundary like every other ingress in this module + * (FM-SEC-CHUNK-3 discipline). Not exported as HTML: the announcements are + * one-line plain text by design. + * @param {string} s + * @returns {string} + */ +function escapeChatHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + /** * SimpleCalendar persists each note as a JournalEntry under one of these module * flag scopes; the namespace's presence on the document is the note signal. @@ -336,6 +381,33 @@ export class CalendarSync { /** @type {string|null} Human-readable mismatch detail for the dashboard + diagnostics. */ this._calendarMismatchDetail = null; + /** + * Last-known Chronicle sub-resource snapshot (weather / season / era / moon + * phases), folded from the WebSocket stream by `reduceSubresourceState`. + * The dashboard's Calendar tab renders it; nothing here drives a write into + * Foundry. FM-SYNC-SUBRESOURCES-P1. + * @type {ReturnType} + */ + this._subresourceState = emptySubresourceState(); + + /** + * Set when a `calendar.structure.updated` (or cycle/festival) broadcast + * arrived this session AND the re-compare found the structures still + * compatible. Feeds the dashboard's advisory `structure-changed` badge. We + * never auto-apply the new structure — see `_onChronicleStructureUpdated`. + * @type {string|null} + */ + this._structureChangedDetail = null; + + /** + * Types already logged by the `default:` branch of `onMessage`, so an + * unhandled `calendar.*` type produces exactly ONE debug line per session + * instead of one per broadcast. The dispatch's "no more silent drops" + * requirement, without turning a busy calendar into console spam. + * @type {Set} + */ + this._loggedUnhandledTypes = new Set(); + // Bound hook handlers for cleanup. this._boundHandlers = {}; } @@ -391,6 +463,20 @@ export class CalendarSync { */ async onMessage(msg) { if (!getSetting('syncCalendar') || !this._calendarModule) return; + + // Structure signals are processed EVEN WHILE PAUSED, ahead of the + // mismatch guard. They are the only broadcast that can legitimately CLEAR + // a structure-mismatch pause: the operator's remedy for the pause is to fix + // the calendar in Chronicle, and fixing it is exactly what emits + // `calendar.structure.updated`. Leaving this behind the guard would make + // the pause unrecoverable without a world reload — the same + // dead-on-arrival shape FM-SYNC-WIRE-FIX just removed from initial sync. + // The handler itself performs NO writes into Foundry (see its doc comment). + if (STRUCTURE_SIGNAL_TYPES.includes(msg?.type)) { + await this._onChronicleStructureUpdated(msg.type); + return; + } + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): no pull switch (msg.type) { @@ -406,9 +492,52 @@ export class CalendarSync { case 'calendar.event.deleted': await this._onChronicleEventDeleted(msg.payload); break; + + // --- Sub-resources (FM-SYNC-SUBRESOURCES-P1) --------------------------- + // Display-level only: every branch below folds the payload into + // `_subresourceState` for the dashboard and optionally posts a GM-only + // chat line. None of them writes a Chronicle value into the Foundry + // calendar's stored structure. + case 'calendar.weather.changed': + await this._onChronicleWeatherChanged(msg.payload); + break; + case 'calendar.worldstate.changed': + case 'calendar.season.changed': + case 'calendar.era.changed': + case 'calendar.moon.phase_changed': + await this._onChronicleSubresourceChanged(msg.type, msg.payload); + break; + + default: + this._logUnhandledCalendarType(msg?.type); + break; } } + /** + * Log an unhandled `calendar.*` type ONCE per session. + * + * Before this dispatch the switch above had no `default:` at all, so seven of + * Chronicle's eleven calendar broadcasts fell off the end and vanished with no + * trace anywhere — the operator's "celestial events unfindable/unsyncable" + * report had no console breadcrumb to follow. Non-calendar types are ignored + * silently: `SyncManager._routeMessage` fans EVERY message to EVERY module, so + * entity/map/note traffic reaching CalendarSync is normal, not a gap. + * + * @param {string|undefined} type + * @private + */ + _logUnhandledCalendarType(type) { + if (typeof type !== 'string' || !type.startsWith('calendar.')) return; + if (ROUTED_CALENDAR_TYPES.includes(type)) return; + if (this._loggedUnhandledTypes.has(type)) return; + this._loggedUnhandledTypes.add(type); + console.debug( + `Chronicle: unhandled calendar WebSocket type "${type}" — dropped. ` + + 'If this carries state the table should see, wire it in calendar-sync.mjs onMessage.', + ); + } + /** * Handle a sync mapping received during initial sync. * Stores calendar event mappings for later lookup. @@ -711,6 +840,276 @@ export class CalendarSync { } } + // --- Chronicle → Foundry: sub-resources (FM-SYNC-SUBRESOURCES-P1) ---------- + + /** + * Whisper one line to the GMs. Every sub-resource announcement goes through + * here, and it is deliberately the ONLY chat surface this arc adds. + * + * **Why whisper rather than broadcast.** Chronicle's WS hub gates dm_only + * traffic server-side via `Message.RequiresDM` (`internal/websocket/hub.go` + * :159-167), so a payload that reaches this module is already cleared for the + * GM — but "cleared for the GM" is not "cleared for the table". Weather zones + * and world state routinely encode information the DM is holding back (an + * unnatural darkness, a zone the party hasn't discovered). Re-broadcasting a + * DM-gated payload into public chat would launder a server-side permission + * decision into a player-visible one, which is exactly the leak class + * cordinator#32 hardened against on the Chronicle side. `whisper` to the GM + * user ids keeps the audience identical to the one Chronicle authorized. + * + * The GM decides what to share; the module never decides for them. + * + * @param {string|null} line + * @private + */ + _announceToGM(line) { + if (!line) return; + try { + const gmIds = globalThis.ChatMessage?.getWhisperRecipients?.('GM')?.map((u) => u.id) ?? []; + globalThis.ChatMessage?.create?.({ + content: `

${escapeChatHtml(line)}

`, + whisper: gmIds, + speaker: { alias: 'Chronicle' }, + }); + } catch (err) { + // Chat is a courtesy surface — never let it break message routing. + console.debug('Chronicle: sub-resource chat announcement failed', err?.message); + } + } + + /** + * Should this sub-resource type announce in chat right now? Reads the + * per-type world setting (`announceSettingFor`). Unknown settings fail + * CLOSED (no announcement) rather than spamming a world whose settings + * predate this release. + * @param {string} type + * @returns {boolean} + * @private + */ + _shouldAnnounce(type) { + const key = announceSettingFor(type); + if (!key) return false; + try { + return getSetting(key) === true; + } catch { + return false; + } + } + + /** + * Handle `calendar.weather.changed`. + * + * Two payload shapes arrive on this type (see `_calendar-subresources.mjs`): + * the merged `WeatherInput` from `SetWeather`, and `null` from the + * weather-zone paths, which publish the type purely as a "refetch me" ping. + * A null payload therefore triggers one `GET /calendar/weather` rather than + * being discarded — without it, every active-zone change would land as a + * no-op, which is the same silent-drop shape this dispatch is closing. + * + * Apply order, honestly degrading: + * 1. If the active module is Calendaria AND it exposes a weather SETTER, + * hand it the reading (see `_applyWeatherToCalendaria`). + * 2. Otherwise — SimpleCalendar, or a Calendaria build with read-only + * weather — fall back to the GM chat line. The dashboard panel is + * updated either way. + * + * @param {object|null} payload + * @private + */ + async _onChronicleWeatherChanged(payload) { + this._syncDepth++; + try { + let raw = payload; + if (!raw) { + // Zone-change ping: the type fired with no body. Refetch the reading. + try { + raw = await this._api.get('/calendar/weather'); + } catch (err) { + console.debug('Chronicle: weather refetch after zone change failed', err?.message); + } + } + const weather = normalizeWeather(raw); + if (!weather) return; + + this._subresourceState = reduceSubresourceState( + this._subresourceState, 'calendar.weather.changed', raw, + ); + + const appliedToModule = await this._applyWeatherToCalendaria(weather); + if (!appliedToModule && this._shouldAnnounce('calendar.weather.changed')) { + this._announceToGM(this._subresourceState.weatherLine); + } + } finally { + this._syncDepth--; + } + } + + /** + * Push a weather reading into Calendaria when — and only when — its API + * exposes a setter. + * + * Step-0 inventory (this dispatch): the module's existing Calendaria probe + * list (`sync-dashboard.mjs` `_buildCalendarDiagnosticsInput`) covers + * `getWeatherForDate` and `getCurrentWeather` — both READS, used for the + * diagnostics bundle. No Calendaria build the module has been pointed at + * exposes a documented weather *setter*, and Calendaria's own weather is + * generated from its zone/preset tables rather than assigned. So rather than + * pin one speculative method name, we probe the plausible names and no-op + * when none is present, returning false so the caller falls back to chat. + * The probe names are mirrored into the diagnostics bundle, so an operator on + * a build that DOES expose one can see it in their report and we can pin the + * real name in a follow-up instead of guessing now. + * + * @param {ReturnType} weather + * @returns {Promise} true when the reading was handed to Calendaria + * @private + */ + async _applyWeatherToCalendaria(weather) { + if (this._calendarModule !== 'calendaria' || !weather) return false; + try { + const api = globalThis.CALENDARIA?.api; + if (!api) return false; + const setter = CALENDARIA_WEATHER_SETTERS.find((n) => typeof api[n] === 'function'); + if (!setter) return false; + await api[setter]({ + label: weather.presetLabel, + temperature: weather.temperatureC, + windTier: weather.windTier, + windSpeed: weather.windSpeedKph, + windDirection: weather.windDirection, + precipitation: weather.precipType, + intensity: weather.precipIntensity, + zone: weather.zoneName, + description: weather.description, + }); + console.debug(`Chronicle: applied Chronicle weather via CALENDARIA.api.${setter}`); + return true; + } catch (err) { + // A failed apply must degrade to the chat line, not swallow the update. + console.debug('Chronicle: Calendaria weather apply failed, falling back to chat', err?.message); + return false; + } + } + + /** + * Handle the display-only sub-resource types: world state, season, era and + * moon phase. Each folds into `_subresourceState` (which the dashboard's + * Calendar tab renders) and optionally posts a GM-only chat line, gated by + * that type's world setting. + * + * **No writes into Foundry.** P1 is display-level by dispatch: nothing here + * creates a note, mutates a calendar, or touches a document. In particular + * the world-state branch does NOT auto-create a same-day Calendaria note for + * celestial events, for two reasons found in Step 0 and recorded in full in + * the PR body: the payload carries no celestial detail to put in a note + * (`{date, moodTint}` — service side, `worldstate_service.go`:265), and it + * carries no stable event id, so a re-broadcast or a reconnect would create + * duplicate notes with no way to dedupe them. Announcing what the payload + * actually contains is honest; inventing a "Meteor Shower" note from a mood + * tint would not be. + * + * @param {string} type + * @param {object|null} payload + * @private + */ + async _onChronicleSubresourceChanged(type, payload) { + this._syncDepth++; + try { + this._subresourceState = reduceSubresourceState(this._subresourceState, type, payload); + if (!this._shouldAnnounce(type)) return; + this._announceToGM(formatSubresourceLine(type, payload)); + } finally { + this._syncDepth--; + } + } + + /** + * Handle `calendar.structure.updated` and its granular siblings + * (`calendar.cycle.changed`, `calendar.festival.changed`, which Chronicle + * fires alongside it — `service.go`:1594-1595, :1629-1630). + * + * **Deliberately does NOT auto-apply the new structure.** Rewriting the + * Foundry calendar's months/weekdays from a broadcast would be the most + * destructive write in the module: Calendaria stores notes against month/day + * coordinates, so re-shaping the calendar underneath them silently re-dates + * every note in the world. Non-destructive auto-merge is a later arc + * (calendar-remodel requirements §1 item 10). What we do instead: + * + * 1. Refetch `GET /calendar` so the cached structure isn't stale. + * 2. Re-run the SAME comparison `onInitialSync` runs, with the same + * fail-open discipline — an unreadable structure on either side never + * pauses anything. + * 3. Now incompatible → pause (the existing B-R2 path, one warning). + * Still compatible → record the advisory `structure-changed` badge + * detail so the dashboard stops claiming a clean "In Sync" the operator + * hasn't eyeballed. + * 4. Compatible AND we were previously paused for a mismatch → CLEAR the + * pause. This is the recovery path: the operator's documented remedy for + * a mismatch pause is "fix the calendar in Chronicle", and doing so is + * precisely what emits this broadcast. Without step 4 the pause would + * survive until a world reload even after the cause was removed. + * + * Runs even while paused — it's the one handler that must (see `onMessage`). + * + * @param {string} [type] - the signal type, for the log line. + * @private + */ + async _onChronicleStructureUpdated(type = 'calendar.structure.updated') { + this._syncDepth++; + try { + let cal = null; + try { + cal = await this._api.get('/calendar'); + } catch (err) { + console.debug('Chronicle: structure re-compare could not refetch /calendar', err?.message); + } + if (cal) this._chronicleCalendar = cal; + + const chronicleCal = this._chronicleCalendar; + // Fail open, exactly as onInitialSync does: no readable structure on + // either side means no verdict, so we neither pause nor un-pause. + if (!(chronicleCal?.months?.length > 0)) { + console.debug(`Chronicle: ${type} received; Chronicle structure unreadable — no re-compare.`); + return; + } + const foundryStruct = this._readActiveFoundryStructure(); + if (!foundryStruct) { + console.debug(`Chronicle: ${type} received; Foundry structure unreadable — no re-compare.`); + return; + } + + const cmp = compareCalendarStructures(chronicleCal, foundryStruct); + if (!cmp.match) { + this._structureChangedDetail = null; + if (!this._calendarSyncDisabled) { + this._pauseCalendarSyncForMismatch(chronicleCal, foundryStruct, cmp.detail); + } + return; + } + + // Compatible. Recover from a prior mismatch pause if there was one. + if (this._calendarSyncDisabled) { + this._calendarSyncDisabled = false; + this._calendarMismatchDetail = null; + const msg = 'Chronicle Sync: the Chronicle calendar structure now matches the active ' + + 'Foundry calendar — calendar sync resumed for this session.'; + console.warn(msg); + try { globalThis.ui?.notifications?.info(msg); } catch { /* headless */ } + } + + const chronicleShape = `${(chronicleCal.months || []).length}mo/${(chronicleCal.weekdays || []).length}wd`; + const foundryShape = `${(foundryStruct.monthDays || []).length}mo/${foundryStruct.weekdayCount ?? 0}wd`; + this._structureChangedDetail = + `Chronicle's calendar structure changed (${type}). Re-compared: still compatible ` + + `(Chronicle ${chronicleShape} vs Foundry ${foundryShape}). Month names, cycles, festivals ` + + 'and era boundaries are outside this comparison — re-check the calendar. ' + + 'The Foundry calendar was NOT modified.'; + console.debug(`Chronicle: ${this._structureChangedDetail}`); + } finally { + this._syncDepth--; + } + } + /** * Create a local calendar event from Chronicle data. * @param {object} data - Chronicle event object. diff --git a/scripts/settings.mjs b/scripts/settings.mjs index 7250f70..5c4874c 100644 --- a/scripts/settings.mjs +++ b/scripts/settings.mjs @@ -122,6 +122,53 @@ export function registerSettings() { default: false, }); + // Calendar sub-resource chat announcements (FM-SYNC-SUBRESOURCES-P1). + // + // Chronicle broadcasts weather / world-state / season / era / moon-phase + // changes; the module surfaces them as GM-ONLY whispers (never public chat — + // see CalendarSync._announceToGM for why re-broadcasting a DM-gated payload + // would launder a server-side permission decision). + // + // Defaults follow the dispatch: season/era and weather ON because they change + // rarely and matter at the table; moon phases OFF because a moon crosses a + // phase boundary every few in-world days and would flood the log. All four + // are independent so a GM can keep the dashboard panel without the chat. + game.settings.register(MODULE_ID, 'calendarAnnounceWeather', { + name: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceWeather.Name'), + hint: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceWeather.Hint'), + scope: 'world', + config: true, + type: Boolean, + default: true, + }); + + game.settings.register(MODULE_ID, 'calendarAnnounceWorldstate', { + name: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceWorldstate.Name'), + hint: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceWorldstate.Hint'), + scope: 'world', + config: true, + type: Boolean, + default: true, + }); + + game.settings.register(MODULE_ID, 'calendarAnnounceSeasonEra', { + name: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceSeasonEra.Name'), + hint: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceSeasonEra.Hint'), + scope: 'world', + config: true, + type: Boolean, + default: true, + }); + + game.settings.register(MODULE_ID, 'calendarAnnounceMoon', { + name: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceMoon.Name'), + hint: game.i18n.localize('CHRONICLE.Settings.CalendarAnnounceMoon.Hint'), + scope: 'world', + config: true, + type: Boolean, + default: false, + }); + // Internal: detected Chronicle system ID matched from Foundry's game.system.id. game.settings.register(MODULE_ID, 'detectedSystem', { scope: 'world', diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 8b5bfeb..6a34ea1 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -28,6 +28,7 @@ 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'; +import { projectSubresourcePanel } from './_calendar-subresources.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; /** @@ -681,6 +682,10 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { const calSync = this._getCalendarSyncModule(); const paused = !!calSync?._calendarSyncDisabled; const pausedDetail = calSync?._calendarMismatchDetail || null; + // FM-SYNC-SUBRESOURCES-P1: advisory 5th state — Chronicle's structure moved + // this session and CalendarSync's re-compare found it still compatible. + // Outranked by paused/incompatible in the classifier; see its doc comment. + const structureChangedDetail = calSync?._structureChangedDetail || null; const chronicleDate = { year: chronicle.current_year, @@ -708,8 +713,15 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { foundryShape, chronicleDate, foundryDate: localDate ? { year: localDate.year, month: localDate.month, day: localDate.day } : null, + structureChangedDetail, }); + // Chronicle world-state panel (weather / season / era / moons) — the + // display surface for the sub-resource broadcasts CalendarSync now folds + // into `_subresourceState`. Read-only projection; the dashboard never + // writes any of it back into the Foundry calendar. + const worldState = projectSubresourcePanel(calSync?._subresourceState ?? null); + return { available: true, enabled: calendarEnabled, @@ -721,15 +733,17 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { calendarName: chronicle.name || 'Campaign Calendar', }, localDate, - // Four-state model + supporting display fields. - syncState: cls.state, // 'in-sync' | 'date-drift' | 'incompatible-structures' | 'paused' + // Five-state model + supporting display fields. + syncState: cls.state, // 'in-sync' | 'date-drift' | 'structure-changed' | 'incompatible-structures' | 'paused' syncDirection: cls.direction, // 'chronicle-ahead' | 'foundry-ahead' | null - syncStateDetail: cls.detail, // reason string for paused / incompatible-structures + syncStateDetail: cls.detail, // reason string for paused / incompatible / structure-changed // Convenience booleans for the template (derived from syncState). inSync: cls.state === 'in-sync', isPaused: cls.state === 'paused', isIncompatible: cls.state === 'incompatible-structures', + isStructureChanged: cls.state === 'structure-changed', isDrift: cls.state === 'date-drift', + worldState, }; } @@ -2813,10 +2827,18 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { const calApi = (typeof CALENDARIA !== 'undefined' ? CALENDARIA?.api : null) ?? null; let apiMethods = null; if (calApi) { + // The trailing three are WRITE probes added by FM-SYNC-SUBRESOURCES-P1. + // Calendaria publishes weather reads but no documented setter, so + // `CalendarSync._applyWeatherToCalendaria` probes these names and falls + // back to a GM chat line when none exists. Reporting them here means an + // operator on a build that DOES expose one shows up in the bug report and + // we can pin the real name instead of guessing. Keep this list in sync + // with CALENDARIA_WEATHER_SETTERS in calendar-sync.mjs. const probeKeys = [ 'getWeatherForDate', 'getCurrentWeather', 'getAllMoonPhases', 'getSelectedDay', 'createNote', 'updateNote', 'deleteNote', 'getCalendars', 'getActiveCalendar', 'getAllNotes', + 'setWeather', 'setCurrentWeather', 'setWeatherForDate', ]; apiMethods = {}; for (const k of probeKeys) apiMethods[k] = typeof calApi[k] === 'function'; diff --git a/styles/chronicle-sync.css b/styles/chronicle-sync.css index a05d88b..138d11c 100644 --- a/styles/chronicle-sync.css +++ b/styles/chronicle-sync.css @@ -1203,6 +1203,46 @@ justify-content: center; } +/* Chronicle world-state panel (FM-SYNC-SUBRESOURCES-P1): weather / season / + era / moon phases folded from the sub-resource WebSocket stream. Read-only + display, styled as a quiet block under the date-compare + actions so it + never competes with the sync badge above it. */ +.calendar-worldstate { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.calendar-worldstate .worldstate-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; +} + +.calendar-worldstate .worldstate-rows { + margin: 0 0 8px; +} + +.calendar-worldstate .worldstate-row { + display: flex; + gap: 8px; + padding: 3px 0; + font-size: 12px; +} + +.calendar-worldstate .worldstate-row dt { + flex: 0 0 90px; + color: rgba(255, 255, 255, 0.6); +} + +.calendar-worldstate .worldstate-row dd { + margin: 0; + flex: 1 1 auto; +} + /* ================================================================= */ /* STATUS TAB */ /* ================================================================= */ diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 1933517..4f280a7 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -945,6 +945,13 @@ Sync Paused {{else if calendar.isIncompatible}} Incompatible Structures + {{else if calendar.isStructureChanged}} + {{!-- FM-SYNC-SUBRESOURCES-P1: Chronicle broadcast structure.updated + this session; the re-compare came back compatible. Advisory — + sync keeps running, but the badge stops claiming a clean + "In Sync" the operator hasn't eyeballed. The Foundry calendar + was NOT modified. --}} + Structure Changed — Re-check {{else if calendar.isDrift}} {{#if (eq calendar.syncDirection "chronicle-ahead")}} Date Drift — Chronicle Ahead @@ -966,6 +973,48 @@ Push Date to Chronicle + + {{!-- FM-SYNC-SUBRESOURCES-P1: Chronicle world state. Weather, season, + era and moon phases folded from the WebSocket stream this + session. Read-only — nothing here is written back into the + Foundry calendar. --}} +
+
+ + {{localize "CHRONICLE.Dashboard.Calendar.WorldStateTitle"}} +
+ {{#if calendar.worldState.has}} +
+ {{#if calendar.worldState.weatherLine}} +
+
Weather
+
{{calendar.worldState.weatherLine}}
+
+ {{/if}} + {{#if calendar.worldState.seasonName}} +
+
Season
+
{{calendar.worldState.seasonName}}
+
+ {{/if}} + {{#if calendar.worldState.eraName}} +
+
Era
+
{{calendar.worldState.eraName}}
+
+ {{/if}} + {{#each calendar.worldState.moons}} +
+
{{this.name}}
+
{{#if this.phase}}{{this.phase}}{{else}}phase changed{{/if}}
+
+ {{/each}} +
+ {{else}} +

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

+ {{/if}} +

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

+
{{else}} diff --git a/tools/test-calendar-subresource-routing.mjs b/tools/test-calendar-subresource-routing.mjs new file mode 100644 index 0000000..2308488 --- /dev/null +++ b/tools/test-calendar-subresource-routing.mjs @@ -0,0 +1,483 @@ +#!/usr/bin/env node +/** + * test-calendar-subresource-routing.mjs — FM-SYNC-SUBRESOURCES-P1. + * + * Pins the WIRED half of the sub-resource arc — everything the pure-helper + * suite (`test-calendar-subresources.mjs`) can't reach because it touches + * `CalendarSync` state and stubbed Foundry globals: + * + * 1. Every handled `calendar.*` type routes to its handler. + * 2. dm_only weather is NEVER exposed to players — announcements are GM + * whispers, and no branch ever posts an unwhispered ChatMessage. + * 3. `calendar.structure.updated` (and its cycle/festival siblings) triggers + * a re-compare, pauses on a new incompatibility, un-pauses on recovery, + * and NEVER writes the structure into Foundry. + * 4. The `default:` branch logs an unhandled `calendar.*` type exactly once + * per session and stays silent on non-calendar traffic. + * + * Run: node --test tools/test-calendar-subresource-routing.mjs + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Foundry global stubs (same shape as test-calendar-backcatalog-fix.mjs) ─── + +const settingValues = { + syncCalendar: true, + calendarAnnounceWeather: true, + calendarAnnounceWorldstate: true, + calendarAnnounceSeasonEra: true, + calendarAnnounceMoon: false, +}; + +globalThis.foundry = globalThis.foundry || { + applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (base) => base } }, +}; +globalThis.game = globalThis.game || { + settings: { get: (_scope, key) => settingValues[key], register: () => {}, registerMenu: () => {} }, + i18n: { localize: (k) => k, format: (k) => k }, + user: { isGM: true }, + modules: { get: () => null }, +}; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {} }; + +/** Every ChatMessage.create() call made during a test. */ +let chatCalls = []; +globalThis.ChatMessage = { + getWhisperRecipients: (which) => (which === 'GM' ? [{ id: 'gm-user-1' }, { id: 'gm-user-2' }] : []), + create: (data) => { chatCalls.push(data); return Promise.resolve(data); }, +}; + +/** Every ui.notifications call, so the un-pause notice can be asserted. */ +let notices = []; +globalThis.ui = { + notifications: { + warn: (m) => notices.push({ level: 'warn', m }), + info: (m) => notices.push({ level: 'info', m }), + error: (m) => notices.push({ level: 'error', m }), + }, +}; + +const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); +const { emptySubresourceState } = await import('../scripts/_calendar-subresources.mjs'); + +/** + * Build a CalendarSync without running the constructor (it registers hooks and + * reads settings), seeded with the fields the sub-resource paths touch. + */ +function makeSync(overrides = {}) { + return Object.assign( + Object.create(CalendarSync.prototype), + { + _syncDepth: 0, + _calendarModule: 'calendaria', + _hasModernCalendariaApi: true, + _calendarSyncDisabled: false, + _calendarMismatchDetail: null, + _structureChangedDetail: null, + _subresourceState: emptySubresourceState(), + _loggedUnhandledTypes: new Set(), + _chronicleCalendar: null, + _api: { get: async () => null }, + }, + overrides, + ); +} + +function reset() { + chatCalls = []; + notices = []; + for (const k of Object.keys(settingValues)) { + settingValues[k] = { syncCalendar: true, calendarAnnounceMoon: false }[k] ?? true; + } + settingValues.syncCalendar = true; + settingValues.calendarAnnounceMoon = false; +} + +// A 12-month/7-weekday pair that compareCalendarStructures accepts. +const CHRONICLE_12x7 = { + name: 'Harptos', + months: Array.from({ length: 12 }, () => ({ days: 30 })), + weekdays: Array.from({ length: 7 }, (_, i) => ({ name: `d${i}` })), +}; +const FOUNDRY_12x7 = { + name: 'Harptos (Foundry)', + monthDays: Array.from({ length: 12 }, () => 30), + weekdayCount: 7, +}; +const FOUNDRY_15x6 = { + name: 'Therin', + monthDays: Array.from({ length: 15 }, () => 24), + weekdayCount: 6, +}; + +// ── 1. Routing: each handled type reaches its handler ─────────────────────── + +test('every handled calendar.* type routes to its handler', async () => { + reset(); + const seen = []; + const cs = makeSync({ + _onChronicaleDateAdvanced: async () => seen.push('date'), + _onChronicleEventCreated: async () => seen.push('created'), + _onChronicleEventUpdated: async () => seen.push('updated'), + _onChronicleEventDeleted: async () => seen.push('deleted'), + _onChronicleWeatherChanged: async () => seen.push('weather'), + _onChronicleSubresourceChanged: async (t) => seen.push(`sub:${t}`), + _onChronicleStructureUpdated: async (t) => seen.push(`struct:${t}`), + }); + + for (const type of [ + 'calendar.date.advanced', 'calendar.event.created', 'calendar.event.updated', + 'calendar.event.deleted', 'calendar.weather.changed', 'calendar.worldstate.changed', + 'calendar.season.changed', 'calendar.era.changed', 'calendar.moon.phase_changed', + 'calendar.structure.updated', 'calendar.cycle.changed', 'calendar.festival.changed', + ]) { + await cs.onMessage({ type, payload: null }); + } + + assert.deepEqual(seen, [ + 'date', 'created', 'updated', 'deleted', + 'weather', + 'sub:calendar.worldstate.changed', + 'sub:calendar.season.changed', + 'sub:calendar.era.changed', + 'sub:calendar.moon.phase_changed', + 'struct:calendar.structure.updated', + 'struct:calendar.cycle.changed', + 'struct:calendar.festival.changed', + ]); +}); + +test('sub-resource messages are suppressed while calendar sync is paused', async () => { + reset(); + const seen = []; + const cs = makeSync({ + _calendarSyncDisabled: true, + _onChronicleWeatherChanged: async () => seen.push('weather'), + _onChronicleSubresourceChanged: async (t) => seen.push(t), + }); + await cs.onMessage({ type: 'calendar.weather.changed', payload: { preset_label: 'Clear' } }); + await cs.onMessage({ type: 'calendar.season.changed', payload: { name: 'Spring' } }); + assert.deepEqual(seen, [], 'a paused module must not apply sub-resource state'); +}); + +test('structure signals are processed EVEN WHILE PAUSED — the only recovery path', async () => { + reset(); + let called = 0; + const cs = makeSync({ + _calendarSyncDisabled: true, + _onChronicleStructureUpdated: async () => { called += 1; }, + }); + await cs.onMessage({ type: 'calendar.structure.updated', payload: null }); + assert.equal(called, 1, 'structure.updated must run ahead of the pause guard'); +}); + +test('sub-resource routing is off entirely when syncCalendar is disabled', async () => { + reset(); + settingValues.syncCalendar = false; + let called = 0; + const cs = makeSync({ _onChronicleWeatherChanged: async () => { called += 1; } }); + await cs.onMessage({ type: 'calendar.weather.changed', payload: { preset_label: 'Clear' } }); + assert.equal(called, 0); +}); + +// ── 2. dm_only: announcements never reach players ─────────────────────────── + +test('SECURITY: weather announcements are GM whispers, never public chat', async () => { + reset(); + const cs = makeSync({ _calendarModule: 'simple-calendar' }); // no Calendaria setter + await cs._onChronicleWeatherChanged({ + preset_label: 'Unnatural darkness', + zone_name: 'The Sunken Ward', + description: 'dm_only mood — the party has not discovered this zone', + }); + + assert.equal(chatCalls.length, 1, 'exactly one chat line'); + const msg = chatCalls[0]; + assert.ok(Array.isArray(msg.whisper), 'whisper MUST be an array of user ids'); + assert.deepEqual(msg.whisper, ['gm-user-1', 'gm-user-2'], 'whisper targets only GM users'); + assert.ok(msg.whisper.length > 0, 'an empty whisper array is a PUBLIC message in Foundry'); + assert.match(msg.content, /Unnatural darkness/); +}); + +test('SECURITY: no sub-resource branch ever posts an unwhispered ChatMessage', async () => { + reset(); + const cs = makeSync({ _calendarModule: 'simple-calendar' }); + settingValues.calendarAnnounceMoon = true; // turn every announcement on + + await cs._onChronicleWeatherChanged({ preset_label: 'Blood rain' }); + await cs._onChronicleSubresourceChanged('calendar.worldstate.changed', { + date: { year: 1492, month: 3, day: 15 }, moodTint: { color: '#a00', intensity: 0.9 }, + }); + await cs._onChronicleSubresourceChanged('calendar.season.changed', { name: 'The Long Dark' }); + await cs._onChronicleSubresourceChanged('calendar.era.changed', { name: 'Age of Ash' }); + await cs._onChronicleSubresourceChanged('calendar.moon.phase_changed', { + moon_id: 1, moon_name: 'Selûne', phase_name: 'Full', + }); + + assert.equal(chatCalls.length, 5, 'all five announced'); + for (const m of chatCalls) { + assert.ok(Array.isArray(m.whisper) && m.whisper.length > 0, + `unwhispered ChatMessage would be player-visible: ${JSON.stringify(m)}`); + } +}); + +test('SECURITY: chat content is HTML-escaped at the boundary', async () => { + reset(); + const cs = makeSync({ _calendarModule: 'simple-calendar' }); + await cs._onChronicleSubresourceChanged('calendar.season.changed', { + name: '', + }); + assert.equal(chatCalls.length, 1); + assert.doesNotMatch(chatCalls[0].content, / { + reset(); + const cs = makeSync({ _calendarModule: 'simple-calendar' }); + + // Moon is OFF by default — state still updates, chat stays quiet. + await cs._onChronicleSubresourceChanged('calendar.moon.phase_changed', { + moon_id: 1, moon_name: 'Luna', phase_name: 'Full', + }); + assert.equal(chatCalls.length, 0, 'moon announcements are off by default'); + assert.equal(cs._subresourceState.moons['1'].phase, 'Full', 'but the dashboard panel still updates'); + + // Season/era ON by default. + await cs._onChronicleSubresourceChanged('calendar.season.changed', { name: 'Spring' }); + assert.equal(chatCalls.length, 1); + + // Turn season/era off; nothing more posts. + settingValues.calendarAnnounceSeasonEra = false; + await cs._onChronicleSubresourceChanged('calendar.era.changed', { name: 'Fifth Age' }); + assert.equal(chatCalls.length, 1); + assert.equal(cs._subresourceState.era.name, 'Fifth Age', 'panel updates regardless of the chat toggle'); +}); + +// ── weather: Calendaria apply vs chat fallback ────────────────────────────── + +test('weather is applied via a Calendaria setter when one exists — no chat line', async () => { + reset(); + const applied = []; + globalThis.CALENDARIA = { api: { setWeather: (d) => { applied.push(d); } } }; + const cs = makeSync({ _calendarModule: 'calendaria' }); + await cs._onChronicleWeatherChanged({ preset_label: 'Heavy snow', temperature_celsius: -8 }); + delete globalThis.CALENDARIA; + + assert.equal(applied.length, 1, 'handed to Calendaria'); + assert.equal(applied[0].label, 'Heavy snow'); + assert.equal(applied[0].temperature, -8); + assert.equal(chatCalls.length, 0, 'no duplicate chat line once applied to the module'); +}); + +test('weather degrades to chat when Calendaria exposes no setter', async () => { + reset(); + globalThis.CALENDARIA = { api: { getCurrentWeather: () => ({}) } }; // reads only + const cs = makeSync({ _calendarModule: 'calendaria' }); + await cs._onChronicleWeatherChanged({ preset_label: 'Heavy snow' }); + delete globalThis.CALENDARIA; + + assert.equal(chatCalls.length, 1, 'read-only Calendaria falls back to chat'); + assert.match(chatCalls[0].content, /Heavy snow/); +}); + +test('a failing Calendaria setter degrades to chat rather than losing the update', async () => { + reset(); + globalThis.CALENDARIA = { api: { setWeather: () => { throw new Error('boom'); } } }; + const cs = makeSync({ _calendarModule: 'calendaria' }); + await cs._onChronicleWeatherChanged({ preset_label: 'Hail' }); + delete globalThis.CALENDARIA; + + assert.equal(chatCalls.length, 1, 'the update must survive an apply failure'); +}); + +test('a null weather payload (zone-change ping) refetches GET /calendar/weather', async () => { + reset(); + const gets = []; + const cs = makeSync({ + _calendarModule: 'simple-calendar', + _api: { + get: async (p) => { + gets.push(p); + // The nested Weather model shape the REST endpoint returns. + return { preset_label: 'Sandstorm', wind: { speed_tier: 'gale' }, zone_name: 'Waste' }; + }, + }, + }); + await cs._onChronicleWeatherChanged(null); + + assert.deepEqual(gets, ['/calendar/weather'], 'the zone ping must refetch, not no-op'); + assert.equal(chatCalls.length, 1); + assert.match(chatCalls[0].content, /Sandstorm/); + assert.match(chatCalls[0].content, /Waste/); +}); + +test('a null weather payload with a failing refetch is a quiet no-op', async () => { + reset(); + const cs = makeSync({ + _calendarModule: 'simple-calendar', + _api: { get: async () => { throw new Error('502'); } }, + }); + await cs._onChronicleWeatherChanged(null); + assert.equal(chatCalls.length, 0); + assert.equal(cs._syncDepth, 0, 'the reentrant guard must unwind even on the error path'); +}); + +// ── 3. structure.updated → re-compare, never auto-apply ───────────────────── + +test('structure.updated re-runs the comparison and pauses on a NEW incompatibility', async () => { + reset(); + let structureWrites = 0; + const cs = makeSync({ + _api: { get: async () => CHRONICLE_12x7 }, + _readActiveFoundryStructure: () => FOUNDRY_15x6, + // Any call to the date/structure writers would be an auto-apply. + _setLocalDate: async () => { structureWrites += 1; return true; }, + }); + await cs._onChronicleStructureUpdated('calendar.structure.updated'); + + assert.equal(cs._calendarSyncDisabled, true, 'a now-incompatible structure must pause'); + assert.match(cs._calendarMismatchDetail, /12mo\/7wd/); + assert.equal(cs._structureChangedDetail, null, 'the advisory does not co-exist with a pause'); + assert.equal(structureWrites, 0, 'NO auto-apply — the Foundry calendar is never rewritten'); + assert.ok(notices.some((n) => n.level === 'warn'), 'the operator is warned once'); +}); + +test('structure.updated sets the advisory badge when the re-compare stays compatible', async () => { + reset(); + const cs = makeSync({ + _api: { get: async () => CHRONICLE_12x7 }, + _readActiveFoundryStructure: () => FOUNDRY_12x7, + }); + await cs._onChronicleStructureUpdated('calendar.structure.updated'); + + assert.equal(cs._calendarSyncDisabled, false, 'a compatible structure must not pause'); + assert.match(cs._structureChangedDetail, /still compatible/); + assert.match(cs._structureChangedDetail, /NOT modified/, 'the detail states no write happened'); +}); + +test('structure.updated CLEARS a prior mismatch pause once the structures match again', async () => { + reset(); + const cs = makeSync({ + _calendarSyncDisabled: true, + _calendarMismatchDetail: 'Chronicle: Harptos 12mo/7wd · Foundry: Therin 15mo/6wd — month count', + _api: { get: async () => CHRONICLE_12x7 }, + _readActiveFoundryStructure: () => FOUNDRY_12x7, + }); + await cs._onChronicleStructureUpdated('calendar.structure.updated'); + + assert.equal(cs._calendarSyncDisabled, false, 'the pause must lift when its cause is gone'); + assert.equal(cs._calendarMismatchDetail, null); + assert.ok(notices.some((n) => n.level === 'info' && /resumed/.test(n.m)), 'the GM is told sync resumed'); +}); + +test('structure.updated fails OPEN when either structure is unreadable', async () => { + reset(); + // Foundry side unreadable. + const a = makeSync({ + _api: { get: async () => CHRONICLE_12x7 }, + _readActiveFoundryStructure: () => null, + }); + await a._onChronicleStructureUpdated('calendar.structure.updated'); + assert.equal(a._calendarSyncDisabled, false, 'unreadable Foundry structure must not pause'); + assert.equal(a._structureChangedDetail, null, 'and must not claim a verdict either'); + + // Chronicle side unreadable. + const b = makeSync({ + _api: { get: async () => ({ name: 'degraded', months: [] }) }, + _readActiveFoundryStructure: () => FOUNDRY_12x7, + }); + await b._onChronicleStructureUpdated('calendar.structure.updated'); + assert.equal(b._calendarSyncDisabled, false); + assert.equal(b._structureChangedDetail, null); +}); + +test('structure re-compare survives a /calendar fetch failure using the cached structure', async () => { + reset(); + const cs = makeSync({ + _chronicleCalendar: CHRONICLE_12x7, + _api: { get: async () => { throw new Error('offline'); } }, + _readActiveFoundryStructure: () => FOUNDRY_12x7, + }); + await cs._onChronicleStructureUpdated('calendar.cycle.changed'); + assert.match(cs._structureChangedDetail, /calendar\.cycle\.changed/); + assert.equal(cs._syncDepth, 0); +}); + +// ── 4. default: log-once for unhandled calendar.* types ───────────────────── + +test('an unhandled calendar.* type logs exactly once per session', async () => { + reset(); + const logs = []; + const orig = console.debug; + console.debug = (m) => logs.push(String(m)); + try { + const cs = makeSync(); + await cs.onMessage({ type: 'calendar.weather.zones.changed', payload: null }); + await cs.onMessage({ type: 'calendar.weather.zones.changed', payload: null }); + await cs.onMessage({ type: 'calendar.weather.zones.changed', payload: null }); + } finally { + console.debug = orig; + } + const hits = logs.filter((l) => l.includes('calendar.weather.zones.changed')); + assert.equal(hits.length, 1, 'one debug line per type per session, not per broadcast'); + assert.match(hits[0], /unhandled calendar WebSocket type/); +}); + +test('non-calendar traffic never logs (every module sees every message)', async () => { + reset(); + const logs = []; + const orig = console.debug; + console.debug = (m) => logs.push(String(m)); + try { + const cs = makeSync(); + await cs.onMessage({ type: 'entity.updated', payload: {} }); + await cs.onMessage({ type: 'map.created', payload: {} }); + await cs.onMessage({ type: 'note.deleted', payload: {} }); + } finally { + console.debug = orig; + } + assert.deepEqual(logs.filter((l) => l.includes('unhandled calendar')), []); +}); + +test('routed types never fall into the unhandled log', async () => { + reset(); + const logs = []; + const orig = console.debug; + console.debug = (m) => logs.push(String(m)); + try { + const cs = makeSync({ + _onChronicleWeatherChanged: async () => {}, + _onChronicleSubresourceChanged: async () => {}, + _onChronicleStructureUpdated: async () => {}, + _onChronicaleDateAdvanced: async () => {}, + _onChronicleEventCreated: async () => {}, + _onChronicleEventUpdated: async () => {}, + _onChronicleEventDeleted: async () => {}, + }); + for (const type of [ + 'calendar.date.advanced', 'calendar.event.created', 'calendar.weather.changed', + 'calendar.season.changed', 'calendar.era.changed', 'calendar.moon.phase_changed', + 'calendar.worldstate.changed', 'calendar.structure.updated', + ]) { + await cs.onMessage({ type, payload: null }); + } + } finally { + console.debug = orig; + } + assert.deepEqual(logs.filter((l) => l.includes('unhandled calendar')), []); +}); + +// ── reentrancy discipline ─────────────────────────────────────────────────── + +test('every sub-resource handler unwinds the reentrant _syncDepth guard', async () => { + reset(); + const cs = makeSync({ _calendarModule: 'simple-calendar' }); + await cs._onChronicleWeatherChanged({ preset_label: 'Clear' }); + await cs._onChronicleSubresourceChanged('calendar.season.changed', { name: 'Spring' }); + await cs._onChronicleStructureUpdated('calendar.structure.updated'); + assert.equal(cs._syncDepth, 0, 'a leaked depth would mask every later local hook'); + assert.equal(cs._syncing, false); +}); diff --git a/tools/test-calendar-subresources.mjs b/tools/test-calendar-subresources.mjs new file mode 100644 index 0000000..001c559 --- /dev/null +++ b/tools/test-calendar-subresources.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +/** + * test-calendar-subresources.mjs — FM-SYNC-SUBRESOURCES-P1. + * + * Pins the PURE half of the sub-resource arc: payload normalization, the + * GM-facing one-liners, the announce-gating map, and the snapshot reducer the + * dashboard's world-state panel renders. No Foundry globals needed. + * + * The payload shapes asserted here are transcribed from Chronicle main + * (2026-07-25) — `internal/plugins/calendar/service.go` + + * `worldstate_service.go`. If Chronicle changes a payload, these tests are the + * tripwire. + * + * Run: node --test tools/test-calendar-subresources.mjs + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + ROUTED_CALENDAR_TYPES, + STRUCTURE_SIGNAL_TYPES, + announceSettingFor, + emptySubresourceState, + formatEraLine, + formatMoonLine, + formatSeasonLine, + formatSubresourceLine, + formatWeatherLine, + formatWorldstateLine, + normalizeWeather, + projectSubresourcePanel, + reduceSubresourceState, +} from '../scripts/_calendar-subresources.mjs'; + +// ── normalizeWeather: both wire shapes ─────────────────────────────────────── + +test('normalizeWeather reads the FLAT WeatherInput shape the WS payload carries', () => { + // service.go:1333 publishes the merged WeatherInput — flat snake_case. + const w = normalizeWeather({ + preset_id: 'heavy-snow', + preset_label: 'Heavy snow', + temperature_celsius: -8, + wind_speed_kph: 45, + wind_speed_tier: 'gale', + wind_direction: 'north', + precipitation_type: 'snow', + precipitation_intensity: 0.9, + zone_name: 'North Reach', + }); + assert.equal(w.presetLabel, 'Heavy snow'); + assert.equal(w.temperatureC, -8); + assert.equal(w.windSpeedKph, 45); + assert.equal(w.windTier, 'gale'); + assert.equal(w.precipType, 'snow'); + assert.equal(w.zoneName, 'North Reach'); +}); + +test('normalizeWeather also reads the NESTED Weather shape GET /calendar/weather returns', () => { + // The zone-change refetch path hits GET /calendar/weather, which serializes + // the Weather model with nested wind/precipitation objects. Both shapes must + // render identically or the refetch fallback would look like a different bug. + const w = normalizeWeather({ + preset_label: 'Heavy snow', + temperature_celsius: -8, + wind: { speed_kph: 45, speed_tier: 'gale', direction: 'north' }, + precipitation: { type: 'snow', intensity: 0.9 }, + zone_name: 'North Reach', + }); + assert.equal(w.windSpeedKph, 45); + assert.equal(w.windTier, 'gale'); + assert.equal(w.windDirection, 'north'); + assert.equal(w.precipType, 'snow'); + assert.equal(w.precipIntensity, 0.9); +}); + +test('normalizeWeather returns null for null/empty payloads (the zone-ping case)', () => { + assert.equal(normalizeWeather(null), null); + assert.equal(normalizeWeather(undefined), null); + assert.equal(normalizeWeather({}), null); + // An icon alone is not renderable content. + assert.equal(normalizeWeather({ icon: 'snowflake' }), null); +}); + +test('normalizeWeather does not turn a missing temperature into 0°C', () => { + // Number(null) === 0 — the trap that would print "0°C" for "unknown". + const w = normalizeWeather({ preset_label: 'Clear', temperature_celsius: null }); + assert.equal(w.temperatureC, null); + // But a real zero survives. + assert.equal(normalizeWeather({ temperature_celsius: 0 }).temperatureC, 0); +}); + +// ── formatWeatherLine ──────────────────────────────────────────────────────── + +test('formatWeatherLine renders the full reading with its zone', () => { + const line = formatWeatherLine(normalizeWeather({ + preset_label: 'Heavy snow', + temperature_celsius: -8, + wind_speed_tier: 'gale', + wind_direction: 'north', + precipitation_type: 'snow', + zone_name: 'North Reach', + })); + assert.match(line, /^Chronicle weather: /); + assert.match(line, /Heavy snow/); + assert.match(line, /-8°C/); + assert.match(line, /gale from north/); + assert.match(line, /— North Reach$/); +}); + +test('formatWeatherLine degrades to the fields that are present', () => { + const line = formatWeatherLine(normalizeWeather({ preset_label: 'Clear' })); + assert.equal(line, 'Chronicle weather: Clear'); +}); + +test('formatWeatherLine returns null for a null record', () => { + assert.equal(formatWeatherLine(null), null); +}); + +// ── worldstate / season / era / moon ───────────────────────────────────────── + +test('formatWorldstateLine renders the date + mood tint the payload actually carries', () => { + // worldstate_service.go:265 — {date:{y,m,d}, moodTint:{color,intensity}}. + // NOTE: no celestial/meteor detail is present in the payload. The line must + // not invent one. See the file header + PR body for the Chronicle-side gap. + const line = formatWorldstateLine({ + date: { year: 1492, month: 3, day: 15 }, + moodTint: { color: '#8844aa', intensity: 0.4 }, + }); + assert.match(line, /1492-03-15/); + assert.match(line, /mood #8844aa/); + assert.doesNotMatch(line, /meteor|eclipse/i); +}); + +test('formatWorldstateLine returns null for an empty payload', () => { + assert.equal(formatWorldstateLine(null), null); + assert.equal(formatWorldstateLine({}), null); +}); + +test('formatSeasonLine handles the documented null payload as a real state', () => { + // service.go:2544 publishes season.changed with a NULL payload when the date + // leaves a season without entering another. That is information, not noise. + assert.equal(formatSeasonLine({ name: 'Deepwinter' }), 'Chronicle season: Deepwinter'); + assert.match(formatSeasonLine(null), /no season currently in effect/); +}); + +test('formatEraLine renders a named era and nothing otherwise', () => { + assert.equal(formatEraLine({ name: 'Third Age' }), 'Chronicle era: Third Age'); + assert.equal(formatEraLine(null), null); + assert.equal(formatEraLine({ name: ' ' }), null); +}); + +test('formatMoonLine uses the snake_case fields Chronicle publishes', () => { + const line = formatMoonLine({ + moon_id: 3, moon_name: 'Selûne', phase_name: 'Waxing Gibbous', phase_position: 0.4, + }); + assert.equal(line, 'Chronicle moon: Selûne is now Waxing Gibbous'); +}); + +test('formatMoonLine survives a partial moon payload', () => { + assert.equal(formatMoonLine({ moon_name: 'Selûne' }), 'Chronicle moon: Selûne phase changed'); + assert.equal(formatMoonLine(null), null); +}); + +// ── formatSubresourceLine dispatch ─────────────────────────────────────────── + +test('formatSubresourceLine dispatches every announcing type and nothing else', () => { + assert.match( + formatSubresourceLine('calendar.weather.changed', { preset_label: 'Clear' }), + /Chronicle weather/, + ); + assert.match(formatSubresourceLine('calendar.season.changed', { name: 'Spring' }), /season/); + assert.match(formatSubresourceLine('calendar.era.changed', { name: 'Fourth Age' }), /era/); + assert.match( + formatSubresourceLine('calendar.moon.phase_changed', { moon_name: 'Luna', phase_name: 'Full' }), + /moon/, + ); + // structure.updated never announces — it drives the badge, not chat. + assert.equal(formatSubresourceLine('calendar.structure.updated', null), null); + assert.equal(formatSubresourceLine('entity.updated', {}), null); +}); + +// ── announce gating ────────────────────────────────────────────────────────── + +test('announceSettingFor maps each announcing type to its world setting', () => { + assert.equal(announceSettingFor('calendar.weather.changed'), 'calendarAnnounceWeather'); + assert.equal(announceSettingFor('calendar.worldstate.changed'), 'calendarAnnounceWorldstate'); + assert.equal(announceSettingFor('calendar.season.changed'), 'calendarAnnounceSeasonEra'); + assert.equal(announceSettingFor('calendar.era.changed'), 'calendarAnnounceSeasonEra'); + assert.equal(announceSettingFor('calendar.moon.phase_changed'), 'calendarAnnounceMoon'); +}); + +test('announceSettingFor returns null for types that must never reach chat', () => { + for (const t of ['calendar.structure.updated', 'calendar.date.advanced', 'calendar.event.created']) { + assert.equal(announceSettingFor(t), null, `${t} must not be announceable`); + } +}); + +// ── routed-type inventory ──────────────────────────────────────────────────── + +test('every Chronicle calendar.* type is claimed as routed (no silent drops)', () => { + // The eleven types Chronicle's publisher adapter can emit + // (internal/app/routes.go PublishCalendarEvent switch) plus worldstate. + const chronicleEmits = [ + 'calendar.event.created', 'calendar.event.updated', 'calendar.event.deleted', + 'calendar.date.advanced', 'calendar.season.changed', 'calendar.moon.phase_changed', + 'calendar.weather.changed', 'calendar.structure.updated', 'calendar.era.changed', + 'calendar.cycle.changed', 'calendar.festival.changed', 'calendar.worldstate.changed', + ]; + for (const t of chronicleEmits) { + assert.ok(ROUTED_CALENDAR_TYPES.includes(t), `${t} is not routed — it would be silently dropped`); + } +}); + +test('structure signals are the three that fire together from the same service call', () => { + assert.deepEqual([...STRUCTURE_SIGNAL_TYPES].sort(), [ + 'calendar.cycle.changed', 'calendar.festival.changed', 'calendar.structure.updated', + ]); +}); + +// ── reducer ────────────────────────────────────────────────────────────────── + +test('reducer folds weather, season, era and multiple moons into one snapshot', () => { + let s = emptySubresourceState(); + s = reduceSubresourceState(s, 'calendar.weather.changed', { preset_label: 'Fog', temperature_celsius: 4 }); + s = reduceSubresourceState(s, 'calendar.season.changed', { name: 'Autumn', color: '#c60' }); + s = reduceSubresourceState(s, 'calendar.era.changed', { name: 'Fourth Age' }); + s = reduceSubresourceState(s, 'calendar.moon.phase_changed', { moon_id: 1, moon_name: 'Selûne', phase_name: 'Full' }); + s = reduceSubresourceState(s, 'calendar.moon.phase_changed', { moon_id: 2, moon_name: 'Tears', phase_name: 'New' }); + + assert.equal(s.weather.presetLabel, 'Fog'); + assert.equal(s.season.name, 'Autumn'); + assert.equal(s.era.name, 'Fourth Age'); + assert.equal(Object.keys(s.moons).length, 2, 'two moons must coexist, not overwrite'); + assert.equal(s.moons['1'].phase, 'Full'); + assert.equal(s.moons['2'].phase, 'New'); +}); + +test('reducer never mutates the previous state object', () => { + const a = emptySubresourceState(); + const b = reduceSubresourceState(a, 'calendar.moon.phase_changed', { moon_id: 1, moon_name: 'Luna', phase_name: 'Full' }); + assert.deepEqual(a.moons, {}, 'prev.moons must not gain the new moon'); + assert.notEqual(a, b); +}); + +test('a null weather payload PRESERVES the last reading rather than blanking it', () => { + // The weather-zone paths (service.go:1490, :1523) publish weather.changed with + // a null payload. Treating that as "weather unknown" would wipe a good reading + // off the dashboard on every zone edit. + let s = reduceSubresourceState(emptySubresourceState(), 'calendar.weather.changed', { preset_label: 'Clear' }); + s = reduceSubresourceState(s, 'calendar.weather.changed', null); + assert.equal(s.weather.presetLabel, 'Clear'); + assert.match(s.weatherLine, /Clear/); +}); + +test('a null season payload DOES clear the season — it is a meaningful state', () => { + let s = reduceSubresourceState(emptySubresourceState(), 'calendar.season.changed', { name: 'Summer' }); + s = reduceSubresourceState(s, 'calendar.season.changed', null); + assert.equal(s.season, null); +}); + +// ── panel projection ───────────────────────────────────────────────────────── + +test('projectSubresourcePanel reports has:false before anything arrives', () => { + const p = projectSubresourcePanel(emptySubresourceState()); + assert.equal(p.has, false); + assert.deepEqual(p.moons, []); + assert.equal(projectSubresourcePanel(null).has, false); +}); + +test('projectSubresourcePanel flattens the snapshot with moons sorted by name', () => { + let s = emptySubresourceState(); + s = reduceSubresourceState(s, 'calendar.moon.phase_changed', { moon_id: 1, moon_name: 'Zephyr', phase_name: 'New' }); + s = reduceSubresourceState(s, 'calendar.moon.phase_changed', { moon_id: 2, moon_name: 'Alba', phase_name: 'Full' }); + s = reduceSubresourceState(s, 'calendar.season.changed', { name: 'Winter' }); + const p = projectSubresourcePanel(s); + assert.equal(p.has, true); + assert.equal(p.seasonName, 'Winter'); + assert.deepEqual(p.moons.map((m) => m.name), ['Alba', 'Zephyr']); +}); + +test('projectSubresourcePanel still shows after a season.changed(null) clears the season', () => { + // has:false must mean "nothing arrived", not "the arriving value was null" — + // otherwise the panel vanishes exactly when it has news to report. + let s = reduceSubresourceState(emptySubresourceState(), 'calendar.season.changed', null); + const p = projectSubresourcePanel(s); + assert.equal(p.has, true); + assert.equal(p.seasonName, null); +}); diff --git a/tools/test-calendar-sync-state.mjs b/tools/test-calendar-sync-state.mjs index 0d1a8fd..f835856 100644 --- a/tools/test-calendar-sync-state.mjs +++ b/tools/test-calendar-sync-state.mjs @@ -150,17 +150,75 @@ test('in-sync also holds when structures were simply not comparable but dates ma assert.equal(r.state, 'in-sync'); }); -// ── all four states are reachable (the honest-badge contract) ───────────────── +// ── structure-changed (FM-SYNC-SUBRESOURCES-P1) ────────────────────────────── -test('the classifier yields exactly the four documented states across inputs', () => { +test('structure-changed advisory raises even when the dates agree', () => { + // The whole point: without this state the badge would say a confident + // "In Sync" seconds after Chronicle re-shaped the calendar underneath it. + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true, detail: '' }, + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 15), + structureChangedDetail: 'Chronicle\'s calendar structure changed — re-compared: still compatible.', + }); + assert.equal(r.state, 'structure-changed'); + assert.equal(r.direction, null); + assert.match(r.detail, /still compatible/); +}); + +test('structure-changed is outranked by paused and by incompatible-structures', () => { + // Both describe actual breakage; the advisory must never mask them. + const paused = classifyCalendarSyncState({ + paused: true, + pausedDetail: 'month count', + structureChangedDetail: 'advisory', + }); + assert.equal(paused.state, 'paused'); + + const incompatible = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: false, detail: 'weekday count' }, + structureChangedDetail: 'advisory', + }); + assert.equal(incompatible.state, 'incompatible-structures'); +}); + +test('structure-changed outranks date-drift', () => { + // "The structure moved under you" is the bigger fact than a one-day delta. + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true }, + chronicleDate: D(1492, 3, 15), + foundryDate: D(1492, 3, 14), + structureChangedDetail: 'advisory', + }); + assert.equal(r.state, 'structure-changed'); +}); + +test('a null structureChangedDetail leaves the pre-existing states untouched', () => { + const r = classifyCalendarSyncState({ + paused: false, + structureCmp: { match: true }, + chronicleDate: D(1, 1, 1), + foundryDate: D(1, 1, 1), + structureChangedDetail: null, + }); + assert.equal(r.state, 'in-sync'); +}); + +// ── all five states are reachable (the honest-badge contract) ───────────────── + +test('the classifier yields exactly the five 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, 1), structureChangedDetail: 'advisory' }).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'], + ['date-drift', 'in-sync', 'incompatible-structures', 'paused', 'structure-changed'], ); });