Skip to content

feat(calendar): land weather/season/era/moon/structure sub-resources in Foundry (FM-SYNC-SUBRESOURCES-P1) - #82

Merged
keyxmakerx merged 1 commit into
mainfrom
claude/foundry-fm-sync-subresources-cgpwo7
Jul 26, 2026
Merged

feat(calendar): land weather/season/era/moon/structure sub-resources in Foundry (FM-SYNC-SUBRESOURCES-P1)#82
keyxmakerx merged 1 commit into
mainfrom
claude/foundry-fm-sync-subresources-cgpwo7

Conversation

@keyxmakerx

Copy link
Copy Markdown
Owner

Cites: dispatches/foundry/FM-SYNC-SUBRESOURCES-P1.md; plans/2026-07-24-calendar-remodel-requirements.md §2 ("Sub-resources: … module handles 4 of 11+ types — all others silently dropped"); rides the WS/initial-sync path revived by FM-SYNC-WIRE-FIX-R1 (PR #81, merged caad6c4)
Security implication: positive, and the load-bearing design decision. Every new chat surface is a GM whisper, never public chat — see "Security: dm_only" below. Chronicle-supplied strings are HTML-escaped at the chat boundary. No new endpoint, no new credential path, no destructive write.
Consumer-verified: payload shapes read from Chronicle main this session — internal/plugins/calendar/service.go:1333 (weather), :2536-2544 (season), :2550-2554 (era), :2564-2569 (moon), :1594-1595 / :1629-1630 (cycle/festival + structure), internal/plugins/calendar/worldstate_service.go:265 (worldstate), internal/websocket/message.go:46-62 (type constants), internal/app/routes.go calendarEventPublisherAdapter.PublishCalendarEvent (name translation), internal/plugins/syncapi/routes.go:171 (GET /calendar/weather)
Foundry compatibility: v12–v14. New runtime surfaces are ChatMessage.create + ChatMessage.getWhisperRecipients (stable across v12–v14) and one Handlebars block in the existing dashboard template. No V1 Application API, no deprecated call added.
Mockup: n/a — the world-state panel reuses the Calendar tab's existing action-hint / badge idiom.

What this changes

Chronicle broadcasts eleven calendar.* WebSocket types. calendar-sync.mjs's handler switch matched four of them — date plus the three event-CRUD types — and had no default: branch, so the other seven fell off the end of the switch and vanished with no console breadcrumb anywhere. That is why the operator's "celestial events unfindable/unsyncable" report had nothing to trace: there was no trace.

This wires the high-value ones through as display-level, non-destructive surfaces — a dashboard world-state panel, GM-only chat whispers, and an honest structure-change badge — and adds a default: that logs any still-unhandled calendar.* type once per session.

Why

plans/2026-07-24-calendar-remodel-requirements.md §2 names this exact gap ("module handles 4 of 11+ types … ONLY date + events sync today"), and §1 records the operator experience it produces: "Calendaria sync flat out doesn't work", "celestial events (meteors) unfindable/unsyncable."


Step 0 findings (mandatory — all re-verified on merged main @ caad6c4)

Anchors moved as predicted

The dispatch flagged that post-R1 line numbers would differ. They do:

Anchor Dispatch said Actual
handler switch calendar-sync.mjs:396-408 :396-409 on caad6c4 — R1 left it untouched; now :464-515 after this PR
R1 badge classifier (new in R1) scripts/_calendar-sync-state.mjs, 4 states, consumed at sync-dashboard.mjs:708-752
pause guard _calendarSyncDisabled set by BOTH module paths post-R1 (calendar-sync.mjs:599 on caad6c4, :728 after this PR), read at sync-dashboard.mjs:683

🔴 BLOCKER on scope item 2: calendar.worldstate.changed never reaches the wire

Three independent Chronicle-side blockers. The dispatch assumed this event was reaching the module; it is not, and has never been.

  1. It is dropped before the bus. worldstate_service.go:265 calls PublishCalendarEvent("calendar.worldstate.changed", …), but the adapter that maps internal event names to ws.MessageTypecalendarEventPublisherAdapter.PublishCalendarEvent in internal/app/routes.go — has no case for it, so it hits default: return. calendar.weather.zones.changed (service.go:1488) is dropped identically. Both are publisher-side dead letters.
  2. The payload carries no celestial detail. Even once published it is {date:{year,month,day}, moodTint:{color,intensity}} — the meteor/eclipse rows in calendar_celestial_events that motivated the dispatch are not in it. A "meteor shower" announcement cannot be built from this payload.
  3. No syncapi read path to enrich from. GET /calendar/world-state is registered on the web plugin group (internal/plugins/calendar/routes.go:157), not the Bearer-token syncapi group, so the module cannot fetch the detail either.

What I did: module repo only is a hard bound, so I did not touch Chronicle. The handler ships wired and tested — it lights up the moment Chronicle closes blocker 1 — and renders exactly what the payload carries, never inventing a celestial event it cannot see. Closing (1) is a one-line case in routes.go; (2) and (3) are real Chronicle work. Coordinator: this needs a Chronicle dispatch.

Calendaria surface inventory (dispatch: "find those call sites and reuse")

Found the read sites at sync-dashboard.mjs:2824-2867 (_buildCalendarDiagnosticsInput) and sync-calendar-diagnostics.mjs:133-150. The probed methods are getWeatherForDate, getCurrentWeather, getAllMoonPhasesall reads. No Calendaria build the module has been pointed at exposes a documented weather setter; Calendaria generates weather from zone/preset tables rather than accepting an assignment.

Rather than hard-code one speculative method name that silently no-ops on every build, _applyWeatherToCalendaria probes setWeather / setCurrentWeather / setWeatherForDate and returns false when none exists, so the caller falls back to chat. I also added those three names to the diagnostics probe list (sync-dashboard.mjs:2837-2842), so an operator on a build that does expose one shows up in their bug report and we can pin the real name in a follow-up instead of guessing now.

SimpleCalendar has no weather surface at all → chat fallback, as the dispatch anticipated.

Payload shapes — two traps worth naming

  • Four of the eight types can arrive with payload: null by design. season.changed(null) means "left a season without entering another" (service.go:2544); weather.changed(null) is the weather-zone paths' "refetch me" ping (:1490, :1523); structure/cycle/festival are always null. A handler that treats null as malformed re-creates the silent drop it was meant to fix.
  • Weather arrives in two different shapes. The WS payload is the flat merged 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 renders identically to the push path.

Files touched

scripts/_calendar-subresources.mjs — NEW (pure, 383 lines)

The pure half: payload normalization, the GM-facing one-liners, the announce-gating map, and the reducer maintaining the dashboard snapshot. No Foundry globals, so it is unit-testable off-DOM (house pattern: _overview-model.mjs, _calendar-sync-state.mjs). The file header carries the full verified payload table.

  • ROUTED_CALENDAR_TYPES / STRUCTURE_SIGNAL_TYPES — the inventory the default: branch checks against.
  • normalizeWeather — accepts both weather shapes; returns null when nothing is renderable. Explicit null/'' rejection in num() so a missing temperature never prints as 0°C (Number(null) === 0).
  • formatWeatherLine / formatWorldstateLine / formatSeasonLine / formatEraLine / formatMoonLine / formatSubresourceLine.
  • announceSettingFor — type → world-setting key; null for types that must never reach chat.
  • emptySubresourceState / reduceSubresourceState / projectSubresourcePanel.

scripts/calendar-sync.mjs — +399

  • onMessage (:464-515) — structure signals routed ahead of the _calendarSyncDisabled guard (rationale below); five new cases; a default: calling _logUnhandledCalendarType.
  • _logUnhandledCalendarType (:530) — one console.debug per type per session, not per broadcast. Non-calendar.* traffic stays silent: SyncManager._routeMessage fans every message to every module, so entity/map/note traffic reaching CalendarSync is normal, not a gap.
  • _announceToGM (:865) — the module's only chat surface. Whispers to GM user ids.
  • _onChronicleWeatherChanged (:919) — refetches on a null payload, tries the Calendaria setter, falls back to chat.
  • _applyWeatherToCalendaria (:967) — probes CALENDARIA_WEATHER_SETTERS; a throw degrades to chat rather than losing the update.
  • _onChronicleSubresourceChanged (:1015) — worldstate/season/era/moon: fold + optional whisper.
  • _onChronicleStructureUpdated (:1057) — refetch /calendar, re-compare, badge. Detailed below.
  • CALENDARIA_WEATHER_SETTERS + escapeChatHtml at :106-129.

scripts/_calendar-sync-state.mjs — 5th badge state

structure-changed, ranked below paused and incompatible-structures (both describe real breakage — the advisory must never mask them) and above date-drift ("the structure moved under you" is the bigger fact than a one-day delta).

scripts/sync-dashboard.mjs · templates/sync-dashboard.hbs · styles/chronicle-sync.css

New "Chronicle world state" panel on the Calendar tab (weather / season / era / per-moon phase rows) + the structure-changed badge. Read-only projection of CalendarSync._subresourceState; the dashboard writes nothing back.

scripts/settings.mjs · lang/en.json

Four world toggles: calendarAnnounceWeather (on), calendarAnnounceWorldstate (on), calendarAnnounceSeasonEra (on), calendarAnnounceMoon (off — a moon crosses a phase boundary every few in-world days and would flood the log; the dashboard panel shows phases regardless). Lang keys hand-edited, not reserialized, so the diff is +20/-1 with no whitespace churn.

API-CONTRACT.md · .ai.md · CLAUDE.md

New "What the module does with each calendar.* type" table (type → behavior → Calendaria → SimpleCalendar), the corrected payload column (null cases, flat-vs-nested weather), and the full worldstate gap trace. .ai.md records the three abstention decisions; CLAUDE.md gets the convention + the blocked-on-Chronicle TODO.


Three deliberate abstentions (honest deviation reporting)

1. No structure auto-apply. The dispatch says do not auto-apply and I agree emphatically: rewriting the Foundry calendar's months/weekdays from a broadcast is the most destructive write available to this module, because Calendaria stores notes against month/day coordinates — re-shaping the calendar silently re-dates every note in the world. The handler re-compares and badges.

2. No auto-created celestial note (dispatch: "if Calendaria supports a note/banner, a same-day note"). Skipped, for two reasons found in Step 0: the payload carries no celestial detail to put in a note, 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. Revisit once Chronicle ships celestial detail on the wire.

3. Weather setter is probed, not pinned — see the Calendaria inventory above.

One addition beyond the dispatch: the pause can now be CLEARED

_onChronicleStructureUpdated clears a prior structure-mismatch pause when the re-compare comes back compatible. The dispatch asked only for "pause if now incompatible", but the asymmetry would be a bug: the operator's documented remedy for a mismatch pause is "import or author the matching calendar in Chronicle, then reload the world", and doing so is precisely what emits this broadcast. Without the clear, the pause outlives its own cause until a world reload — the same dead-on-arrival shape FM-SYNC-WIRE-FIX just removed from initial sync. This is also why structure signals are routed ahead of the pause guard: behind it, the one message that can lift the pause would be suppressed by the pause. Non-destructive (it re-enables sync, it does not write), notified once via ui.notifications.info, and pinned by a test.


Security: dm_only respected

Chronicle gates dm_only traffic server-side (Message.RequiresDM, internal/websocket/hub.go:159-167), so a payload reaching 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 what 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: the exact leak class cordinator#32 hardened against on the Chronicle side.

So _announceToGM whispers to ChatMessage.getWhisperRecipients('GM') ids and is the module's only chat surface. Three tests enforce it, including one asserting whisper.length > 0an empty whisper array is a public message in Foundry, which is the way this could silently regress. Chronicle strings are HTML-escaped at the boundary (FM-SEC-CHUNK-3 discipline), pinned by an XSS test.


Test plan

$ node --test tools/test-*.mjs
# tests 755   # pass 755   # fail 0

$ node tools/check-package-descriptor.mjs
chronicle-package.json: OK (0 warnings)

755 pass / 0 fail (was 704 — +51 new). Every scripts/*.mjs also passes node --check.

New: tools/test-calendar-subresources.mjs (25) — both weather shapes, the 0°C trap, null-payload semantics per type, reducer immutability, multi-moon coexistence, panel projection. tools/test-calendar-subresource-routing.mjs (22) — all twelve types route; paused suppresses sub-resources but not structure; the three dm_only/XSS pins; setter-applied vs chat-fallback vs setter-throws; null-payload refetch; re-compare pause / un-pause / fail-open / offline; log-once; _syncDepth unwinds on every path including errors. Plus 4 in tools/test-calendar-sync-state.mjs for the new state's ranking.

  • Test suite passes locally
  • node tools/check-package-descriptor.mjs passes
  • Manual verification in Foundry — needed, see below
  • CI passes

What the coordinator must re-verify on a live client

Headless tests cannot reach Foundry's runtime:

  1. Set weather / cross a season or era boundary in Chronicle → GM whisper lands, players see nothing, "Chronicle world state" panel fills.
  2. Edit the calendar structure in Chronicle → badge flips to "Structure Changed — Re-check", and the Foundry calendar is not modified.
  3. Trigger a structure-mismatch pause, fix the calendar in Chronicle → pause clears without a world reload.
  4. Diagnostics bundle → CALENDARIA.api methods available — does the build expose any of setWeather / setCurrentWeather / setWeatherForDate? If yes, we pin the real name in a follow-up.
  5. calendar.worldstate.changed cannot be verified until Chronicle publishes it (blocker above).

Tenet self-check

  • T-B1 security — dm_only handled as a first-class constraint; escaping at the chat boundary; no new auth path.
  • T-B2 plugin isolation — module repo only. The Chronicle-side gap was flagged, not fixed.
  • T-B3 production UI — panel has a populated state and an explicit empty state; badge has a title tooltip; no motion added.
  • T-B4 dual-audience docs — API-CONTRACT.md, .ai.md, CLAUDE.md all updated with the WHY.

Stop-and-flag

calendar.worldstate.changed (and calendar.weather.zones.changed) are publisher-side dead letters in Chronicle — dropped by the default: return in calendarEventPublisherAdapter.PublishCalendarEvent. Scope item 2 of the dispatch cannot land until that is fixed, and a meaningful celestial announcement additionally needs celestial detail on the payload plus a syncapi read path. This is Chronicle work and wants its own dispatch.


Generated by Claude Code

…in Foundry

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8JUBq46gCQ7sem7YcHeaj
@keyxmakerx
keyxmakerx merged commit a29824e into main Jul 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants