feat(calendar): land weather/season/era/moon/structure sub-resources in Foundry (FM-SYNC-SUBRESOURCES-P1) - #82
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, mergedcaad6c4)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
mainthis 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.gocalendarEventPublisherAdapter.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 nodefault: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-unhandledcalendar.*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:
calendar-sync.mjs:396-408:396-409oncaad6c4— R1 left it untouched; now:464-515after this PRscripts/_calendar-sync-state.mjs, 4 states, consumed atsync-dashboard.mjs:708-752_calendarSyncDisabledcalendar-sync.mjs:599oncaad6c4,:728after this PR), read atsync-dashboard.mjs:683🔴 BLOCKER on scope item 2:
calendar.worldstate.changednever reaches the wireThree independent Chronicle-side blockers. The dispatch assumed this event was reaching the module; it is not, and has never been.
worldstate_service.go:265callsPublishCalendarEvent("calendar.worldstate.changed", …), but the adapter that maps internal event names tows.MessageType—calendarEventPublisherAdapter.PublishCalendarEventininternal/app/routes.go— has nocasefor it, so it hitsdefault: return.calendar.weather.zones.changed(service.go:1488) is dropped identically. Both are publisher-side dead letters.{date:{year,month,day}, moodTint:{color,intensity}}— the meteor/eclipse rows incalendar_celestial_eventsthat motivated the dispatch are not in it. A "meteor shower" announcement cannot be built from this payload.GET /calendar/world-stateis registered on the web plugin group (internal/plugins/calendar/routes.go:157), not the Bearer-tokensyncapigroup, 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
caseinroutes.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) andsync-calendar-diagnostics.mjs:133-150. The probed methods aregetWeatherForDate,getCurrentWeather,getAllMoonPhases— all 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,
_applyWeatherToCalendariaprobessetWeather/setCurrentWeather/setWeatherForDateand returnsfalsewhen 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
payload: nullby 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/festivalare alwaysnull. A handler that treats null as malformed re-creates the silent drop it was meant to fix.WeatherInput(wind_speed_kph,precipitation_type); a refetch ofGET /calendar/weatherreturns the nestedWeathermodel (wind:{speed_kph},precipitation:{type}).normalizeWeatheraccepts 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 thedefault:branch checks against.normalizeWeather— accepts both weather shapes; returnsnullwhen nothing is renderable. Explicit null/''rejection innum()so a missing temperature never prints as0°C(Number(null) === 0).formatWeatherLine/formatWorldstateLine/formatSeasonLine/formatEraLine/formatMoonLine/formatSubresourceLine.announceSettingFor— type → world-setting key;nullfor types that must never reach chat.emptySubresourceState/reduceSubresourceState/projectSubresourcePanel.scripts/calendar-sync.mjs— +399onMessage(:464-515) — structure signals routed ahead of the_calendarSyncDisabledguard (rationale below); five newcases; adefault:calling_logUnhandledCalendarType._logUnhandledCalendarType(:530) — oneconsole.debugper type per session, not per broadcast. Non-calendar.*traffic stays silent:SyncManager._routeMessagefans 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) — probesCALENDARIA_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+escapeChatHtmlat:106-129.scripts/_calendar-sync-state.mjs— 5th badge statestructure-changed, ranked belowpausedandincompatible-structures(both describe real breakage — the advisory must never mask them) and abovedate-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.cssNew "Chronicle world state" panel on the Calendar tab (weather / season / era / per-moon phase rows) + the
structure-changedbadge. Read-only projection ofCalendarSync._subresourceState; the dashboard writes nothing back.scripts/settings.mjs·lang/en.jsonFour 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.mdNew "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.mdrecords the three abstention decisions;CLAUDE.mdgets 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
_onChronicleStructureUpdatedclears 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 viaui.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
_announceToGMwhispers toChatMessage.getWhisperRecipients('GM')ids and is the module's only chat surface. Three tests enforce it, including one assertingwhisper.length > 0— an emptywhisperarray 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
755 pass / 0 fail (was 704 — +51 new). Every
scripts/*.mjsalso passesnode --check.New:
tools/test-calendar-subresources.mjs(25) — both weather shapes, the0°Ctrap, 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;_syncDepthunwinds on every path including errors. Plus 4 intools/test-calendar-sync-state.mjsfor the new state's ranking.node tools/check-package-descriptor.mjspassesWhat the coordinator must re-verify on a live client
Headless tests cannot reach Foundry's runtime:
CALENDARIA.api methods available— does the build expose any ofsetWeather/setCurrentWeather/setWeatherForDate? If yes, we pin the real name in a follow-up.calendar.worldstate.changedcannot be verified until Chronicle publishes it (blocker above).Tenet self-check
.ai.md,CLAUDE.mdall updated with the WHY.Stop-and-flag
calendar.worldstate.changed(andcalendar.weather.zones.changed) are publisher-side dead letters in Chronicle — dropped by thedefault: returnincalendarEventPublisherAdapter.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