{"version":3,"file":"calendaria.mjs","sources":["../scripts/constants.mjs","../scripts/calendar/calendar-loader.mjs","../scripts/data/calendar-note-data-model.mjs","../scripts/utils/calendar-math.mjs","../scripts/utils/permissions.mjs","../scripts/weather/data/weather-presets.mjs","../scripts/weather/data/climate-data.mjs","../scripts/weather/weather-generator.mjs","../scripts/utils/macro-utils.mjs","../scripts/utils/socket.mjs","../scripts/weather/weather-manager.mjs","../scripts/weather/weather-sound.mjs","../scripts/utils/formatting/moon-utils.mjs","../scripts/utils/formatting/format-utils.mjs","../scripts/utils/chat/chat-command-handler.mjs","../scripts/utils/chat/chat-commands.mjs","../scripts/utils/chat/chat-timestamp.mjs","../scripts/utils/fog-of-war.mjs","../scripts/utils/chronicle-data.mjs","../scripts/utils/combat-behavior.mjs","../scripts/utils/eclipse-calculator.mjs","../scripts/time/darkness.mjs","../scripts/time/event-scheduler.mjs","../scripts/time/reminder-scheduler.mjs","../scripts/time/time-tracker.mjs","../scripts/time/time-clock.mjs","../scripts/utils/enrichers.mjs","../scripts/utils/inserts.mjs","../scripts/utils/migrations.mjs","../scripts/utils/patches.mjs","../scripts/notes/event-dependency-resolver.mjs","../scripts/utils/ui/calendar-view-utils.mjs","../scripts/utils/print/calendar-printer.mjs","../scripts/utils/search-manager.mjs","../scripts/festivals/festival-manager.mjs","../scripts/notes/note-manager.mjs","../scripts/notes/note-data.mjs","../scripts/utils/settings-io.mjs","../scripts/utils/ui/journal-button.mjs","../scripts/utils/ui/range-slider.mjs","../scripts/utils/ui/sky-utils.mjs","../scripts/utils/ui/sticky-zones.mjs","../scripts/utils/ui/toolbar-buttons.mjs","../scripts/utils/widget-manager.mjs","../scripts/notes/condition-engine.mjs","../scripts/notes/condition-field-schema.mjs","../scripts/notes/condition-presets.mjs","../scripts/notes/condition-utils.mjs","../scripts/notes/date-utils.mjs","../scripts/notes/note-filter-service.mjs","../scripts/notes/recurrence.mjs","../scripts/data/calendaria-calendar.mjs","../scripts/calendar/calendar-utils.mjs","../scripts/data/condition-data.mjs","../scripts/data/moon-phase-resolver.mjs","../scripts/integrations/luxon-sync.mjs","../scripts/calendar/calendar-manager.mjs","../scripts/calendar/calendar-registry.mjs","../scripts/calendar/date-converter.mjs","../scripts/applications/calendar/big-cal.mjs","../scripts/importers/base-importer.mjs","../scripts/importers/calendaria-importer.mjs","../scripts/importers/calendarium-importer.mjs","../scripts/importers/fantasy-calendar-importer.mjs","../scripts/importers/mini-calendar-importer.mjs","../scripts/importers/seasons-stars-importer.mjs","../scripts/importers/simple-calendar-importer.mjs","../scripts/importers/simple-timekeeping-importer.mjs","../scripts/importers/importer-registry.mjs","../scripts/applications/calendar/calendar-editor.mjs","../scripts/applications/calendar/chronicle.mjs","../scripts/applications/calendar/mini-cal.mjs","../scripts/applications/calendar/note-viewer.mjs","../scripts/applications/calendar/secondary-calendar.mjs","../scripts/applications/cinematics/cinematic-keyframe-builder.mjs","../scripts/applications/cinematics/cinematic-overlay.mjs","../scripts/applications/dialogs/computed-event-builder.mjs","../scripts/applications/dialogs/condition-builder-dialog.mjs","../scripts/applications/dialogs/importer-app.mjs","../scripts/applications/dialogs/set-date-dialog.mjs","../scripts/applications/dialogs/token-reference-dialog.mjs","../scripts/applications/hud/hud-scene-renderer.mjs","../scripts/applications/hud/hud.mjs","../scripts/applications/settings/preset-manager.mjs","../scripts/applications/settings/settings-panel.mjs","../scripts/applications/sheets/calendar-note-sheet.mjs","../scripts/applications/sheets/calendaria-scene-config.mjs","../scripts/applications/time/stopwatch.mjs","../scripts/applications/time/sun-dial.mjs","../scripts/applications/time/time-keeper.mjs","../scripts/applications/weather/climate-editor.mjs","../scripts/integrations/bastion-orders.mjs","../scripts/integrations/chat-commander.mjs","../scripts/integrations/fxmaster.mjs","../scripts/integrations/rest-time.mjs","../scripts/applications/weather/weather-editor.mjs","../scripts/applications/weather/weather-picker.mjs","../scripts/applications/weather/weather-probability-dialog.mjs","../scripts/api.mjs","../dev/quench/index.mjs","../dev/quench/calendar-switching.mjs","../dev/quench/time-advancement.mjs","../dev/quench/weather-generation.mjs","../dev/quench/note-crud.mjs","../dev/quench/darkness-sync.mjs","../dev/quench/api-smoke.mjs","../dev/quench/weather-advanced.mjs","../dev/quench/note-queries.mjs","../dev/quench/note-properties.mjs","../dev/quench/time-day-cycle.mjs","../dev/quench/date-arithmetic.mjs","../dev/quench/moon-phases.mjs","../dev/quench/calendar-metadata.mjs","../dev/quench/format-strings.mjs","../dev/quench/clock-controls.mjs","../dev/quench/hook-events.mjs","../dev/quench/date-math.mjs","../dev/quench/celestial.mjs","../dev/quench/conditions.mjs","../dev/quench/weather-extended.mjs","../dev/quench/note-advanced.mjs","../dev/quench/cinematic.mjs","../scripts/hooks.mjs","../scripts/settings-handler.mjs","../calendaria.mjs","../scripts/utils/keybinds.mjs"],"sourcesContent":["/** @type {object} Module identification */\nexport const MODULE = {\n ID: 'calendaria',\n TITLE: 'Calendaria'\n};\n\n/** @enum {string} Keybinding action IDs */\nexport const KEYBINDS = {\n STOPWATCH_RESET: 'stopwatch-reset',\n STOPWATCH_START_PAUSE: 'stopwatch-start-pause',\n TOGGLE_BIGCAL: 'toggle-bigcal',\n TOGGLE_HUD: 'toggle-hud',\n TOGGLE_MINICAL: 'toggle-minical',\n TOGGLE_STOPWATCH: 'toggle-stopwatch',\n TOGGLE_CHRONICLE: 'toggle-chronicle',\n TOGGLE_NOTEVIEWER: 'toggle-noteviewer',\n TOGGLE_SUNDIAL: 'toggle-sundial',\n TOGGLE_TIMEKEEPER: 'toggle-timekeeper'\n};\n\n/** @enum {string} Settings keys for Foundry VTT game settings */\nexport const SETTINGS = {\n ACTIVE_CALENDAR: 'activeCalendar',\n ADVANCE_BASTION_ORDERS: 'advanceBastionOrders',\n ADVANCE_TIME_ON_REST: 'advanceTimeOnRest',\n ALLOW_SIDEBAR_OVERLAP: 'allowSidebarOverlap',\n AMBIENCE_INTENSITY: 'ambienceIntensity',\n AMBIENCE_SYNC: 'ambienceSync',\n AUTO_GENERATE_WEATHER: 'autoGenerateWeather',\n BASTION_LAST_ADVANCE: 'bastionLastAdvance',\n BIG_CAL_AUTO_FADE: 'bigCalAutoFade',\n BIG_CAL_COMBAT_MODE: 'bigCalCombatMode',\n BIG_CAL_CYCLES_DISPLAY_MODE: 'bigCalCyclesDisplayMode',\n BIG_CAL_ERA_DISPLAY_MODE: 'bigCalEraDisplayMode',\n BIG_CAL_HEADER_SHOW_SELECTED: 'bigCalHeaderShowSelected',\n BIG_CAL_IDLE_OPACITY: 'bigCalIdleOpacity',\n BIG_CAL_SEASON_DISPLAY_MODE: 'bigCalSeasonDisplayMode',\n BIG_CAL_SHOW_CYCLES: 'bigCalShowCycles',\n BIG_CAL_SHOW_ERA: 'bigCalShowEra',\n BIG_CAL_SHOW_SEASON: 'bigCalShowSeason',\n BIG_CAL_SHOW_WEATHER: 'bigCalShowWeather',\n BIG_CAL_WEATHER_DISPLAY_MODE: 'bigCalWeatherDisplayMode',\n CALENDAR_HUD_LOCKED: 'calendarHUDLocked',\n CALENDAR_HUD_MODE: 'calendarHUDMode',\n CALENDAR_HUD_POSITION: 'calendarHUDPosition',\n CALENDAR_POSITION: 'calendarPosition',\n CHAT_TIMESTAMP_MODE: 'chatTimestampMode',\n CHAT_TIMESTAMP_SHOW_TIME: 'chatTimestampShowTime',\n CHRONICLE_BIG_CAL_BUTTON: 'chronicleBigCalButton',\n CHRONICLE_CATEGORY_FILTER: 'chronicleCategoryFilter',\n CHRONICLE_COMBAT_MODE: 'chronicleCombatMode',\n CHRONICLE_EMPTY_CONTENT_TYPES: 'chronicleEmptyContentTypes',\n CHRONICLE_ENTRY_DEPTH: 'chronicleEntryDepth',\n CHRONICLE_HUD_BUTTON: 'chronicleHudButton',\n CHRONICLE_MINI_CAL_BUTTON: 'chronicleMiniCalButton',\n CHRONICLE_POSITION: 'chroniclePosition',\n CHRONICLE_SHOW_EMPTY: 'chronicleShowEmpty',\n CHRONICLE_SHOW_SEASON_CHANGES: 'chronicleShowSeasonChanges',\n CHRONICLE_SHOW_WEATHER: 'chronicleShowWeather',\n CHRONICLE_VIEW_MODE: 'chronicleViewMode',\n CINEMATIC_ENABLED: 'cinematicEnabled',\n CINEMATIC_EVENT_MAX_CARDS: 'cinematicEventMaxCards',\n CINEMATIC_EVENT_WEIGHTING: 'cinematicEventWeighting',\n CINEMATIC_ON_REST: 'cinematicOnRest',\n CINEMATIC_PANEL_DURATION: 'cinematicPanelDuration',\n CINEMATIC_SHOW_EVENTS: 'cinematicShowEvents',\n CINEMATIC_SHOW_MOONS: 'cinematicShowMoons',\n CINEMATIC_SHOW_WEATHER: 'cinematicShowWeather',\n CINEMATIC_THRESHOLD_UNIT: 'cinematicThresholdUnit',\n CINEMATIC_THRESHOLD: 'cinematicThreshold',\n CLOCK_LOCKED: 'clockLocked',\n CLOCK_RUN_DURING_COMBAT: 'clockRunDuringCombat',\n COLOR_SHIFT_SYNC: 'colorShiftSync',\n CURRENT_WEATHER: 'currentWeather',\n CUSTOM_CALENDARS: 'customCalendars',\n CUSTOM_PRESETS: 'customPresets',\n CUSTOM_TIME_JUMPS: 'customTimeJumps',\n CUSTOM_WEATHER_PRESETS: 'customWeatherPresets',\n DARKNESS_MOON_SYNC: 'darknessMoonSync',\n DARKNESS_SYNC_ALL_SCENES: 'darknessSyncAllScenes',\n DARKNESS_SYNC: 'darknessSync',\n DARKNESS_WEATHER_SYNC: 'darknessWeatherSync',\n DEFAULT_BRIGHTNESS_MULTIPLIER: 'defaultBrightnessMultiplier',\n DEFAULT_NOTE_PRESET: 'defaultNotePreset',\n DEFAULT_OVERRIDES: 'defaultOverrides',\n DEV_MODE: 'devMode',\n DISPLAY_FORMATS: 'displayFormats',\n ENRICHER_CLICK_TARGET: 'enricherClickTarget',\n EQUIVALENT_DATE_CALENDARS: 'equivalentDateCalendars',\n FOG_OF_WAR_CONFIG: 'fogOfWarConfig',\n FOG_OF_WAR_ENABLED: 'fogOfWarEnabled',\n FOG_OF_WAR_NAV_MODE: 'fogOfWarNavMode',\n FOG_OF_WAR_RANGES: 'fogOfWarRanges',\n FOG_OF_WAR_REVEAL_INTERMEDIATE: 'fogOfWarRevealIntermediate',\n FOG_OF_WAR_START_DATE: 'fogOfWarStartDate',\n FORCE_BIG_CAL: 'forceBigCal',\n FORCE_CHRONICLE: 'forceChronicle',\n FORCE_HUD: 'forceHUD',\n FORCE_HUD_LOCK: 'forceHUDLock',\n FORCE_HUD_ZONE: 'forceHUDZone',\n FORCE_MINI_CAL: 'forceMiniCal',\n FORCE_STOPWATCH: 'forceStopwatch',\n FORCE_SUN_DIAL: 'forceSunDial',\n HIDE_MOONS_FROM_PLAYERS: 'hideMoonsFromPlayers',\n FORCE_TIME_KEEPER: 'forceTimeKeeper',\n FORECAST_ACCURACY: 'forecastAccuracy',\n FORECAST_DAYS: 'forecastDays',\n FXMASTER_BELOW_FOREGROUND: 'fxmasterBelowForeground',\n FXMASTER_BELOW_TILES: 'fxmasterBelowTiles',\n FXMASTER_BELOW_TOKENS: 'fxmasterBelowTokens',\n FXMASTER_DARKNESS_ACTIVATION_ENABLED: 'fxmasterDarknessActivationEnabled',\n FXMASTER_DARKNESS_ACTIVATION_MAX: 'fxmasterDarknessActivationMax',\n FXMASTER_DARKNESS_ACTIVATION_MIN: 'fxmasterDarknessActivationMin',\n FXMASTER_ENABLED: 'fxmasterEnabled',\n FXMASTER_FORCE_DOWNWARD: 'fxmasterForceDownward',\n FXMASTER_SOUND_FX: 'fxMasterSoundFx',\n FXMASTER_SPEED_MULTIPLIER: 'fxmasterSpeedMultiplier',\n FXMASTER_SPLASH: 'fxmasterSplash',\n FXMASTER_TOP_DOWN: 'fxmasterTopDown',\n GM_OVERRIDE_CLEARS_FORECAST: 'gmOverrideClearsForecast',\n HUD_AUTO_FADE: 'hudAutoFade',\n HUD_BORDER_GLOW: 'hudBorderGlow',\n HUD_CALENDAR_BUTTON: 'hudCalendarButton',\n HUD_COMBAT_MODE: 'hudCombatMode',\n HUD_CYCLES_DISPLAY_MODE: 'hudCyclesDisplayMode',\n HUD_DIAL_STYLE: 'hudDialStyle',\n HUD_DOME_AUTO_HIDE: 'hudDomeAutoHide',\n HUD_DOME_BELOW: 'hudDomeBelow',\n HUD_ERA_DISPLAY_MODE: 'hudEraDisplayMode',\n HUD_IDLE_OPACITY: 'hudIdleOpacity',\n HUD_SEASON_DISPLAY_MODE: 'hudSeasonDisplayMode',\n HUD_SHOW_ALL_MOONS: 'hudShowAllMoons',\n HUD_SHOW_CYCLES: 'hudShowCycles',\n HUD_SHOW_ERA: 'hudShowEra',\n HUD_SHOW_SEASON: 'hudShowSeason',\n HUD_SHOW_WEATHER: 'hudShowWeather',\n HUD_STICKY_STATES: 'hudStickyStates',\n HUD_STICKY_ZONES_ENABLED: 'hudStickyZonesEnabled',\n HUD_TRAY_DIRECTION: 'hudTrayDirection',\n HUD_WEATHER_DISPLAY_MODE: 'hudWeatherDisplayMode',\n HUD_WEATHER_FX_MODE: 'hudWeatherFxMode',\n HUD_WIDTH_SCALE: 'hudWidthScale',\n INTRADAY_CARRY_OVER: 'intradayCarryOver',\n INTRADAY_WEATHER: 'intradayWeather',\n MACRO_TRIGGERS: 'macroTriggers',\n MINI_CAL_AUTO_FADE: 'miniCalAutoFade',\n MINI_CAL_AUTO_OPEN_NOTES: 'miniCalAutoOpenNotes',\n MINI_CAL_COMBAT_MODE: 'miniCalCombatMode',\n MINI_CAL_COMPACT_MODE: 'miniCalCompactMode',\n MINI_CAL_CONFIRM_SET_DATE: 'miniCalConfirmSetDate',\n MINI_CAL_CONTROLS_DELAY: 'miniCalControlsDelay',\n MINI_CAL_CYCLES_DISPLAY_MODE: 'miniCalCyclesDisplayMode',\n MINI_CAL_ERA_DISPLAY_MODE: 'miniCalEraDisplayMode',\n MINI_CAL_HEADER_SHOW_SELECTED: 'miniCalHeaderShowSelected',\n MINI_CAL_IDLE_OPACITY: 'miniCalIdleOpacity',\n MINI_CAL_POSITION: 'miniCalPosition',\n MINI_CAL_SEASON_DISPLAY_MODE: 'miniCalSeasonDisplayMode',\n MINI_CAL_SHOW_CYCLES: 'miniCalShowCycles',\n MINI_CAL_SHOW_ERA: 'miniCalShowEra',\n MINI_CAL_SHOW_SEASON: 'miniCalShowSeason',\n MINI_CAL_SHOW_TIME: 'miniCalShowTime',\n MINI_CAL_SHOW_WEATHER: 'miniCalShowWeather',\n MINI_CAL_STICKY_STATES: 'miniCalStickyStates',\n MINI_CAL_TIME_JUMPS: 'miniCalTimeJumps',\n MINI_CAL_WEATHER_DISPLAY_MODE: 'miniCalWeatherDisplayMode',\n NOTE_OPEN_MODE: 'noteOpenMode',\n PERMISSIONS: 'permissions',\n POSITION_LOCKED: 'positionLocked',\n PRECIPITATION_UNIT: 'precipitationUnit',\n PRIMARY_GM: 'primaryGM',\n REST_ADVANCE_MODE: 'restAdvanceMode',\n REST_FIXED_HOURS: 'restFixedHours',\n SAVED_TIMEPOINTS: 'savedTimepoints',\n SEEDED_CALENDARS: 'seededCalendars',\n SHORT_REST_ADVANCE_MODE: 'shortRestAdvanceMode',\n SHORT_REST_FIXED_MINUTES: 'shortRestFixedMinutes',\n SHOW_BIG_CAL: 'showBigCal',\n SHOW_CALENDAR_HUD: 'showCalendarHUD',\n SHOW_CHRONICLE: 'showChronicle',\n SHOW_JOURNAL_FOOTER: 'showJournalFooter',\n SHOW_MINI_CAL: 'showMiniCal',\n SHOW_SECRET_NOTES: 'showSecretNotes',\n SHOW_STOPWATCH: 'showStopwatch',\n SHOW_SUN_DIAL: 'showSunDial',\n SHOW_TIME_KEEPER: 'showTimeKeeper',\n SHOW_TOOLBAR_BUTTON: 'showToolbarButton',\n STOPWATCH_AUTO_FADE: 'stopwatchAutoFade',\n STOPWATCH_AUTO_START_TIME: 'stopwatchAutoStartTime',\n STOPWATCH_COMBAT_MODE: 'stopwatchCombatMode',\n STOPWATCH_IDLE_OPACITY: 'stopwatchIdleOpacity',\n STOPWATCH_POSITION: 'stopwatchPosition',\n STOPWATCH_STATE: 'stopwatchState',\n STOPWATCH_STICKY_STATES: 'stopwatchStickyStates',\n SUN_DIAL_AUTO_FADE: 'sunDialAutoFade',\n SUN_DIAL_COMBAT_MODE: 'sunDialCombatMode',\n SUN_DIAL_CRANK_MODE: 'sunDialCrankMode',\n SUN_DIAL_IDLE_OPACITY: 'sunDialIdleOpacity',\n SUN_DIAL_POSITION: 'sunDialPosition',\n SUN_DIAL_STICKY_STATES: 'sunDialStickyStates',\n SYNC_CLOCK_PAUSE: 'syncClockPause',\n TEMPERATURE_SHOW_BOTH: 'temperatureShowBoth',\n TEMPERATURE_UNIT: 'temperatureUnit',\n TIME_ADVANCE_INTERVAL: 'timeAdvanceInterval',\n TIME_KEEPER_POSITION: 'timeKeeperPosition',\n TIME_SPEED_INCREMENT: 'timeSpeedIncrement',\n TIME_SPEED_MULTIPLIER: 'timeSpeedMultiplier',\n TIMEKEEPER_AUTO_FADE: 'timeKeeperAutoFade',\n TIMEKEEPER_COMBAT_MODE: 'timeKeeperCombatMode',\n TIMEKEEPER_IDLE_OPACITY: 'timeKeeperIdleOpacity',\n TIMEKEEPER_STICKY_STATES: 'timeKeeperStickyStates',\n TIMEKEEPER_TIME_JUMPS: 'timeKeeperTimeJumps',\n TOOLBAR_APPS: 'toolbarApps',\n WEATHER_AMBIENCE_INTENSITY: 'weatherAmbienceIntensity',\n WEATHER_DAY_INDEX_MIGRATED: 'weatherDayIndexMigrated',\n WEATHER_FORECAST_PLAN: 'weatherForecastPlan',\n WEATHER_HISTORY_DAYS: 'weatherHistoryDays',\n WEATHER_HISTORY: 'weatherHistory',\n WEATHER_INERTIA: 'weatherInertia',\n WEATHER_PRESET_ALIASES: 'weatherPresetAliases',\n WEATHER_SEED: 'weatherSeed',\n WEATHER_SOUND_FX: 'weatherSoundFx',\n WEATHER_SOUND_VOLUME: 'weatherSoundVolume',\n WEATHER_SUPPRESS_MUFFLE: 'weatherSuppressMuffle',\n WEATHER_VISUAL_OVERRIDES: 'weatherVisualOverrides',\n WEATHER_YEAR_KEY_MIGRATED: 'weatherYearKeyMigrated',\n WIND_SPEED_UNIT: 'windSpeedUnit'\n};\n\n/** @enum {string} Scene flags for scene-specific configuration */\nexport const SCENE_FLAGS = {\n AMBIENCE_SYNC: 'ambienceSync',\n BRIGHTNESS_MULTIPLIER: 'brightnessMultiplier',\n CLIMATE_ZONE_OVERRIDE: 'climateZoneOverride',\n COLOR_SHIFT_SYNC: 'colorShiftSync',\n DARKNESS_MOON_SYNC: 'darknessMoonSync',\n DARKNESS_SYNC: 'darknessSync',\n DARKNESS_WEATHER_SYNC: 'darknessWeatherSync',\n FXMASTER_SPLASH_OVERRIDE: 'fxmasterSplashOverride',\n FXMASTER_TOP_DOWN_OVERRIDE: 'fxmasterTopDownOverride',\n HUD_HIDE_FOR_PLAYERS: 'hudHideForPlayers',\n WEATHER_FX_LEVELS: 'weatherFxLevels',\n WEATHER_FX_OVERRIDE: 'weatherFxOverride',\n WEATHER_SOUND_DISABLED: 'weatherSoundDisabled'\n};\n\n/** @type {object} Template file paths for UI components */\nexport const TEMPLATES = {\n FORM_FOOTER: 'templates/generic/form-footer.hbs',\n TAB_NAVIGATION: `modules/${MODULE.ID}/templates/partials/tab-navigation.hbs`,\n SETTINGS: {\n PANEL_BIGCAL: `modules/${MODULE.ID}/templates/applications/settings/tab-bigcal.hbs`,\n PANEL_CANVAS: `modules/${MODULE.ID}/templates/applications/settings/tab-canvas.hbs`,\n PANEL_CINEMATICS: `modules/${MODULE.ID}/templates/applications/settings/tab-cinematics.hbs`,\n PANEL_CHAT: `modules/${MODULE.ID}/templates/applications/settings/tab-chat.hbs`,\n PANEL_FOG_OF_WAR: `modules/${MODULE.ID}/templates/applications/settings/tab-fogofwar.hbs`,\n PANEL_FOOTER: `modules/${MODULE.ID}/templates/applications/settings/form-footer.hbs`,\n PANEL_HOME: `modules/${MODULE.ID}/templates/applications/settings/tab-home.hbs`,\n PANEL_HUD: `modules/${MODULE.ID}/templates/applications/settings/tab-hud.hbs`,\n PANEL_MACROS: `modules/${MODULE.ID}/templates/applications/settings/tab-macros.hbs`,\n PANEL_MINI_CAL: `modules/${MODULE.ID}/templates/applications/settings/tab-mini-cal.hbs`,\n PANEL_MODULE: `modules/${MODULE.ID}/templates/applications/settings/tab-module.hbs`,\n PANEL_CHRONICLE: `modules/${MODULE.ID}/templates/applications/settings/tab-chronicle.hbs`,\n PANEL_NOTES: `modules/${MODULE.ID}/templates/applications/settings/tab-notes.hbs`,\n PANEL_PERMISSIONS: `modules/${MODULE.ID}/templates/applications/settings/tab-permissions.hbs`,\n PANEL_STOPWATCH: `modules/${MODULE.ID}/templates/applications/settings/tab-stopwatch.hbs`,\n PANEL_SUN_DIAL: `modules/${MODULE.ID}/templates/applications/settings/tab-sun-dial.hbs`,\n PANEL_TIME: `modules/${MODULE.ID}/templates/applications/settings/tab-time.hbs`,\n PANEL_TIMEKEEPER: `modules/${MODULE.ID}/templates/applications/settings/tab-timekeeper.hbs`,\n PANEL_WEATHER: `modules/${MODULE.ID}/templates/applications/settings/tab-weather.hbs`,\n TOKEN_REFERENCE: `modules/${MODULE.ID}/templates/applications/dialogs/token-reference.hbs`,\n WEATHER_EDITOR_FOOTER: `modules/${MODULE.ID}/templates/applications/weather/weather-editor-footer.hbs`,\n WEATHER_EDITOR: `modules/${MODULE.ID}/templates/applications/weather/weather-editor.hbs`\n },\n PARTIALS: {\n CHAT_ANNOUNCEMENT: `modules/${MODULE.ID}/templates/partials/chat-announcement.hbs`,\n DATE_PICKER: `modules/${MODULE.ID}/templates/partials/dialog-date-picker.hbs`,\n MOON_PICKER: `modules/${MODULE.ID}/templates/partials/moon-picker.hbs`,\n NOTE_PANEL_ITEM: `modules/${MODULE.ID}/templates/partials/note-panel-item.hbs`\n },\n CALENDAR_HUD_BAR: `modules/${MODULE.ID}/templates/applications/hud/calendaria-hud-bar.hbs`,\n CALENDAR_HUD_DOME: `modules/${MODULE.ID}/templates/applications/hud/calendaria-hud-dome.hbs`,\n CALENDAR_HUD: `modules/${MODULE.ID}/templates/applications/hud/calendaria-hud.hbs`,\n MINI_CAL: `modules/${MODULE.ID}/templates/applications/calendar/mini-cal.hbs`,\n SECONDARY_CALENDAR: `modules/${MODULE.ID}/templates/applications/calendar/secondary-calendar.hbs`,\n STOPWATCH: `modules/${MODULE.ID}/templates/applications/time/stopwatch.hbs`,\n SUN_DIAL: `modules/${MODULE.ID}/templates/applications/time/sun-dial.hbs`,\n TIME_KEEPER: `modules/${MODULE.ID}/templates/applications/time/time-keeper.hbs`,\n SCENE: { CONFIG_CALENDARIA: `modules/${MODULE.ID}/templates/scene/scene-config-calendaria.hbs` },\n SHEETS: {\n CALENDAR_CONTENT: `modules/${MODULE.ID}/templates/applications/sheets/calendar-content.hbs`,\n CALENDAR_GRID: `modules/${MODULE.ID}/templates/applications/sheets/calendar-grid.hbs`,\n CALENDAR_HEADER: `modules/${MODULE.ID}/templates/applications/sheets/calendar-header.hbs`,\n CALENDAR_NOTE_VIEW: `modules/${MODULE.ID}/templates/applications/sheets/calendar-note-view.hbs`,\n CALENDAR_WEEK: `modules/${MODULE.ID}/templates/applications/sheets/calendar-week.hbs`,\n CALENDAR_YEAR: `modules/${MODULE.ID}/templates/applications/sheets/calendar-year.hbs`,\n NOTE_TAB_CONTENT: `modules/${MODULE.ID}/templates/applications/sheets/note-tab-content.hbs`,\n NOTE_TAB_SETTINGS: `modules/${MODULE.ID}/templates/applications/sheets/note-tab-settings.hbs`,\n NOTE_TAB_SCHEDULE: `modules/${MODULE.ID}/templates/applications/sheets/note-tab-schedule.hbs`\n },\n EDITOR: {\n TAB_CYCLES: `modules/${MODULE.ID}/templates/editor/tab-cycles.hbs`,\n TAB_DISPLAY: `modules/${MODULE.ID}/templates/editor/tab-display.hbs`,\n TAB_ERAS: `modules/${MODULE.ID}/templates/editor/tab-eras.hbs`,\n TAB_FESTIVALS: `modules/${MODULE.ID}/templates/editor/tab-festivals.hbs`,\n TAB_MONTHS: `modules/${MODULE.ID}/templates/editor/tab-months.hbs`,\n TAB_MOONS: `modules/${MODULE.ID}/templates/editor/tab-moons.hbs`,\n TAB_OVERVIEW: `modules/${MODULE.ID}/templates/editor/tab-overview.hbs`,\n TAB_SEASONS: `modules/${MODULE.ID}/templates/editor/tab-seasons.hbs`,\n TAB_TIME: `modules/${MODULE.ID}/templates/editor/tab-time.hbs`,\n TAB_WEATHER: `modules/${MODULE.ID}/templates/editor/tab-weather.hbs`,\n TAB_WEEKS: `modules/${MODULE.ID}/templates/editor/tab-weeks.hbs`,\n TAB_YEARS: `modules/${MODULE.ID}/templates/editor/tab-years.hbs`\n },\n IMPORTER: { APP: `modules/${MODULE.ID}/templates/importers/importer-app.hbs` },\n WEATHER: {\n CLIMATE_EDITOR_ENVIRONMENT: `modules/${MODULE.ID}/templates/applications/weather/climate-editor-environment.hbs`,\n CLIMATE_EDITOR_PRESETS: `modules/${MODULE.ID}/templates/applications/weather/climate-editor-presets.hbs`,\n CLIMATE_EDITOR_TABS: `modules/${MODULE.ID}/templates/applications/weather/climate-editor-tabs.hbs`,\n CLIMATE_EDITOR_WEATHER: `modules/${MODULE.ID}/templates/applications/weather/climate-editor-weather.hbs`,\n CLIMATE_EDITOR: `modules/${MODULE.ID}/templates/applications/weather/climate-editor.hbs`,\n PICKER_FOOTER: `modules/${MODULE.ID}/templates/applications/weather/weather-picker-footer.hbs`,\n PICKER: `modules/${MODULE.ID}/templates/applications/weather/weather-picker.hbs`,\n PROBABILITY_DIALOG: `modules/${MODULE.ID}/templates/applications/weather/weather-probability-dialog.hbs`\n },\n DIALOGS: {\n COMPUTED_EVENT_BUILDER: `modules/${MODULE.ID}/templates/applications/dialogs/computed-event-builder.hbs`,\n CONDITION_BUILDER: `modules/${MODULE.ID}/templates/applications/dialogs/condition-builder.hbs`,\n SET_DATE: `modules/${MODULE.ID}/templates/applications/dialogs/set-date-dialog.hbs`\n },\n CHRONICLE: `modules/${MODULE.ID}/templates/applications/calendar/chronicle.hbs`,\n CHRONICLE_CONTENT: `modules/${MODULE.ID}/templates/applications/calendar/chronicle-content.hbs`,\n CHRONICLE_ENTRY: `modules/${MODULE.ID}/templates/applications/calendar/chronicle-entry.hbs`,\n CHRONICLE_TIMELINE_ENTRY: `modules/${MODULE.ID}/templates/applications/calendar/chronicle-timeline-entry.hbs`,\n NOTE_VIEWER: {\n SEARCH: `modules/${MODULE.ID}/templates/applications/calendar/note-viewer-search.hbs`,\n FILTERS: `modules/${MODULE.ID}/templates/applications/calendar/note-viewer-filters.hbs`,\n RESULTS: `modules/${MODULE.ID}/templates/applications/calendar/note-viewer-results.hbs`,\n ROW: `modules/${MODULE.ID}/templates/applications/calendar/note-viewer-row.hbs`,\n FOOTER: `modules/${MODULE.ID}/templates/applications/calendar/note-viewer-footer.hbs`\n },\n SET_DATE_DIALOG: `modules/${MODULE.ID}/templates/applications/dialogs/set-date-dialog.hbs`\n};\n\n/** @type {Object} Asset paths */\nexport const ASSETS = {\n MOON_ICONS: `modules/${MODULE.ID}/assets/moon-phases`\n};\n\n/** @type {Object} Standard 8-phase moon cycle (start/end are 0-1 range) */\nexport const DEFAULT_MOON_PHASES = {\n newmoon000000000: { name: 'CALENDARIA.MoonPhase.NewMoon', icon: `${ASSETS.MOON_ICONS}/01_newmoon.svg`, start: 0, end: 0.125 },\n waxingcrescent00: { name: 'CALENDARIA.MoonPhase.WaxingCrescent', icon: `${ASSETS.MOON_ICONS}/02_waxingcrescent.svg`, start: 0.125, end: 0.25 },\n firstquarter0000: { name: 'CALENDARIA.MoonPhase.FirstQuarter', icon: `${ASSETS.MOON_ICONS}/03_firstquarter.svg`, start: 0.25, end: 0.375 },\n waxinggibbous000: { name: 'CALENDARIA.MoonPhase.WaxingGibbous', icon: `${ASSETS.MOON_ICONS}/04_waxinggibbous.svg`, start: 0.375, end: 0.5 },\n fullmoon00000000: { name: 'CALENDARIA.MoonPhase.FullMoon', icon: `${ASSETS.MOON_ICONS}/05_fullmoon.svg`, start: 0.5, end: 0.625 },\n waninggibbous000: { name: 'CALENDARIA.MoonPhase.WaningGibbous', icon: `${ASSETS.MOON_ICONS}/06_waninggibbous.svg`, start: 0.625, end: 0.75 },\n lastquarter00000: { name: 'CALENDARIA.MoonPhase.LastQuarter', icon: `${ASSETS.MOON_ICONS}/07_lastquarter.svg`, start: 0.75, end: 0.875 },\n waningcrescent00: { name: 'CALENDARIA.MoonPhase.WaningCrescent', icon: `${ASSETS.MOON_ICONS}/08_waningcrescent.svg`, start: 0.875, end: 1 }\n};\n\n/** @type {string[]} Standard 8-phase moon localization keys. */\nexport const MOON_PHASE_LABELS = [\n 'CALENDARIA.MoonPhase.NewMoon',\n 'CALENDARIA.MoonPhase.WaxingCrescent',\n 'CALENDARIA.MoonPhase.FirstQuarter',\n 'CALENDARIA.MoonPhase.WaxingGibbous',\n 'CALENDARIA.MoonPhase.FullMoon',\n 'CALENDARIA.MoonPhase.WaningGibbous',\n 'CALENDARIA.MoonPhase.LastQuarter',\n 'CALENDARIA.MoonPhase.WaningCrescent'\n];\n\n/** @type {Object} Wind speed scale (0-5). Canonical values stored in kph; imperial conversion at display time. */\nexport const WIND_SPEEDS = {\n CALM: { id: 'calm', value: 0, label: 'CALENDARIA.Weather.Wind.Calm', kph: 5 },\n LIGHT: { id: 'light', value: 1, label: 'CALENDARIA.Common.Light', kph: 20 },\n MODERATE: { id: 'moderate', value: 2, label: 'CALENDARIA.Common.Moderate', kph: 40 },\n STRONG: { id: 'strong', value: 3, label: 'CALENDARIA.Weather.Wind.Strong', kph: 60 },\n SEVERE: { id: 'severe', value: 4, label: 'CALENDARIA.Common.Severe', kph: 90 },\n EXTREME: { id: 'extreme', value: 5, label: 'CALENDARIA.Weather.Wind.Extreme', kph: 250 }\n};\n\n/** @enum {string|null} Precipitation type identifiers */\nexport const PRECIPITATION_TYPES = { NONE: null, DRIZZLE: 'drizzle', RAIN: 'rain', SNOW: 'snow', SLEET: 'sleet', HAIL: 'hail' };\n\n/** @type {Object} Compass direction degrees for 16-point wind rose */\nexport const COMPASS_DIRECTIONS = { N: 0, NNE: 22.5, NE: 45, ENE: 67.5, E: 90, ESE: 112.5, SE: 135, SSE: 157.5, S: 180, SSW: 202.5, SW: 225, WSW: 247.5, W: 270, WNW: 292.5, NW: 315, NNW: 337.5 };\n\n/** @type {Object} Intraday weather period definitions tied to threshold hooks */\nexport const WEATHER_PERIODS = {\n NIGHT: { id: 'night', index: 0, label: 'CALENDARIA.Common.Night', icon: 'fa-moon' },\n MORNING: { id: 'morning', index: 1, label: 'CALENDARIA.Format.ApproxTime.Morning', icon: 'fa-sun' },\n AFTERNOON: { id: 'afternoon', index: 2, label: 'CALENDARIA.Format.ApproxTime.Afternoon', icon: 'fa-cloud-sun' },\n EVENING: { id: 'evening', index: 3, label: 'CALENDARIA.Format.ApproxTime.Evening', icon: 'fa-cloud-moon' }\n};\n\n/** @enum {string} Condition field identifiers for the unified condition engine. */\nexport const CONDITION_FIELDS = {\n COMPUTED: 'computed',\n CYCLE: 'cycle',\n DATE: 'date',\n DAY_OF_YEAR: 'dayOfYear',\n DAY: 'day',\n DAY_IN_CALENDAR: 'dayInCalendar',\n DAYS_BEFORE_MONTH_END: 'daysBeforeMonthEnd',\n ECLIPSE: 'eclipse',\n EPOCH: 'epoch',\n ERA_YEAR: 'eraYear',\n ERA: 'era',\n EVENT: 'event',\n INTERCALARY: 'intercalary',\n INVERSE_WEEK_NUMBER: 'inverseWeekNumber',\n IS_AUTUMN_EQUINOX: 'isAutumnEquinox',\n IS_ECLIPSE: 'isEclipse',\n IS_LEAP_YEAR: 'isLeapYear',\n IS_LONGEST_DAY: 'isLongestDay',\n IS_LUNAR_ECLIPSE: 'isLunarEclipse',\n IS_SHORTEST_DAY: 'isShortestDay',\n IS_SOLAR_ECLIPSE: 'isSolarEclipse',\n IS_SPRING_EQUINOX: 'isSpringEquinox',\n MONTH: 'month',\n MONTH_IN_CALENDAR: 'monthInCalendar',\n MOON_PHASE_COUNT_MONTH: 'moonPhaseCountMonth',\n MOON_PHASE_COUNT_EPOCH: 'moonPhaseCountEpoch',\n MOON_PHASE_COUNT_YEAR: 'moonPhaseCountYear',\n MOON_PHASE_INDEX: 'moonPhaseIndex',\n MOON_PHASE: 'moonPhase',\n MOON_SUB_PHASE: 'moonSubPhase',\n RANDOM: 'random',\n SEASON_DAY: 'seasonDay',\n SEASON_PERCENT: 'seasonPercent',\n SEASON: 'season',\n TOTAL_WEEK: 'totalWeek',\n WEATHER: 'weather',\n WEEK_IN_MONTH: 'weekInMonth',\n WEEK_IN_YEAR: 'weekInYear',\n WEEK_NUMBER_IN_MONTH: 'weekNumberInMonth',\n WEEKDAY: 'weekday',\n WEEKS_BEFORE_MONTH_END: 'weeksBeforeMonthEnd',\n WEEKS_BEFORE_YEAR_END: 'weeksBeforeYearEnd',\n YEAR: 'year'\n};\n\n/** @enum {string} Condition operator identifiers for the unified condition engine. */\nexport const CONDITION_OPERATORS = {\n DAYS_AGO: 'daysAgo',\n DAYS_FROM_NOW: 'daysFromNow',\n EQUAL: '==',\n GREATER_EQUAL: '>=',\n GREATER: '>',\n LESS_EQUAL: '<=',\n LESS: '<',\n MODULO: '%',\n NOT_EQUAL: '!=',\n WITHIN_LAST: 'withinLast',\n WITHIN_NEXT: 'withinNext'\n};\n\n/** @enum {string} Condition group boolean modes for nested condition logic. */\nexport const CONDITION_GROUP_MODES = { AND: 'and', COUNT: 'count', NAND: 'nand', OR: 'or', XOR: 'xor' };\n\n/** @type {number} Maximum allowed nesting depth for condition groups. */\nexport const MAX_NESTING_DEPTH = 5;\n\n/** @type {number} Density multiplier for reduced weather FX mode. */\nexport const REDUCED_FX_DENSITY = 0.3;\n\n/** @enum {string} Note display style options. */\nexport const DISPLAY_STYLES = { BANNER: 'banner', ICON: 'icon', PIP: 'pip' };\n\n/** @enum {string} Note visibility levels. */\nexport const NOTE_VISIBILITY = { HIDDEN: 'hidden', SECRET: 'secret', VISIBLE: 'visible' };\n\n/** @type {Object} Moon visibility states. Two-state by design; hidden moons render only for the GM. */\nexport const MOON_VISIBILITY = { HIDDEN: 'hidden', VISIBLE: 'visible' };\n\n/** @enum {string} Custom hook names fired by the module */\nexport const HOOKS = {\n CALENDAR_ADDED: 'calendaria.calendarAdded',\n CALENDAR_REMOVED: 'calendaria.calendarRemoved',\n CALENDAR_SWITCHED: 'calendaria.calendarSwitched',\n CALENDAR_UPDATED: 'calendaria.calendarUpdated',\n CINEMATIC_ABORT: 'calendaria.cinematicAbort',\n CINEMATIC_END: 'calendaria.cinematicEnd',\n CINEMATIC_START: 'calendaria.cinematicStart',\n PRESETS_CHANGED: 'calendaria.presetsChanged',\n CLOCK_START_STOP: 'calendaria.clockStartStop',\n CLOCK_UPDATE: 'calendaria.clockUpdate',\n CONDITION_EVALUATED: 'calendaria.conditionEvaluated',\n DATE_TIME_CHANGE: 'calendaria.dateTimeChange',\n DAY_CHANGE: 'calendaria.dayChange',\n DISPLAY_FORMATS_CHANGED: 'calendaria.displayFormatsChanged',\n EVENT_DAY_CHANGED: 'calendaria.eventDayChanged',\n FOG_RANGE_CHANGED: 'calendaria.fogRangeChanged',\n EVENT_TRIGGERED: 'calendaria.eventTriggered',\n IMPORT_COMPLETE: 'calendaria.importComplete',\n IMPORT_FAILED: 'calendaria.importFailed',\n IMPORT_STARTED: 'calendaria.importStarted',\n INIT: 'calendaria.init',\n MIDDAY: 'calendaria.midday',\n MIDNIGHT: 'calendaria.midnight',\n MONTH_CHANGE: 'calendaria.monthChange',\n MOON_PHASE_CHANGE: 'calendaria.moonPhaseChange',\n NOTE_CREATED: 'calendaria.noteCreated',\n NOTE_DELETED: 'calendaria.noteDeleted',\n NOTE_UPDATED: 'calendaria.noteUpdated',\n PRE_RENDER_CALENDAR: 'calendaria.preRenderCalendar',\n READY: 'calendaria.ready',\n RENDERED: 'calendaria.rendered',\n REMINDER_RECEIVED: 'calendaria.reminderReceived',\n REMOTE_CALENDAR_SWITCH: 'calendaria.remoteCalendarSwitch',\n REMOTE_DATE_CHANGE: 'calendaria.remoteDateChange',\n RENDER_CALENDAR: 'calendaria.renderCalendar',\n REST_DAY_CHANGE: 'calendaria.restDayChange',\n SEASON_CHANGE: 'calendaria.seasonChange',\n STOPWATCH_LAP: 'calendaria.stopwatchLap',\n STOPWATCH_PAUSE: 'calendaria.stopwatchPause',\n STOPWATCH_RESET: 'calendaria.stopwatchReset',\n STOPWATCH_START: 'calendaria.stopwatchStart',\n SUNRISE: 'calendaria.sunrise',\n SUNSET: 'calendaria.sunset',\n VISUAL_TICK: 'calendaria.visualTick',\n WEATHER_CHANGE: 'calendaria.weatherChange',\n WEATHER_PERIOD_CHANGE: 'calendaria.weatherPeriodChange',\n WIDGET_REGISTERED: 'calendaria.widgetRegistered',\n WIDGETS_REFRESH: 'calendaria.widgetsRefresh',\n WORLD_TIME_UPDATED: 'calendaria.worldTimeUpdated',\n YEAR_CHANGE: 'calendaria.yearChange'\n};\n\n/** @enum {string} Journal page type identifiers */\nexport const JOURNALS = { CALENDAR_NOTE: 'calendaria.calendarnote' };\n\n/** @enum {string} Sheet registration identifiers */\nexport const SHEETS = { CALENDARIA: 'calendaria' };\n\n/** @enum {string} Socket message types for multiplayer sync */\nexport const SOCKET_TYPES = {\n BIG_CAL_VISIBILITY: 'bigCalVisibility',\n CALENDAR_REQUEST: 'calendarRequest',\n CHRONICLE_VISIBILITY: 'chronicleVisibility',\n CINEMATIC_ABORT: 'cinematicAbort',\n CINEMATIC_PLAY: 'cinematicPlay',\n CALENDAR_SWITCH: 'calendarSwitch',\n CLOCK_UPDATE: 'clockUpdate',\n CREATE_NOTE_COMPLETE: 'createNoteComplete',\n CREATE_NOTE: 'createNote',\n DATE_CHANGE: 'dateChange',\n HUD_VISIBILITY: 'hudVisibility',\n MINI_CAL_VISIBILITY: 'miniCalVisibility',\n NOTE_UPDATE: 'noteUpdate',\n OWNERSHIP_UPDATE: 'ownershipUpdate',\n REMINDER_NOTIFY: 'reminderNotify',\n STOPWATCH_VISIBILITY: 'stopwatchVisibility',\n SUN_DIAL_VISIBILITY: 'sunDialVisibility',\n TIME_KEEPER_VISIBILITY: 'timeKeeperVisibility',\n TIME_REQUEST: 'timeRequest',\n WEATHER_CHANGE: 'weatherChange',\n WEATHER_REQUEST: 'weatherRequest'\n};\n\n/** @enum {string} Widget insertion points for external modules */\nexport const WIDGET_POINTS = {\n BIGCAL_ACTIONS: 'bigcal.actions',\n HUD_BUTTONS_LEFT: 'hud.buttons.left',\n HUD_BUTTONS_RIGHT: 'hud.buttons.right',\n HUD_INDICATORS: 'hud.indicators',\n HUD_TRAY: 'hud.tray',\n MINICAL_SIDEBAR: 'minical.sidebar'\n};\n\n/** @enum {string} Built-in elements that can be replaced by widgets */\nexport const REPLACEABLE_ELEMENTS = { CYCLE_INDICATOR: 'cycle-indicator', ERA_INDICATOR: 'era-indicator', SEASON_INDICATOR: 'season-indicator', WEATHER_INDICATOR: 'weather-indicator' };\n","import { MODULE } from '../constants.mjs';\nimport { CalendarRegistry, preLocalizeCalendar } from './_module.mjs';\n\n/** @type {string[]} List of bundled calendar IDs that ship with the module. */\nexport const BUNDLED_CALENDARS = [\n 'athasian',\n 'barovian',\n 'cerilian',\n 'drakkenheim',\n 'exandrian',\n 'forbidden-lands',\n 'galifar',\n 'golarion',\n 'golarion-imperial',\n 'gregorian',\n 'pact-standard',\n 'greyhawk',\n 'harptos',\n 'hijri',\n 'imperial-warhammer',\n 'krynn-elven',\n 'krynn-solamnia',\n 'renescara',\n 'thyatian',\n 'traveller'\n];\n\n/** @type {string} Default calendar ID to use when no calendar is selected. */\nexport const DEFAULT_CALENDAR = 'gregorian';\n\n/**\n * Load a single calendar JSON file.\n * @param {string} id Calendar ID (filename without extension)\n * @returns {Promise} Calendar data or null if failed\n */\nasync function loadCalendarFile(id) {\n const path = `modules/${MODULE.ID}/calendars/${id}.json`;\n try {\n const response = await fetch(path);\n if (!response.ok) {\n ATLAS.log(1, `Failed to load calendar file: ${path} (${response.status})`);\n return null;\n }\n const data = await response.json();\n preLocalizeCalendar(data);\n ATLAS.log(3, `Loaded calendar file: ${id}`);\n return data;\n } catch (error) {\n ATLAS.log(1, `Error loading calendar file ${path}:`, error);\n return null;\n }\n}\n\n/**\n * Load all bundled calendars and register them with the CalendarRegistry.\n * @returns {Promise} Array of successfully loaded calendar IDs\n */\nexport async function loadBundledCalendars() {\n const loaded = [];\n for (const id of BUNDLED_CALENDARS) {\n const data = await loadCalendarFile(id);\n if (data) {\n CalendarRegistry.register(id, data);\n loaded.push(id);\n }\n }\n ATLAS.log(3, `Loaded ${loaded.length}/${BUNDLED_CALENDARS.length} bundled calendars`);\n return loaded;\n}\n\n/**\n * Check if a calendar ID is a bundled calendar.\n * @param {string} id Calendar ID\n * @returns {boolean} - If calendar is module-provided\n */\nexport function isBundledCalendar(id) {\n return BUNDLED_CALENDARS.includes(id);\n}\n","import { DISPLAY_STYLES, NOTE_VISIBILITY } from '../constants.mjs';\nimport { BaseConditionData } from './_module.mjs';\n\n/** Data model for calendar note journal entry pages. */\nexport class CalendarNoteDataModel extends foundry.abstract.TypeDataModel {\n /**\n * Define the schema for calendar note data.\n * @returns {object} Schema definition\n */\n static defineSchema() {\n const fields = foundry.data.fields;\n return {\n startDate: new fields.SchemaField(\n {\n year: new fields.NumberField({ required: true, integer: true, initial: 1492 }),\n month: new fields.NumberField({ required: true, integer: true, min: 0, initial: 0 }),\n dayOfMonth: new fields.NumberField({ required: true, integer: true, min: 0, initial: 0 }),\n hour: new fields.NumberField({ integer: true, min: 0, initial: 12 }),\n minute: new fields.NumberField({ integer: true, min: 0, max: 59, initial: 0 })\n },\n { required: true }\n ),\n endDate: new fields.SchemaField(\n {\n year: new fields.NumberField({ integer: true }),\n month: new fields.NumberField({ integer: true, min: 0 }),\n dayOfMonth: new fields.NumberField({ integer: true, min: 0 }),\n hour: new fields.NumberField({ integer: true, min: 0 }),\n minute: new fields.NumberField({ integer: true, min: 0, max: 59 })\n },\n { nullable: true }\n ),\n allDay: new fields.BooleanField({ initial: false }),\n repeat: new fields.StringField({ choices: ['never', 'computed'], initial: 'never' }),\n repeatInterval: new fields.NumberField({ integer: true, min: 1, initial: 1 }),\n repeatEndDate: new fields.SchemaField(\n { year: new fields.NumberField({ integer: true }), month: new fields.NumberField({ integer: true, min: 0 }), dayOfMonth: new fields.NumberField({ integer: true, min: 0 }) },\n { nullable: true }\n ),\n maxOccurrences: new fields.NumberField({ integer: true, min: 0, initial: 0 }),\n moonConditions: new fields.ArrayField(\n new fields.SchemaField({\n moonIndex: new fields.NumberField({ required: true, integer: true, min: 0 }),\n phaseStart: new fields.NumberField({ required: true, min: 0, max: 1 }),\n phaseEnd: new fields.NumberField({ required: true, min: 0, max: 1 }),\n modifier: new fields.StringField({ choices: ['any', 'rising', 'true', 'fading'], initial: 'any' })\n }),\n { initial: [] }\n ),\n randomConfig: new fields.SchemaField(\n {\n seed: new fields.NumberField({ integer: true, initial: 0 }),\n probability: new fields.NumberField({ min: 0, max: 100, initial: 10 }),\n checkInterval: new fields.StringField({ choices: ['daily', 'weekly', 'monthly'], initial: 'daily' })\n },\n { nullable: true }\n ),\n linkedEvent: new fields.SchemaField({ noteId: new fields.StringField({ required: true, blank: false }), offset: new fields.NumberField({ integer: true, initial: 0 }) }, { nullable: true }),\n rangePattern: new fields.SchemaField(\n { year: new fields.JSONField({ nullable: true }), month: new fields.JSONField({ nullable: true }), dayOfMonth: new fields.JSONField({ nullable: true }) },\n { nullable: true }\n ),\n weekday: new fields.NumberField({ integer: true, min: 0, nullable: true }),\n weekNumber: new fields.NumberField({ integer: true, min: -5, max: 5, nullable: true }),\n seasonalConfig: new fields.SchemaField(\n {\n seasonIndex: new fields.NumberField({ required: true, integer: true, min: 0, initial: 0 }),\n trigger: new fields.StringField({ choices: ['entire', 'firstDay', 'lastDay'], initial: 'entire' })\n },\n { nullable: true }\n ),\n computedConfig: new fields.SchemaField(\n {\n chain: new fields.ArrayField(\n new fields.SchemaField({\n type: new fields.StringField({ required: true, choices: ['anchor', 'firstAfter', 'daysAfter', 'weekdayOnOrAfter'] }),\n value: new fields.StringField({ nullable: true }),\n condition: new fields.StringField({ choices: ['moonPhase', 'weekday', 'seasonStart', 'seasonEnd'], nullable: true }),\n params: new fields.ObjectField({ nullable: true })\n }),\n { initial: [] }\n ),\n yearOverrides: new fields.ObjectField({ initial: {} })\n },\n { nullable: true }\n ),\n connectedEvents: new fields.ArrayField(new fields.StringField({ blank: false }), { initial: [] }),\n conditionTree: new fields.ObjectField({ nullable: true, initial: null }),\n conditions: new fields.ArrayField(new fields.TypedSchemaField(BaseConditionData.TYPES), { initial: [] }),\n categories: new fields.ArrayField(new fields.StringField(), { initial: [] }),\n color: new fields.ColorField({ initial: '#4a9eff' }),\n icon: new fields.StringField({ initial: 'fas fa-calendar', blank: true }),\n iconType: new fields.StringField({ choices: ['image', 'fontawesome'], initial: 'fontawesome' }),\n reminderOffset: new fields.NumberField({ integer: true, min: 0, initial: 0 }),\n reminderUnit: new fields.StringField({ choices: ['hour', 'day', 'week', 'month', 'year'], initial: 'hour' }),\n reminderType: new fields.StringField({ choices: ['none', 'toast', 'chat', 'dialog'], initial: 'toast' }),\n reminderTargets: new fields.StringField({ choices: ['all', 'gm', 'author', 'specific', 'viewers'], initial: 'all' }),\n reminderUsers: new fields.ArrayField(new fields.StringField(), { initial: [] }),\n macro: new fields.StringField({ nullable: true, blank: true }),\n sceneId: new fields.StringField({ nullable: true, blank: true }),\n playlistId: new fields.StringField({ nullable: true, blank: true }),\n hasDuration: new fields.BooleanField({ initial: false }),\n duration: new fields.NumberField({ integer: true, min: 1, initial: 1 }),\n showBookends: new fields.BooleanField({ initial: false }),\n displayStyle: new fields.StringField({ choices: [DISPLAY_STYLES.ICON, DISPLAY_STYLES.PIP, DISPLAY_STYLES.BANNER], initial: DISPLAY_STYLES.ICON }),\n visibility: new fields.StringField({ choices: [NOTE_VISIBILITY.VISIBLE, NOTE_VISIBILITY.HIDDEN, NOTE_VISIBILITY.SECRET], initial: NOTE_VISIBILITY.VISIBLE }),\n silent: new fields.BooleanField({ initial: false }),\n linkedFestival: new fields.SchemaField(\n {\n calendarId: new fields.StringField({ required: true, blank: false }),\n festivalKey: new fields.StringField({ required: true, blank: false }),\n countsForWeekday: new fields.BooleanField({ initial: true }),\n leapYearOnly: new fields.BooleanField({ initial: false }),\n leapDuration: new fields.NumberField({ integer: true, min: 1, nullable: true, initial: null })\n },\n { nullable: true, initial: null }\n ),\n author: new fields.DocumentAuthorField(foundry.documents.BaseUser)\n };\n }\n\n /**\n * Migrate raw source data before schema initialization.\n * @param {object} source - Raw source data\n * @returns {object} Migrated source data\n */\n static migrateData(source) {\n if (source.repeat && source.repeat !== 'never' && source.repeat !== 'computed') source.repeat = 'never';\n for (const field of ['startDate', 'endDate', 'repeatEndDate']) {\n if (source[field]?.day != null && source[field]?.dayOfMonth == null) {\n source[field].dayOfMonth = (source[field].day ?? 1) - 1;\n delete source[field].day;\n }\n }\n if (source.rangePattern?.day != null && source.rangePattern?.dayOfMonth == null) {\n source.rangePattern.dayOfMonth = source.rangePattern.day;\n delete source.rangePattern.day;\n }\n if (source.gmOnly !== undefined && source.visibility === undefined) {\n source.visibility = source.gmOnly ? NOTE_VISIBILITY.HIDDEN : NOTE_VISIBILITY.VISIBLE;\n delete source.gmOnly;\n }\n return super.migrateData(source);\n }\n}\n","import { CalendarManager } from '../calendar/_module.mjs';\n\n/**\n * Deterministic seeded random number generator.\n * @param {number} seed - Random seed\n * @param {number} year - Year component\n * @param {number} dayOfYear - Day of year component\n * @returns {number} Pseudo-random value in [0, 99.99]\n */\nexport function seededRandom(seed, year, dayOfYear) {\n let hash = Math.abs(seed) || 1;\n hash = ((hash * 1103515245 + 12345) >>> 0) % 0x7fffffff;\n hash = ((hash + year * 31337) >>> 0) % 0x7fffffff;\n hash = ((hash * 1103515245 + dayOfYear * 7919) >>> 0) % 0x7fffffff;\n return (hash % 10000) / 100;\n}\n\n/**\n * Get day of year for a date (0-based).\n * @param {object} date - Date with year, month, dayOfMonth (0-based)\n * @returns {number} Day of year (0-based)\n */\nexport function getDayOfYear(date) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return date.dayOfMonth;\n const yearZero = calendar.years?.yearZero ?? 0;\n const internalYear = date.year - yearZero;\n let dayOfYear = 0;\n for (let m = 0; m < date.month; m++) dayOfYear += calendar.getDaysInMonth(m, internalYear);\n return dayOfYear + date.dayOfMonth;\n}\n\n/**\n * Get the last day number of a month (1-indexed).\n * @param {object} date - Date with year, month\n * @returns {number} Last day of month\n */\nexport function getLastDayOfMonth(date) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return 30;\n const yearZero = calendar.years?.yearZero ?? 0;\n return calendar.getDaysInMonth(date.month, date.year - yearZero);\n}\n\n/**\n * Get total days in a year.\n * @param {number} year - Display year\n * @returns {number} Total days\n */\nexport function getTotalDaysInYear(year) {\n const calendar = CalendarManager.getActiveCalendar();\n const yearZero = calendar?.years?.yearZero ?? 0;\n return calendar.getDaysInYear(year - yearZero);\n}\n\n/**\n * Get total days since epoch for a date.\n * @param {object} date - Date with year, month, dayOfMonth\n * @returns {number} Total days\n */\nexport function getTotalDaysSinceEpoch(date) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return 0;\n const yearZero = calendar.years?.yearZero ?? 0;\n const components = { year: date.year - yearZero, month: date.month, dayOfMonth: date.dayOfMonth ?? 0, hour: 0, minute: 0, second: 0 };\n const time = calendar.componentsToTime(components);\n const hoursPerDay = calendar.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar.days?.minutesPerHour ?? 60;\n const secondsPerMinute = calendar.days?.secondsPerMinute ?? 60;\n const secondsPerDay = hoursPerDay * minutesPerHour * secondsPerMinute;\n return Math.floor(time / secondsPerDay);\n}\n\n/**\n * Re-resolve a stored 0-based reference-year day-of-year for a specific year, leap-aware.\n * @param {number} value - 0-based day-of-year in a non-leap reference year\n * @param {object} calendar - Calendar instance\n * @param {number} [internalYear] - Year minus yearZero (for leap-aware month sizes)\n * @returns {number} 0-based day-of-year valid for internalYear\n */\nexport function normalizeRawDoy(value, calendar, internalYear = 0) {\n const months = calendar?.monthsArray ?? [];\n if (!months.length || !Number.isFinite(value)) return value;\n let remaining = Math.max(0, value);\n let month = 0;\n for (; month < months.length; month++) {\n const days = months[month]?.days ?? 30;\n if (remaining < days) break;\n remaining -= days;\n }\n if (month >= months.length) {\n month = months.length - 1;\n remaining = (months[month]?.days ?? 1) - 1;\n }\n const daysIn = (m) => calendar.getDaysInMonth?.(m, internalYear) ?? months[m]?.days ?? 30;\n let doy = 0;\n for (let m = 0; m < month; m++) doy += daysIn(m);\n return doy + Math.min(remaining, daysIn(month) - 1);\n}\n\n/**\n * Get season index for a given day-of-year.\n * @param {number} dayOfYear - Day of year (0-based)\n * @param {object[]} seasons - Seasons array\n * @param {object} [date] - Full date for month-based season resolution\n * @returns {number} Season index or -1\n */\nexport function getSeasonIndex(dayOfYear, seasons, date) {\n const calendar = CalendarManager.getActiveCalendar();\n if (calendar?.seasons?.type === 'periodic') {\n const yearZero = calendar.years?.yearZero ?? 0;\n const totalDays = calendar.getDaysInYear((date?.year ?? 0) - yearZero);\n for (let i = 0; i < seasons.length; i++) {\n const { dayStart, dayEnd } = calendar._calculatePeriodicSeasonBounds(i, totalDays);\n if (isInSeasonRange(dayOfYear, dayStart, dayEnd)) return i;\n }\n return -1;\n }\n for (let i = 0; i < seasons.length; i++) {\n const season = seasons[i];\n if (season.monthStart != null && season.monthEnd != null && date) {\n const currentMonth = date.month;\n const startDay = season.dayStart ?? 0;\n const months = calendar?.monthsArray ?? [];\n const endDay = season.dayEnd ?? (months[season.monthEnd]?.days ?? 30) - 1;\n if (season.monthStart === season.monthEnd) {\n if (currentMonth === season.monthStart && date.dayOfMonth >= startDay && date.dayOfMonth <= endDay) return i;\n } else if (season.monthStart < season.monthEnd) {\n if (currentMonth > season.monthStart && currentMonth < season.monthEnd) return i;\n if (currentMonth === season.monthStart && date.dayOfMonth >= startDay) return i;\n if (currentMonth === season.monthEnd && date.dayOfMonth <= endDay) return i;\n } else {\n if (currentMonth > season.monthStart || currentMonth < season.monthEnd) return i;\n if (currentMonth === season.monthStart && date.dayOfMonth >= startDay) return i;\n if (currentMonth === season.monthEnd && date.dayOfMonth <= endDay) return i;\n }\n } else {\n const internalYear = (date?.year ?? 0) - (calendar?.years?.yearZero ?? 0);\n const start = normalizeRawDoy(season.dayStart ?? 0, calendar, internalYear);\n const end = normalizeRawDoy(season.dayEnd ?? season.dayStart ?? 0, calendar, internalYear);\n if (isInSeasonRange(dayOfYear, start, end)) return i;\n }\n }\n return -1;\n}\n\n/**\n * Check if dayOfYear falls within a season range (handles wrap-around).\n * @param {number} dayOfYear - Day of year\n * @param {number} start - Range start\n * @param {number} end - Range end\n * @returns {boolean} True if in range\n */\nexport function isInSeasonRange(dayOfYear, start, end) {\n if (start <= end) return dayOfYear >= start && dayOfYear <= end;\n return dayOfYear >= start || dayOfYear <= end;\n}\n\n/**\n * Resolve a season's day-of-year bounds across periodic, month-based, and day-based season types.\n * @param {object[]} seasons - Seasons array\n * @param {number} idx - Season index\n * @param {number} totalDays - Total days in year\n * @param {number} [internalYear] - Year minus yearZero (for leap-aware month sizes)\n * @returns {{start: number, end: number}} Bounds aligned with getDayOfYear\n */\nfunction resolveSeasonDayBounds(seasons, idx, totalDays, internalYear = 0) {\n const calendar = CalendarManager.getActiveCalendar();\n const season = seasons[idx];\n if (calendar?.seasons?.type === 'periodic') {\n const { dayStart, dayEnd } = calendar._calculatePeriodicSeasonBounds(idx, totalDays);\n return { start: dayStart, end: dayEnd };\n }\n if (season.monthStart != null && season.monthEnd != null && calendar) {\n const bounds = getSeasonDayOfYearBounds(season, calendar, internalYear);\n if (bounds) return { start: bounds.startDoY, end: bounds.endDoY };\n }\n const start = normalizeRawDoy(season.dayStart ?? 0, calendar, internalYear);\n return { start, end: normalizeRawDoy(season.dayEnd ?? season.dayStart ?? 0, calendar, internalYear) };\n}\n\n/**\n * Get percentage progress through current season (0-100).\n * @param {number} dayOfYear - Day of year\n * @param {object[]} seasons - Seasons array\n * @param {number} totalDays - Total days in year\n * @param {number} idx - Season index\n * @param {number} [internalYear] - Year minus yearZero (for leap-aware month sizes)\n * @returns {number} Percentage 0-100\n */\nexport function getSeasonPercent(dayOfYear, seasons, totalDays, idx, internalYear = 0) {\n const { start, end } = resolveSeasonDayBounds(seasons, idx, totalDays, internalYear);\n let seasonLength, dayInSeason;\n if (start <= end) {\n seasonLength = end - start + 1;\n dayInSeason = dayOfYear - start;\n } else {\n seasonLength = totalDays - start + end + 1;\n dayInSeason = dayOfYear >= start ? dayOfYear - start : totalDays - start + dayOfYear;\n }\n return Math.round((dayInSeason / seasonLength) * 100);\n}\n\n/**\n * Get day number within current season (1-based).\n * @param {number} dayOfYear - Day of year\n * @param {object[]} seasons - Seasons array\n * @param {number} totalDays - Total days in year\n * @param {number} idx - Season index\n * @param {number} [internalYear] - Year minus yearZero (for leap-aware month sizes)\n * @returns {number} Day in season\n */\nexport function getSeasonDay(dayOfYear, seasons, totalDays, idx, internalYear = 0) {\n const { start } = resolveSeasonDayBounds(seasons, idx, totalDays, internalYear);\n if (dayOfYear >= start) return dayOfYear - start + 1;\n return totalDays - start + dayOfYear + 1;\n}\n\n/**\n * Find a season by its canonical type using the seasonalType metadata.\n * @param {object[]} seasons - Seasons array\n * @param {'spring'|'summer'|'autumn'|'winter'} type - Canonical seasonal type\n * @returns {number} Season index, or -1 if not found\n */\nexport function findSeasonIndexByType(seasons, type) {\n if (!Array.isArray(seasons) || !seasons.length || !type) return -1;\n return seasons.findIndex((s) => s?.seasonalType === type);\n}\n\n/**\n * Resolve a season's start/end to absolute 0-based day-of-year.\n * @param {object} season - Season record\n * @param {object} calendar - Active calendar instance\n * @param {number} [internalYear] - Year minus yearZero (for leap-aware month sizes)\n * @returns {{startDoY: number, endDoY: number}|null} Bounds or null if unresolvable\n */\nexport function getSeasonDayOfYearBounds(season, calendar, internalYear = 0) {\n if (!season || !calendar) return null;\n const months = calendar.monthsArray ?? [];\n if (season.monthStart != null && season.monthEnd != null && months.length) {\n let startDoY = 0;\n for (let m = 0; m < season.monthStart && m < months.length; m++) startDoY += calendar.getDaysInMonth(m, internalYear);\n startDoY += season.dayStart ?? 0;\n let endDoY = 0;\n for (let m = 0; m < season.monthEnd && m < months.length; m++) endDoY += calendar.getDaysInMonth(m, internalYear);\n const endMonthDays = calendar.getDaysInMonth(season.monthEnd, internalYear);\n endDoY += season.dayEnd ?? endMonthDays - 1;\n return { startDoY, endDoY };\n }\n if (season.dayStart == null) return null;\n return { startDoY: normalizeRawDoy(season.dayStart, calendar, internalYear), endDoY: normalizeRawDoy(season.dayEnd ?? season.dayStart, calendar, internalYear) };\n}\n\n/**\n * Check if date is a solstice or equinox.\n * @param {object} date - Date to check\n * @param {object[]} seasons - Seasons array\n * @param {string} type - 'longest', 'shortest', 'spring', 'autumn'\n * @returns {boolean} True if date matches the solstice or equinox\n */\nexport function checkSolsticeOrEquinox(date, seasons, type) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return false;\n const doy = getDayOfYear(date);\n const typeMap = { longest: 'summer', shortest: 'winter', spring: 'spring', autumn: 'autumn' };\n const seasonalType = typeMap[type];\n if (!seasonalType) return false;\n if (findSeasonIndexByType(seasons, seasonalType) === -1) return false;\n return doy === getAstronomicalDayOfYear(calendar, seasonalType, date.year);\n}\n\nconst astroDayCache = new Map();\n\n/**\n * Scan the calendar's daylight curve to find the longest and shortest days of a given year.\n * @param {object} calendar - Active calendar instance\n * @param {number} internalYear - Year minus yearZero\n * @param {number} totalDays - Total days in the year\n * @param {number} summerCfg - Configured summer solstice day-of-year (fallback)\n * @param {number} winterCfg - Configured winter solstice day-of-year (fallback)\n * @returns {{longest: number, shortest: number}} 0-based day-of-year of each solstice\n */\nfunction scanSolsticeDays(calendar, internalYear, totalDays, summerCfg, winterCfg) {\n const wrap = (d) => ((Math.round(d) % totalDays) + totalDays) % totalDays;\n const months = calendar?.monthsArray ?? [];\n let maxH = -Infinity;\n let minH = Infinity;\n let longest = wrap(summerCfg);\n let shortest = wrap(winterCfg);\n let doy = 0;\n let firstH = null;\n let varied = false;\n for (let m = 0; m < months.length; m++) {\n const dim = calendar.getDaysInMonth(m, internalYear);\n for (let d = 0; d < dim; d++) {\n const h = calendar.daylightHours?.({ year: internalYear, month: m, dayOfMonth: d, hour: 12 });\n if (Number.isFinite(h)) {\n if (firstH === null) firstH = h;\n else if (Math.abs(h - firstH) > 1e-9) varied = true;\n if (h > maxH) {\n maxH = h;\n longest = doy;\n }\n if (h < minH) {\n minH = h;\n shortest = doy;\n }\n }\n doy++;\n }\n }\n if (!varied) return { longest: wrap(summerCfg), shortest: wrap(winterCfg) };\n return { longest, shortest };\n}\n\n/**\n * Resolve the day-of-year for a solstice or equinox from the calendar's daylight curve.\n * @param {object} calendar - Active calendar instance\n * @param {string} seasonalType - 'summer', 'winter', 'spring', or 'autumn'\n * @param {number} year - Display year of the date being resolved\n * @returns {number|null} 0-based day-of-year, or null for an unknown type\n */\nexport function getAstronomicalDayOfYear(calendar, seasonalType, year) {\n const yearZero = calendar?.years?.yearZero ?? 0;\n const internalYear = year - yearZero;\n const totalDays = calendar?.getDaysInYear?.(internalYear) ?? getTotalDaysInYear(year);\n const wrap = (d) => ((Math.round(d) % totalDays) + totalDays) % totalDays;\n // Explicit equinox authoring wins over the solstice-midpoint derivation.\n if (seasonalType === 'spring' && calendar?.daylight?.springEquinox != null) return normalizeRawDoy(calendar.daylight.springEquinox, calendar, internalYear);\n if (seasonalType === 'autumn' && calendar?.daylight?.autumnEquinox != null) return normalizeRawDoy(calendar.daylight.autumnEquinox, calendar, internalYear);\n const summerCfg = calendar?.daylight?.summerSolstice != null ? normalizeRawDoy(calendar.daylight.summerSolstice, calendar, internalYear) : Math.round(totalDays * 0.47);\n const winterCfg = calendar?.daylight?.winterSolstice != null ? normalizeRawDoy(calendar.daylight.winterSolstice, calendar, internalYear) : wrap(summerCfg + totalDays / 2);\n const cacheKey = `${calendar?.metadata?.id ?? 'cal'}:${internalYear}:${summerCfg}:${winterCfg}:${totalDays}`;\n let solstices = astroDayCache.get(cacheKey);\n if (!solstices) {\n solstices = scanSolsticeDays(calendar, internalYear, totalDays, summerCfg, winterCfg);\n astroDayCache.set(cacheKey, solstices);\n }\n const { longest, shortest } = solstices;\n switch (seasonalType) {\n case 'summer':\n return longest;\n case 'winter':\n return shortest;\n case 'spring':\n return getMidpoint(shortest, longest, totalDays);\n case 'autumn':\n return getMidpoint(longest, shortest, totalDays);\n default:\n return null;\n }\n}\n\n/**\n * Get midpoint of a season range.\n * @param {number} start - Start day\n * @param {number} end - End day\n * @param {number} totalDays - Total days in year\n * @returns {number} Midpoint day\n */\nexport function getMidpoint(start, end, totalDays) {\n if (start <= end) return Math.floor((start + end) / 2);\n const length = totalDays - start + end + 1;\n const mid = Math.floor(length / 2);\n return (start + mid) % totalDays;\n}\n\n/**\n * Get moon phase index using the calendar's method.\n * @param {object} date - Date to check\n * @param {number} moonIndex - Index of the moon\n * @returns {number|null} Phase index or null\n */\nexport function getCalendarMoonPhaseIndex(date, moonIndex) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const yearZero = calendar.years?.yearZero ?? 0;\n const components = { year: date.year - yearZero, month: date.month, dayOfMonth: date.dayOfMonth, hour: 12, minute: 0, second: 0 };\n const moonPhaseInfo = calendar.getMoonPhase(moonIndex, components);\n return moonPhaseInfo?.phaseIndex ?? null;\n}\n\n/**\n * Count occurrences of current moon phase in the month up to date.\n * @param {object} date - Date to check\n * @param {number} moonIndex - Moon index\n * @returns {number} Count (1 = first occurrence)\n */\nexport function getMoonPhaseCountInMonth(date, moonIndex) {\n const currentPhaseIndex = getCalendarMoonPhaseIndex(date, moonIndex);\n if (currentPhaseIndex === null) return 0;\n let count = 0;\n let wasInPhase = false;\n for (let d = 0; d <= date.dayOfMonth; d++) {\n const phaseIndex = getCalendarMoonPhaseIndex({ ...date, dayOfMonth: d }, moonIndex);\n const isInPhase = phaseIndex === currentPhaseIndex;\n if (isInPhase && !wasInPhase) count++;\n wasInPhase = isInPhase;\n }\n return count;\n}\n\n/**\n * Count occurrences of current moon phase in the year up to date.\n * @param {object} date - Date to check\n * @param {number} moonIndex - Moon index\n * @returns {number} Count (1 = first occurrence)\n */\nexport function getMoonPhaseCountInYear(date, moonIndex) {\n const calendar = CalendarManager.getActiveCalendar();\n const currentPhaseIndex = getCalendarMoonPhaseIndex(date, moonIndex);\n if (currentPhaseIndex === null) return 0;\n const targetDayOfYear = getDayOfYear(date);\n let count = 0;\n let dayCounter = 0;\n let wasInPhase = false;\n const months = calendar?.monthsArray ?? [];\n for (let m = 0; m < months.length && dayCounter <= targetDayOfYear; m++) {\n const daysInMonth = months[m]?.days || 30;\n for (let d = 0; d < daysInMonth && dayCounter <= targetDayOfYear; d++) {\n dayCounter++;\n const phaseIndex = getCalendarMoonPhaseIndex({ year: date.year, month: m, dayOfMonth: d }, moonIndex);\n const isInPhase = phaseIndex === currentPhaseIndex;\n if (isInPhase && !wasInPhase) count++;\n wasInPhase = isInPhase;\n }\n }\n return count;\n}\n\n/**\n * Advance a date by one day using the calendar's month structure.\n * @param {object} date - Date {year, month, dayOfMonth}\n * @param {object[]} months - Calendar months array\n * @returns {object} New date advanced by one day\n */\nfunction advanceByOneDay(date, months) {\n let { year, month, dayOfMonth } = date;\n dayOfMonth++;\n if (dayOfMonth >= (months[month]?.days || 30)) {\n dayOfMonth = 0;\n month++;\n if (month >= months.length) {\n month = 0;\n year++;\n }\n }\n return { year, month, dayOfMonth };\n}\n\n/**\n * Count occurrences of current moon phase since epoch using cycle-length optimization.\n * Splits total days into complete cycles (each contributing exactly 1 occurrence)\n * plus a remainder iterated day-by-day. O(cycleLength) regardless of epoch size.\n * For randomized moons, falls back to capped day-by-day iteration.\n * @param {object} date - Date to check\n * @param {number} moonIndex - Moon index\n * @returns {number} Count (1 = first occurrence)\n */\nexport function getMoonPhaseCountSinceEpoch(date, moonIndex) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return 0;\n const currentPhaseIndex = getCalendarMoonPhaseIndex(date, moonIndex);\n if (currentPhaseIndex === null) return 0;\n const moons = calendar.moonsArray ?? [];\n const moon = moons[moonIndex];\n if (!moon?.cycleLength || moon.cycleLength <= 0) return 0;\n const totalDays = getTotalDaysSinceEpoch(date);\n if (totalDays < 0) return 0;\n if (moon.phaseMode === 'randomized') {\n const maxIterations = Math.min(totalDays + 1, 50000);\n const yearZero = calendar.years?.yearZero ?? 0;\n const months = calendar.monthsArray ?? [];\n let cur = { year: yearZero, month: 0, dayOfMonth: 0 };\n let count = 0;\n let wasInPhase = false;\n for (let i = 0; i < maxIterations; i++) {\n const phase = getCalendarMoonPhaseIndex(cur, moonIndex);\n if (phase === currentPhaseIndex && !wasInPhase) count++;\n wasInPhase = phase === currentPhaseIndex;\n if (i < maxIterations - 1) cur = advanceByOneDay(cur, months);\n }\n return Math.max(count, 1);\n }\n const cycleLen = Math.ceil(moon.cycleLength);\n const numDays = totalDays + 1;\n const fullCycles = Math.floor(numDays / cycleLen);\n const remainder = numDays - fullCycles * cycleLen;\n let count = fullCycles;\n if (remainder > 0) {\n const yearZero = calendar.years?.yearZero ?? 0;\n const months = calendar.monthsArray ?? [];\n let cur = { year: yearZero, month: 0, dayOfMonth: 0 };\n let wasInPhase = false;\n for (let i = 0; i < remainder; i++) {\n const phase = getCalendarMoonPhaseIndex(cur, moonIndex);\n if (phase === currentPhaseIndex && !wasInPhase) count++;\n wasInPhase = phase === currentPhaseIndex;\n if (i < remainder - 1) cur = advanceByOneDay(cur, months);\n }\n }\n return Math.max(count, 1);\n}\n\n/**\n * Get cycle value for a date.\n * @param {object} date - Date to check\n * @param {object} cycle - Cycle configuration\n * @returns {number} Cycle entry index\n */\nexport function getCycleValue(date, cycle) {\n if (!cycle?.length || !Object.keys(cycle?.stages ?? {}).length) return 0;\n let value;\n switch (cycle.basedOn) {\n case 'year':\n value = date.year;\n break;\n case 'eraYear':\n value = getEraYear(date.year, CalendarManager.getActiveCalendar()?.erasArray ?? []);\n break;\n case 'month':\n value = date.month;\n break;\n case 'monthDay':\n value = date.dayOfMonth + 1;\n break;\n case 'yearDay':\n value = getDayOfYear(date);\n break;\n case 'day':\n default:\n value = getTotalDaysSinceEpoch(date);\n break;\n }\n return (((value - (cycle.offset || 0)) % cycle.length) + cycle.length) % cycle.length;\n}\n\n/**\n * Get era index for a year.\n * @param {number} year - Year to check\n * @param {object[]} eras - Eras array\n * @returns {number} Era index\n */\nexport function getEraIndex(year, eras) {\n for (let i = eras.length - 1; i >= 0; i--) {\n const era = eras[i];\n if (year >= (era.startYear ?? 0)) if (era.endYear == null || year <= era.endYear) return i;\n }\n return 0;\n}\n\n/**\n * Get year within current era.\n * @param {number} year - Year to check\n * @param {object[]} eras - Eras array\n * @returns {number} Year in era\n */\nexport function getEraYear(year, eras) {\n const idx = getEraIndex(year, eras);\n const era = eras[idx];\n if (!era) return year;\n return year - (era.startYear ?? 0) + 1;\n}\n","import { MODULE, SETTINGS } from '../constants.mjs';\n\n/** @type {Object} Default permission settings for each action. */\nconst DEFAULTS = {\n viewBigCal: { player: false, trusted: true, assistant: true },\n viewChronicle: { player: false, trusted: true, assistant: true },\n viewHUD: { player: true, trusted: true, assistant: true },\n viewMiniCal: { player: false, trusted: true, assistant: true },\n viewNotes: { player: true, trusted: true, assistant: true },\n viewStopwatch: { player: false, trusted: true, assistant: true },\n viewSunDial: { player: false, trusted: true, assistant: true },\n viewTimeKeeper: { player: false, trusted: true, assistant: true },\n addNotes: { player: true, trusted: true, assistant: true },\n changeDateTime: { player: false, trusted: false, assistant: true },\n changeActiveCalendar: { player: false, trusted: false, assistant: false },\n changeWeather: { player: false, trusted: false, assistant: true },\n deleteNotes: { player: false, trusted: false, assistant: true },\n editCalendars: { player: false, trusted: false, assistant: false },\n viewWeatherForecast: { player: false, trusted: true, assistant: true }\n};\n\n/**\n * Check if the current user has a specific permission.\n * @param {string} permissionKey - The permission key to check\n * @returns {boolean} True if user has the permission\n */\nexport function hasPermission(permissionKey) {\n if (game.user.isGM) return true;\n const saved = game.settings.get(MODULE.ID, SETTINGS.PERMISSIONS) || {};\n const perms = saved[permissionKey] || DEFAULTS[permissionKey] || {};\n if (perms.player) return true;\n if (perms.trusted && game.user.isTrusted) return true;\n if (perms.assistant && game.user.role === CONST.USER_ROLES.ASSISTANT) return true;\n return false;\n}\n\n/**\n * Check whether moons should be shown to the current user. GMs always see them.\n * @returns {boolean} True if moon display is allowed for this user\n */\nexport function canViewMoons() {\n return game.user.isGM || !game.settings.get(MODULE.ID, SETTINGS.HIDE_MOONS_FROM_PLAYERS);\n}\n\n/**\n * Check if the current user can view the BigCal.\n * @returns {boolean} True if user has permission\n */\nexport function canViewBigCal() {\n return hasPermission('viewBigCal');\n}\n\n/**\n * Check if the current user can view the Chronicle.\n * @returns {boolean} True if user has permission\n */\nexport function canViewChronicle() {\n return hasPermission('viewChronicle');\n}\n\n/**\n * Check if the current user can view the HUD.\n * @returns {boolean} True if user has permission\n */\nexport function canViewHUD() {\n return hasPermission('viewHUD');\n}\n\n/**\n * Check if the current user can view the MiniCal.\n * @returns {boolean} True if user has permission\n */\nexport function canViewMiniCal() {\n return hasPermission('viewMiniCal');\n}\n\n/**\n * Check if the current user can open the Note Viewer.\n * @returns {boolean} True if user has permission\n */\nexport function canViewNotes() {\n return hasPermission('viewNotes');\n}\n\n/**\n * Check if the current user can view the Stopwatch.\n * @returns {boolean} True if user has permission\n */\nexport function canViewStopwatch() {\n return hasPermission('viewStopwatch');\n}\n\n/**\n * Check if the current user can view the Sun Dial.\n * @returns {boolean} True if user has permission\n */\nexport function canViewSunDial() {\n return hasPermission('viewSunDial');\n}\n\n/**\n * Check if the current user can view the TimeKeeper.\n * @returns {boolean} True if user has permission\n */\nexport function canViewTimeKeeper() {\n return hasPermission('viewTimeKeeper');\n}\n\n/**\n * Check if the current user can add notes.\n * @returns {boolean} True if user has permission\n */\nexport function canAddNotes() {\n return hasPermission('addNotes');\n}\n\n/**\n * Check if the current user can change the date/time.\n * @returns {boolean} True if user has permission\n */\nexport function canChangeDateTime() {\n return hasPermission('changeDateTime');\n}\n\n/**\n * Check if the current user can change the active calendar.\n * @returns {boolean} True if user has permission\n */\nexport function canChangeActiveCalendar() {\n return hasPermission('changeActiveCalendar');\n}\n\n/**\n * Check if the current user can change weather.\n * @returns {boolean} True if user has permission\n */\nexport function canChangeWeather() {\n return hasPermission('changeWeather');\n}\n\n/**\n * Check if the current user can delete notes.\n * @returns {boolean} True if user has permission\n */\nexport function canDeleteNotes() {\n return hasPermission('deleteNotes');\n}\n\n/**\n * Check if the current user can edit calendars.\n * @returns {boolean} True if user has permission\n */\nexport function canEditCalendars() {\n return hasPermission('editCalendars');\n}\n\n/**\n * Check if the current user can view weather forecasts.\n * @returns {boolean} True if user has permission\n */\nexport function canViewWeatherForecast() {\n return hasPermission('viewWeatherForecast');\n}\n\n/**\n * Get all users who have a specific permission.\n * Exposed at runtime as `CALENDARIA.permissions.getUsersWithPermission` via the\n * `import * as Permissions` namespace spread in `scripts/api.mjs`.\n * @param {string} permissionKey - The permission key to check\n * @returns {object[]} Array of users with the permission\n */\nexport function getUsersWithPermission(permissionKey) {\n const saved = game.settings.get(MODULE.ID, SETTINGS.PERMISSIONS) || {};\n const perms = saved[permissionKey] || DEFAULTS[permissionKey] || {};\n return game.users.filter((user) => {\n if (user.isGM) return true;\n if (perms.player) return true;\n if (perms.trusted && user.isTrusted) return true;\n if (perms.assistant && user.role === CONST.USER_ROLES.ASSISTANT) return true;\n return false;\n });\n}\n\n/**\n * Get active non-GM users who lack a specific view permission.\n * @param {string} permissionKey - The permission key to check\n * @returns {object[]} Array of active users blocked by the permission\n */\nexport function getBlockedActiveUsers(permissionKey) {\n const saved = game.settings.get(MODULE.ID, SETTINGS.PERMISSIONS) || {};\n const perms = saved[permissionKey] || DEFAULTS[permissionKey] || {};\n return game.users.filter((user) => {\n if (!user.active || user.isGM) return false;\n if (perms.player) return false;\n if (perms.trusted && user.isTrusted) return false;\n if (perms.assistant && user.role === CONST.USER_ROLES.ASSISTANT) return false;\n return true;\n });\n}\n\n/**\n * Warn the GM when some active players lack view permission for a widget.\n * @param {string} permissionKey - The view permission key for the widget\n * @param {string} featureLabel - Localized name of the feature/widget\n */\nexport function warnShowToAll(permissionKey, featureLabel) {\n const blocked = getBlockedActiveUsers(permissionKey);\n if (blocked.length === 0) return;\n const names = blocked.map((u) => u.name).join(', ');\n ui.notifications.warn(_loc('CALENDARIA.Permissions.MismatchWarning', { names, feature: featureLabel }));\n}\n","import { MODULE, SETTINGS } from '../../constants.mjs';\n\n/**\n * Available HUD visual effects for weather overlays (dome and slice modes).\n * @type {string[]}\n */\nexport const HUD_EFFECTS = [\n 'clear',\n 'clouds-light',\n 'clouds-heavy',\n 'clouds-overcast',\n 'rain',\n 'rain-heavy',\n 'snow',\n 'snow-heavy',\n 'fog',\n 'lightning',\n 'sand',\n 'ashfall',\n 'embers',\n 'ice',\n 'hail',\n 'tornado',\n 'hurricane',\n 'nullstatic',\n 'gust',\n 'aurora',\n 'aether',\n 'void',\n 'spectral',\n 'arcane',\n 'arcane-wind',\n 'veil',\n 'petals',\n 'sleet',\n 'haze',\n 'leaves',\n 'smoke',\n 'rain-acid',\n 'rain-blood',\n 'meteors',\n 'spores',\n 'divine',\n 'miasma',\n 'ley-surge'\n];\n\n/** @type {string} Base path for built-in weather sound assets. */\nconst SOUND_BASE = `modules/${MODULE.ID}/assets/sound`;\n\n/**\n * Available built-in sound effect files for weather ambient loops (full paths).\n * @type {string[]}\n */\nexport const SOUND_FX_OPTIONS = [\n `${SOUND_BASE}/rain-acid-rain-blood-rain.ogg`,\n `${SOUND_BASE}/sunshower-drizzle.ogg`,\n `${SOUND_BASE}/thunderstorm.ogg`,\n `${SOUND_BASE}/sleet-hail.ogg`,\n `${SOUND_BASE}/blizzard-ice-storm.ogg`,\n `${SOUND_BASE}/snow-frost.ogg`,\n `${SOUND_BASE}/hurricane-monsoon-tornado.ogg`,\n `${SOUND_BASE}/sandstorm-dust-devil.ogg`,\n `${SOUND_BASE}/wind.ogg`\n];\n\n/**\n * Resolve a sound identifier to a full Foundry-loadable path. Bare identifiers\n * (e.g. `'thunderstorm'`) are expanded against the built-in sound directory; values\n * containing a `/` are treated as already-resolved paths and returned unchanged.\n * @param {string|null} key - Bare sound identifier or full path\n * @returns {string|null} Full sound path or null\n */\nexport function resolveWeatherSoundPath(key) {\n if (!key) return null;\n if (key.includes('/')) return key;\n return `${SOUND_BASE}/${key}.ogg`;\n}\n\n/**\n * Standard weather conditions - common everyday weather.\n * @type {object[]}\n */\nexport const STANDARD_WEATHER = [\n {\n id: 'clear',\n label: 'CALENDARIA.Common.Clear',\n description: 'CALENDARIA.Weather.ClearDesc',\n icon: 'fa-sun',\n color: '#FFEE88',\n category: 'standard',\n chance: 15,\n tempMin: 18,\n tempMax: 32,\n darknessPenalty: 0,\n environmentBase: null,\n environmentDark: null,\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.2,\n hudEffect: 'clear',\n fxPreset: null,\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: null\n },\n {\n id: 'partly-cloudy',\n label: 'CALENDARIA.Common.PartlyCloudy',\n description: 'CALENDARIA.Weather.PartlyCloudyDesc',\n icon: 'fa-cloud-sun',\n color: '#D0E8FF',\n category: 'standard',\n chance: 18,\n tempMin: 15,\n tempMax: 28,\n darknessPenalty: 0,\n environmentBase: null,\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.0,\n hudEffect: 'clouds-light',\n fxPreset: 'partly-cloudy',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: null\n },\n {\n id: 'cloudy',\n label: 'CALENDARIA.Common.Cloudy',\n description: 'CALENDARIA.Weather.CloudyDesc',\n icon: 'fa-cloud',\n color: '#B0C4DE',\n category: 'standard',\n chance: 14,\n tempMin: 12,\n tempMax: 24,\n darknessPenalty: 0,\n environmentBase: { hue: 220, saturation: 0.3 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.2,\n hudEffect: 'clouds-heavy',\n fxPreset: 'cloudy',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: null\n },\n {\n id: 'overcast',\n label: 'CALENDARIA.Common.Overcast',\n description: 'CALENDARIA.Weather.OvercastDesc',\n icon: 'fa-smog',\n color: '#CCCCCC',\n category: 'standard',\n chance: 10,\n tempMin: 10,\n tempMax: 20,\n darknessPenalty: 0.05,\n environmentBase: { hue: 220, saturation: 0.2 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.5,\n hudEffect: 'clouds-overcast',\n fxPreset: 'overcast',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: null\n },\n {\n id: 'drizzle',\n label: 'CALENDARIA.Common.Drizzle',\n description: 'CALENDARIA.Weather.DrizzleDesc',\n icon: 'fa-cloud-rain',\n color: '#CDEFFF',\n category: 'standard',\n chance: 8,\n tempMin: 8,\n tempMax: 18,\n darknessPenalty: 0,\n environmentBase: { hue: 210, saturation: 0.3 },\n environmentDark: null,\n wind: { speed: 0, direction: null },\n precipitation: { type: 'drizzle', intensity: 0.2 },\n inertiaWeight: 1.0,\n hudEffect: 'rain',\n fxPreset: 'drizzle',\n fxDensity: 'low',\n fxSpeed: 'low',\n fxColor: null,\n soundFx: `${SOUND_BASE}/sunshower-drizzle.ogg`\n },\n {\n id: 'rain',\n label: 'CALENDARIA.Common.Rain',\n description: 'CALENDARIA.Weather.RainDesc',\n icon: 'fa-cloud-showers-heavy',\n color: '#A0D8EF',\n category: 'standard',\n chance: 10,\n tempMin: 10,\n tempMax: 22,\n darknessPenalty: 0.05,\n environmentBase: { hue: 210, saturation: 0.4 },\n environmentDark: null,\n wind: { speed: 2, direction: null },\n precipitation: { type: 'rain', intensity: 0.6 },\n inertiaWeight: 1.3,\n hudEffect: 'rain',\n fxPreset: 'rain',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/rain-acid-rain-blood-rain.ogg`\n },\n {\n id: 'fog',\n label: 'CALENDARIA.Common.Fog',\n description: 'CALENDARIA.Weather.FogDesc',\n icon: 'fa-smog',\n color: '#E6E6E6',\n category: 'standard',\n chance: 5,\n tempMin: 5,\n tempMax: 15,\n darknessPenalty: 0.05,\n environmentBase: { hue: 200, saturation: 0.2 },\n environmentDark: null,\n wind: { speed: 0, direction: null },\n precipitation: { type: 'drizzle', intensity: 0.1 },\n inertiaWeight: 1.5,\n hudEffect: 'fog',\n fxPreset: 'fog',\n fxDensity: null,\n fxSpeed: 'low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'mist',\n label: 'CALENDARIA.Common.Mist',\n description: 'CALENDARIA.Weather.MistDesc',\n icon: 'fa-water',\n color: '#F0F8FF',\n category: 'standard',\n chance: 4,\n tempMin: 8,\n tempMax: 18,\n darknessPenalty: 0,\n environmentBase: { hue: 200, saturation: 0.2 },\n environmentDark: null,\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.0,\n hudEffect: 'fog',\n fxPreset: 'mist',\n fxDensity: 'low',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'windy',\n label: 'CALENDARIA.Common.Windy',\n description: 'CALENDARIA.Weather.WindyDesc',\n icon: 'fa-wind',\n color: '#E0F7FA',\n category: 'standard',\n chance: 4,\n tempMin: 10,\n tempMax: 25,\n darknessPenalty: 0,\n environmentBase: null,\n environmentDark: null,\n wind: { speed: 3, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.0,\n hudEffect: 'gust',\n fxPreset: null,\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/wind.ogg`\n },\n {\n id: 'sunshower',\n label: 'CALENDARIA.Common.Sunshower',\n description: 'CALENDARIA.Weather.SunshowerDesc',\n icon: 'fa-cloud-sun-rain',\n color: '#FCEABB',\n category: 'standard',\n chance: 2,\n tempMin: 15,\n tempMax: 26,\n darknessPenalty: 0,\n environmentBase: null,\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: 'rain', intensity: 0.3 },\n inertiaWeight: 0,\n hudEffect: 'rain',\n fxPreset: 'sunshower',\n fxDensity: 'low',\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/sunshower-drizzle.ogg`\n },\n {\n id: 'snow',\n label: 'CALENDARIA.Common.Snow',\n description: 'CALENDARIA.Weather.SnowDesc',\n icon: 'fa-snowflake',\n color: '#FFFFFF',\n category: 'standard',\n chance: 1,\n tempMin: -10,\n tempMax: 2,\n darknessPenalty: 0,\n environmentBase: { hue: 200, saturation: 0.6 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: 'snow', intensity: 0.5 },\n inertiaWeight: 1.3,\n hudEffect: 'snow',\n fxPreset: 'snow',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/snow-frost.ogg`\n },\n {\n id: 'sleet',\n label: 'CALENDARIA.Common.Sleet',\n description: 'CALENDARIA.Weather.SleetDesc',\n icon: 'fa-cloud-rain',\n color: '#C0D8E8',\n category: 'standard',\n chance: 1,\n tempMin: -2,\n tempMax: 4,\n darknessPenalty: 0.05,\n environmentBase: { hue: 200, saturation: 0.5 },\n environmentDark: null,\n wind: { speed: 2, direction: null },\n precipitation: { type: 'sleet', intensity: 0.5 },\n inertiaWeight: 1.0,\n hudEffect: 'sleet',\n fxPreset: 'sleet',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/sleet-hail.ogg`\n },\n {\n id: 'heat-wave',\n label: 'CALENDARIA.Common.HeatWave',\n description: 'CALENDARIA.Weather.HeatWaveDesc',\n icon: 'fa-temperature-arrow-up',\n color: '#FF9944',\n category: 'standard',\n chance: 1,\n tempMin: 35,\n tempMax: 48,\n darknessPenalty: 0,\n environmentBase: { hue: 30, saturation: 0.4 },\n environmentDark: null,\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.5,\n hudEffect: 'haze',\n fxPreset: 'heat-wave',\n fxDensity: 'low',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n }\n];\n\n/**\n * Severe weather conditions - dangerous or extreme weather.\n * @type {object[]}\n */\nexport const SEVERE_WEATHER = [\n {\n id: 'thunderstorm',\n label: 'CALENDARIA.Common.Thunderstorm',\n description: 'CALENDARIA.Weather.ThunderstormDesc',\n icon: 'fa-cloud-bolt',\n color: '#3D3560',\n category: 'severe',\n chance: 2,\n tempMin: 15,\n tempMax: 28,\n darknessPenalty: 0.1,\n environmentBase: { hue: 220, saturation: 0.4 },\n environmentDark: null,\n wind: { speed: 4, direction: null, forced: true },\n precipitation: { type: 'rain', intensity: 0.9 },\n inertiaWeight: 0.3,\n hudEffect: 'lightning',\n fxPreset: 'thunderstorm',\n fxDensity: 'high',\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/thunderstorm.ogg`\n },\n {\n id: 'blizzard',\n label: 'CALENDARIA.Common.Blizzard',\n description: 'CALENDARIA.Weather.BlizzardDesc',\n icon: 'fa-snowflake',\n color: '#C8DCE8',\n category: 'severe',\n chance: 0.5,\n tempMin: -20,\n tempMax: -5,\n darknessPenalty: 0.15,\n environmentBase: { hue: 200, saturation: 0.3 },\n environmentDark: { hue: 210, saturation: null },\n wind: { speed: 5, direction: null, forced: true },\n precipitation: { type: 'snow', intensity: 1.0 },\n inertiaWeight: 0.5,\n hudEffect: 'snow-heavy',\n fxPreset: 'blizzard',\n fxDensity: 'very-high',\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/blizzard-ice-storm.ogg`\n },\n {\n id: 'hail',\n label: 'CALENDARIA.Common.Hail',\n description: 'CALENDARIA.Weather.HailDesc',\n icon: 'fa-cloud-meatball',\n color: '#D1EFFF',\n category: 'severe',\n chance: 0.5,\n tempMin: 5,\n tempMax: 18,\n darknessPenalty: 0.05,\n environmentBase: { hue: null, saturation: 0.5 },\n environmentDark: null,\n wind: { speed: 3, direction: null },\n precipitation: { type: 'hail', intensity: 0.7 },\n inertiaWeight: 0.3,\n hudEffect: 'hail',\n fxPreset: 'hail',\n fxDensity: null,\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/sleet-hail.ogg`\n },\n {\n id: 'tornado',\n label: 'CALENDARIA.Common.Tornado',\n description: 'CALENDARIA.Weather.TornadoDesc',\n icon: 'fa-tornado',\n color: '#4A5A3A',\n category: 'severe',\n chance: 0.5,\n tempMin: 18,\n tempMax: 35,\n darknessPenalty: 0.15,\n environmentBase: { hue: 100, saturation: 0.4 },\n environmentDark: null,\n wind: { speed: 5, direction: null, forced: true },\n precipitation: { type: 'rain', intensity: 0.8 },\n inertiaWeight: 0,\n hudEffect: 'tornado',\n fxPreset: 'tornado',\n fxDensity: 'very-high',\n fxSpeed: 'very-high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/hurricane-monsoon-tornado.ogg`\n },\n {\n id: 'hurricane',\n label: 'CALENDARIA.Common.Hurricane',\n description: 'CALENDARIA.Weather.HurricaneDesc',\n icon: 'fa-hurricane',\n color: '#445566',\n category: 'severe',\n chance: 0.5,\n tempMin: 22,\n tempMax: 35,\n darknessPenalty: 0.15,\n environmentBase: { hue: null, saturation: 0.3 },\n environmentDark: null,\n wind: { speed: 5, direction: null, forced: true },\n precipitation: { type: 'rain', intensity: 1.0 },\n inertiaWeight: 0,\n hudEffect: 'hurricane',\n fxPreset: 'hurricane',\n fxDensity: 'very-high',\n fxSpeed: 'very-high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/hurricane-monsoon-tornado.ogg`\n },\n {\n id: 'ice-storm',\n label: 'CALENDARIA.Common.IceStorm',\n description: 'CALENDARIA.Weather.IceStormDesc',\n icon: 'fa-icicles',\n color: '#A0C8E0',\n category: 'severe',\n chance: 0.5,\n tempMin: -10,\n tempMax: 0,\n darknessPenalty: 0.1,\n environmentBase: { hue: 200, saturation: 0.5 },\n environmentDark: { hue: 210, saturation: 0.4 },\n wind: { speed: 4, direction: null, forced: true },\n precipitation: { type: 'hail', intensity: 0.8 },\n inertiaWeight: 0.3,\n hudEffect: 'ice',\n fxPreset: 'ice-storm',\n fxDensity: 'high',\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/blizzard-ice-storm.ogg`\n },\n {\n id: 'monsoon',\n label: 'CALENDARIA.Common.Monsoon',\n description: 'CALENDARIA.Weather.MonsoonDesc',\n icon: 'fa-cloud-showers-water',\n color: '#3A6080',\n category: 'severe',\n chance: 0.5,\n tempMin: 22,\n tempMax: 35,\n darknessPenalty: 0.1,\n environmentBase: { hue: null, saturation: 0.4 },\n environmentDark: null,\n wind: { speed: 4, direction: null, forced: true },\n precipitation: { type: 'rain', intensity: 1.0 },\n inertiaWeight: 0.5,\n hudEffect: 'rain-heavy',\n fxPreset: 'monsoon',\n fxDensity: 'very-high',\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/hurricane-monsoon-tornado.ogg`\n }\n];\n\n/**\n * Environmental weather conditions - location-specific phenomena.\n * @type {object[]}\n */\nexport const ENVIRONMENTAL_WEATHER = [\n {\n id: 'ashfall',\n label: 'CALENDARIA.Common.Ashfall',\n description: 'CALENDARIA.Weather.AshfallDesc',\n icon: 'fa-volcano',\n color: '#8B5A30',\n category: 'environmental',\n chance: 1.5,\n tempMin: 15,\n tempMax: 40,\n darknessPenalty: 0.1,\n environmentBase: { hue: 30, saturation: 0.4 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0.5,\n hudEffect: 'ashfall',\n fxPreset: 'ashfall',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: null\n },\n {\n id: 'sandstorm',\n label: 'CALENDARIA.Common.Sandstorm',\n description: 'CALENDARIA.Weather.SandstormDesc',\n icon: 'fa-wind',\n color: '#C49A44',\n category: 'environmental',\n chance: 1.5,\n tempMin: 25,\n tempMax: 45,\n darknessPenalty: 0.1,\n environmentBase: { hue: 35, saturation: 0.6 },\n environmentDark: null,\n wind: { speed: 4, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0.3,\n hudEffect: 'sand',\n fxPreset: 'sandstorm',\n fxDensity: 'high',\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/sandstorm-dust-devil.ogg`\n },\n {\n id: 'luminous-sky',\n label: 'CALENDARIA.Common.LuminousSky',\n description: 'CALENDARIA.Weather.LuminousSkyDesc',\n icon: 'fa-star',\n color: '#2E8B57',\n category: 'environmental',\n chance: 1.5,\n tempMin: -5,\n tempMax: 10,\n darknessPenalty: -0.1,\n environmentBase: null,\n environmentDark: { hue: 280, saturation: 0.8 },\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'aurora',\n fxPreset: 'luminous-sky',\n fxDensity: 'low',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'sakura-bloom',\n label: 'CALENDARIA.Common.SakuraBloom',\n description: 'CALENDARIA.Weather.SakuraBloomDesc',\n icon: 'fa-spa',\n color: '#ffb7c5',\n category: 'environmental',\n chance: 1.5,\n tempMin: 18,\n tempMax: 32,\n darknessPenalty: 0,\n environmentBase: null,\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'petals',\n fxPreset: 'sakura-bloom',\n fxDensity: 'low',\n fxSpeed: 'low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'autumn-leaves',\n label: 'CALENDARIA.Common.AutumnLeaves',\n description: 'CALENDARIA.Weather.AutumnLeavesDesc',\n icon: 'fa-leaf',\n color: '#CC7733',\n category: 'environmental',\n chance: 1.5,\n tempMin: 5,\n tempMax: 18,\n darknessPenalty: 0,\n environmentBase: { hue: 30, saturation: 0.6 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'leaves',\n fxPreset: 'autumn-leaves',\n fxDensity: 'low',\n fxSpeed: 'low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'rolling-fog',\n label: 'CALENDARIA.Common.RollingFog',\n description: 'CALENDARIA.Weather.RollingFogDesc',\n icon: 'fa-smog',\n color: '#D0D0D0',\n category: 'environmental',\n chance: 1.5,\n tempMin: 2,\n tempMax: 12,\n darknessPenalty: 0.05,\n environmentBase: { hue: null, saturation: 0.2 },\n environmentDark: null,\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 1.5,\n hudEffect: 'fog',\n fxPreset: 'rolling-fog',\n fxDensity: 'high',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'wildfire-smoke',\n label: 'CALENDARIA.Common.WildfireSmoke',\n description: 'CALENDARIA.Weather.WildfireSmokeDesc',\n icon: 'fa-fire',\n color: '#8B6040',\n category: 'environmental',\n chance: 1,\n tempMin: 20,\n tempMax: 40,\n darknessPenalty: 0.1,\n environmentBase: { hue: 25, saturation: 0.5 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0.5,\n hudEffect: 'smoke',\n fxPreset: 'wildfire-smoke',\n fxDensity: null,\n fxSpeed: 'low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'dust-devil',\n label: 'CALENDARIA.Common.DustDevil',\n description: 'CALENDARIA.Weather.DustDevilDesc',\n icon: 'fa-wind',\n color: '#C8A060',\n category: 'environmental',\n chance: 1,\n tempMin: 28,\n tempMax: 45,\n darknessPenalty: 0.1,\n environmentBase: { hue: 35, saturation: 0.5 },\n environmentDark: null,\n wind: { speed: 3, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'sand',\n fxPreset: 'dust-devil',\n fxDensity: null,\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/sandstorm-dust-devil.ogg`\n }\n];\n\n/**\n * Fantasy weather conditions - magical or supernatural phenomena.\n * @type {object[]}\n */\nexport const FANTASY_WEATHER = [\n {\n id: 'black-sun',\n label: 'CALENDARIA.Common.BlackSun',\n description: 'CALENDARIA.Weather.BlackSunDesc',\n icon: 'fa-circle',\n color: '#1A0E22',\n category: 'fantasy',\n chance: 0.5,\n tempMin: 5,\n tempMax: 20,\n darknessPenalty: 0.3,\n environmentBase: { hue: 270, saturation: 0.3 },\n environmentDark: { hue: 280, saturation: 0.4 },\n wind: { speed: 1, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'void',\n fxPreset: 'black-sun',\n fxDensity: 'low',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'ley-surge',\n label: 'CALENDARIA.Common.LeySurge',\n description: 'CALENDARIA.Weather.LeySurgeDesc',\n icon: 'fa-wand-sparkles',\n color: '#3A9BDC',\n category: 'fantasy',\n chance: 0,\n tempMin: 10,\n tempMax: 25,\n darknessPenalty: -0.1,\n environmentBase: { hue: 180, saturation: 0.9 },\n environmentDark: { hue: 200, saturation: 0.8 },\n wind: { speed: 2, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'ley-surge',\n fxPreset: 'ley-surge',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: null\n },\n {\n id: 'aether-haze',\n label: 'CALENDARIA.Common.AetherHaze',\n description: 'CALENDARIA.Weather.AetherHazeDesc',\n icon: 'fa-smog',\n color: '#7B3F96',\n category: 'fantasy',\n chance: 0,\n tempMin: 12,\n tempMax: 22,\n darknessPenalty: 0.15,\n environmentBase: { hue: 280, saturation: 0.6 },\n environmentDark: { hue: 270, saturation: 0.7 },\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'aether',\n fxPreset: 'aether-haze',\n fxDensity: null,\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'nullfront',\n label: 'CALENDARIA.Common.Nullfront',\n description: 'CALENDARIA.Weather.NullfrontDesc',\n icon: 'fa-ban',\n color: '#2A2030',\n category: 'fantasy',\n chance: 0,\n tempMin: 0,\n tempMax: 15,\n darknessPenalty: 0.15,\n environmentBase: { hue: null, saturation: 0.1 },\n environmentDark: { hue: null, saturation: 0.1 },\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'nullstatic',\n fxPreset: 'nullfront',\n fxDensity: 'low',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'permafrost-surge',\n label: 'CALENDARIA.Common.PermafrostSurge',\n description: 'CALENDARIA.Weather.PermafrostSurgeDesc',\n icon: 'fa-icicles',\n color: '#A8D8EA',\n category: 'fantasy',\n chance: 0,\n tempMin: -30,\n tempMax: -10,\n darknessPenalty: 0.1,\n environmentBase: { hue: 190, saturation: 0.7 },\n environmentDark: { hue: 200, saturation: 0.6 },\n wind: { speed: 3, direction: null },\n precipitation: { type: 'snow', intensity: 0.4 },\n inertiaWeight: 0,\n hudEffect: 'ice',\n fxPreset: 'permafrost-surge',\n fxDensity: 'high',\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/snow-frost.ogg`\n },\n {\n id: 'gravewind',\n label: 'CALENDARIA.Common.Gravewind',\n description: 'CALENDARIA.Weather.GravewindDesc',\n icon: 'fa-ghost',\n color: '#3A5040',\n category: 'fantasy',\n chance: 0,\n tempMin: 5,\n tempMax: 18,\n darknessPenalty: 0.15,\n environmentBase: { hue: 250, saturation: 0.5 },\n environmentDark: { hue: 260, saturation: 0.6 },\n wind: { speed: 3, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'spectral',\n fxPreset: 'gravewind',\n fxDensity: null,\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/wind.ogg`\n },\n {\n id: 'veilfall',\n label: 'CALENDARIA.Common.Veilfall',\n description: 'CALENDARIA.Weather.VeilfallDesc',\n icon: 'fa-droplet',\n color: '#6A5A8E',\n category: 'fantasy',\n chance: 0,\n tempMin: 8,\n tempMax: 20,\n darknessPenalty: 0.1,\n environmentBase: { hue: 180, saturation: 0.4 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: 'rain', intensity: 0.3 },\n inertiaWeight: 0,\n hudEffect: 'veil',\n fxPreset: 'veilfall',\n fxDensity: 'low',\n fxSpeed: 'low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'arcane-winds',\n label: 'CALENDARIA.Common.ArcaneWinds',\n description: 'CALENDARIA.Weather.ArcaneWindsDesc',\n icon: 'fa-hat-wizard',\n color: '#8A40B0',\n category: 'fantasy',\n chance: 0,\n tempMin: 15,\n tempMax: 28,\n darknessPenalty: -0.05,\n environmentBase: { hue: 50, saturation: 0.8 },\n environmentDark: null,\n wind: { speed: 2, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'arcane-wind',\n fxPreset: 'arcane-winds',\n fxDensity: null,\n fxSpeed: 'high',\n fxColor: null,\n soundFx: `${SOUND_BASE}/wind.ogg`\n },\n {\n id: 'acid-rain',\n label: 'CALENDARIA.Common.AcidRain',\n description: 'CALENDARIA.Weather.AcidRainDesc',\n icon: 'fa-flask',\n color: '#55BB33',\n category: 'fantasy',\n chance: 0,\n tempMin: 10,\n tempMax: 25,\n darknessPenalty: 0.05,\n environmentBase: { hue: 100, saturation: 0.6 },\n environmentDark: null,\n wind: { speed: 1, direction: null },\n precipitation: { type: 'rain', intensity: 0.6 },\n inertiaWeight: 0,\n hudEffect: 'rain-acid',\n fxPreset: 'acid-rain',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/rain-acid-rain-blood-rain.ogg`\n },\n {\n id: 'blood-rain',\n label: 'CALENDARIA.Common.BloodRain',\n description: 'CALENDARIA.Weather.BloodRainDesc',\n icon: 'fa-droplet',\n color: '#880022',\n category: 'fantasy',\n chance: 0,\n tempMin: 12,\n tempMax: 28,\n darknessPenalty: 0.1,\n environmentBase: { hue: 0, saturation: 0.7 },\n environmentDark: { hue: 350, saturation: 0.6 },\n wind: { speed: 1, direction: null },\n precipitation: { type: 'rain', intensity: 0.7 },\n inertiaWeight: 0,\n hudEffect: 'rain-blood',\n fxPreset: 'blood-rain',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: `${SOUND_BASE}/rain-acid-rain-blood-rain.ogg`\n },\n {\n id: 'meteor-shower',\n label: 'CALENDARIA.Common.MeteorShower',\n description: 'CALENDARIA.Weather.MeteorShowerDesc',\n icon: 'fa-meteor',\n color: '#FF6622',\n category: 'fantasy',\n chance: 0,\n tempMin: 10,\n tempMax: 30,\n darknessPenalty: -0.1,\n environmentBase: null,\n environmentDark: { hue: 20, saturation: 0.7 },\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'meteors',\n fxPreset: 'meteor-shower',\n fxDensity: null,\n fxSpeed: null,\n fxColor: null,\n soundFx: null\n },\n {\n id: 'spore-cloud',\n label: 'CALENDARIA.Common.SporeCloud',\n description: 'CALENDARIA.Weather.SporeCloudDesc',\n icon: 'fa-disease',\n color: '#88AA44',\n category: 'fantasy',\n chance: 0,\n tempMin: 15,\n tempMax: 28,\n darknessPenalty: 0.15,\n environmentBase: { hue: 80, saturation: 0.5 },\n environmentDark: { hue: 90, saturation: 0.4 },\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'spores',\n fxPreset: 'spore-cloud',\n fxDensity: null,\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'divine-light',\n label: 'CALENDARIA.Common.DivineLight',\n description: 'CALENDARIA.Weather.DivineLightDesc',\n icon: 'fa-sun',\n color: '#FFD700',\n category: 'fantasy',\n chance: 0,\n tempMin: 18,\n tempMax: 30,\n darknessPenalty: -0.2,\n environmentBase: { hue: 45, saturation: 0.9 },\n environmentDark: null,\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'divine',\n fxPreset: 'divine-light',\n fxDensity: 'low',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n },\n {\n id: 'plague-miasma',\n label: 'CALENDARIA.Common.PlagueMiasma',\n description: 'CALENDARIA.Weather.PlagueMiasmaDesc',\n icon: 'fa-biohazard',\n color: '#556B2F',\n category: 'fantasy',\n chance: 0,\n tempMin: 10,\n tempMax: 22,\n darknessPenalty: 0.25,\n environmentBase: { hue: 80, saturation: 0.4 },\n environmentDark: { hue: 90, saturation: 0.3 },\n wind: { speed: 0, direction: null },\n precipitation: { type: null, intensity: 0 },\n inertiaWeight: 0,\n hudEffect: 'miasma',\n fxPreset: 'plague-miasma',\n fxDensity: 'high',\n fxSpeed: 'very-low',\n fxColor: null,\n soundFx: null\n }\n];\n\n/**\n * All built-in weather presets combined.\n * @type {object[]}\n */\nexport const ALL_PRESETS = [...STANDARD_WEATHER, ...SEVERE_WEATHER, ...ENVIRONMENTAL_WEATHER, ...FANTASY_WEATHER];\n\n/**\n * Weather categories for organizing presets.\n * @type {object}\n */\nexport const WEATHER_CATEGORIES = {\n standard: { id: 'standard', label: 'CALENDARIA.Common.Standard' },\n severe: { id: 'severe', label: 'CALENDARIA.Common.Severe' },\n environmental: { id: 'environmental', label: 'CALENDARIA.Weather.Category.Environmental' },\n fantasy: { id: 'fantasy', label: 'CALENDARIA.Common.Fantasy' },\n custom: { id: 'custom', label: 'CALENDARIA.Common.Custom' }\n};\n\n/**\n * Get a weather preset by ID.\n * @param {string} id - Weather preset ID\n * @param {object[]} [customPresets] - Custom presets to search\n * @returns {object|null} Weather preset or null\n */\nexport function getPreset(id, customPresets = []) {\n return ALL_PRESETS.find((p) => p.id === id) || customPresets.find((p) => p.id === id) || null;\n}\n\n/**\n * Get all weather presets including custom ones.\n * @param {object[]} [customPresets] - Custom presets to include\n * @returns {object[]} All presets\n */\nexport function getAllPresets(customPresets = []) {\n return [...ALL_PRESETS, ...customPresets];\n}\n\n/**\n * Get presets by category.\n * @param {string} category - Category ID\n * @param {object[]} [customPresets] - Custom presets to include\n * @returns {object[]} Presets in category\n */\nexport function getPresetsByCategory(category, customPresets = []) {\n const all = getAllPresets(customPresets);\n return all.filter((p) => p.category === category);\n}\n\n/**\n * Get the alias for a specific preset if one exists.\n * @param {string} presetId - Weather preset ID\n * @param {string} [calendarId] - Calendar ID for zone-scoped lookup\n * @param {string} [zoneId] - Zone ID for zone-scoped lookup\n * @returns {string|null} Alias label or null if no alias\n */\nexport function getPresetAlias(presetId, calendarId, zoneId) {\n const aliases = game.settings.get(MODULE.ID, SETTINGS.WEATHER_PRESET_ALIASES) || {};\n if (calendarId && zoneId) return aliases[calendarId]?.[zoneId]?.[presetId] || null;\n return null;\n}\n\n/**\n * Set an alias for a weather preset, scoped to a calendar and zone.\n * @param {string} presetId - Weather preset ID\n * @param {string|null} alias - Alias label, or null to remove\n * @param {string} calendarId - Calendar ID\n * @param {string} zoneId - Zone ID\n * @returns {Promise}\n */\nexport async function setPresetAlias(presetId, alias, calendarId, zoneId) {\n if (!calendarId || !zoneId) return;\n const aliases = game.settings.get(MODULE.ID, SETTINGS.WEATHER_PRESET_ALIASES) || {};\n if (alias && alias.trim()) {\n aliases[calendarId] ??= {};\n aliases[calendarId][zoneId] ??= {};\n aliases[calendarId][zoneId][presetId] = alias.trim();\n } else {\n delete aliases[calendarId]?.[zoneId]?.[presetId];\n if (aliases[calendarId]?.[zoneId] && Object.keys(aliases[calendarId][zoneId]).length === 0) delete aliases[calendarId][zoneId];\n if (aliases[calendarId] && Object.keys(aliases[calendarId]).length === 0) delete aliases[calendarId];\n }\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_PRESET_ALIASES, aliases);\n}\n","import { MODULE, SETTINGS } from '../../constants.mjs';\nimport { ALL_PRESETS } from './weather-presets.mjs';\n\n/**\n * Convert Celsius to Fahrenheit.\n * @param {number} celsius - Temperature in Celsius\n * @returns {number} Temperature in Fahrenheit\n */\nexport function celsiusToFahrenheit(celsius) {\n return Math.round((celsius * 9) / 5 + 32);\n}\n\n/**\n * Convert Fahrenheit to Celsius.\n * @param {number} fahrenheit - Temperature in Fahrenheit\n * @returns {number} Temperature in Celsius\n */\nexport function fahrenheitToCelsius(fahrenheit) {\n return Math.round(((fahrenheit - 32) * 5) / 9);\n}\n\n/**\n * Get the current temperature unit setting.\n * @returns {'celsius'|'fahrenheit'} Current unit\n */\nexport function getTemperatureUnit() {\n return game.settings.get(MODULE.ID, SETTINGS.TEMPERATURE_UNIT) || 'celsius';\n}\n\n/**\n * Convert a temperature value to the user's preferred unit.\n * @param {number} celsius - Temperature in Celsius (internal storage format)\n * @returns {number} Temperature in user's preferred unit\n */\nexport function toDisplayUnit(celsius) {\n if (celsius == null) return null;\n return getTemperatureUnit() === 'fahrenheit' ? celsiusToFahrenheit(celsius) : celsius;\n}\n\n/**\n * Convert a temperature from user's display unit to Celsius for storage.\n * @param {number} value - Temperature in user's display unit\n * @returns {number} Temperature in Celsius\n */\nexport function fromDisplayUnit(value) {\n if (value == null) return null;\n return getTemperatureUnit() === 'fahrenheit' ? fahrenheitToCelsius(value) : value;\n}\n\n/**\n * Convert a temperature delta from Celsius to user's display unit.\n * @param {number} celsiusDelta - Temperature delta in Celsius\n * @returns {number} Delta in user's display unit\n */\nexport function toDisplayDelta(celsiusDelta) {\n if (celsiusDelta == null) return null;\n return getTemperatureUnit() === 'fahrenheit' ? Math.round((celsiusDelta * 9) / 5) : celsiusDelta;\n}\n\n/**\n * Convert a temperature delta from user's display unit to Celsius for storage.\n * @param {number} displayDelta - Temperature delta in user's display unit\n * @returns {number} Delta in Celsius\n */\nexport function fromDisplayDelta(displayDelta) {\n if (displayDelta == null) return null;\n return getTemperatureUnit() === 'fahrenheit' ? Math.round((displayDelta * 5) / 9) : displayDelta;\n}\n\n/**\n * Climate zone template definitions.\n * @type {object}\n */\nexport const CLIMATE_ZONE_TEMPLATES = {\n arctic: {\n id: 'arctic',\n name: 'CALENDARIA.Weather.Climate.Arctic',\n description: 'CALENDARIA.Weather.Climate.ArcticDesc',\n temperatures: { Spring: { min: -15, max: 0 }, Summer: { min: -5, max: 8 }, Autumn: { min: -20, max: -5 }, Winter: { min: -45, max: -20 }, _default: { min: -25, max: -5 } },\n weather: {\n spring: { snow: 4, overcast: 3, windy: 3, clear: 2, blizzard: 2 },\n summer: { clear: 3, 'partly-cloudy': 3, snow: 3, overcast: 2, blizzard: 2, windy: 3, fog: 1 },\n autumn: { snow: 5, blizzard: 3, overcast: 3, windy: 3, clear: 1 },\n winter: { blizzard: 6, snow: 5, overcast: 2, windy: 4, clear: 1 },\n default: { snow: 5, blizzard: 4, overcast: 3, windy: 3, clear: 1 }\n },\n windSpeedRange: { min: 2, max: 5 },\n windDirections: { N: 25, NNW: 20, NW: 15, NNE: 10, W: 8, NE: 5, E: 5, WNW: 5, S: 2, SW: 2, SE: 1, SSW: 1, SSE: 0, ESE: 0, ENE: 3, WSW: 0 },\n brightnessMultiplier: 0.8,\n shortestDay: 4,\n longestDay: 20,\n colorShift: { dawnHue: 220, duskHue: 240, nightHue: 230, transitionMinutes: 90 },\n environmentBase: { hue: 200, saturation: 0.6 },\n environmentDark: { hue: 210, saturation: 0.5 }\n },\n subarctic: {\n id: 'subarctic',\n name: 'CALENDARIA.Weather.Climate.Subarctic',\n description: 'CALENDARIA.Weather.Climate.SubarcticDesc',\n temperatures: { Spring: { min: -10, max: 8 }, Summer: { min: 5, max: 18 }, Autumn: { min: -5, max: 10 }, Winter: { min: -35, max: -10 }, _default: { min: -10, max: 5 } },\n weather: {\n spring: { snow: 3, cloudy: 3, overcast: 2, windy: 2, clear: 3 },\n summer: { clear: 4, 'partly-cloudy': 4, rain: 3, snow: 1, cloudy: 2, overcast: 1, windy: 1, mist: 2 },\n autumn: { snow: 4, cloudy: 4, overcast: 3, windy: 2, clear: 1 },\n winter: { snow: 6, blizzard: 4, overcast: 3, windy: 3, cloudy: 2, clear: 1 },\n default: { snow: 4, cloudy: 3, overcast: 3, windy: 2, clear: 2 }\n },\n windSpeedRange: { min: 1, max: 4 },\n windDirections: { N: 20, NNW: 15, NW: 12, NNE: 10, W: 10, NE: 8, E: 5, WNW: 5, S: 3, SW: 3, SE: 3, SSW: 2, SSE: 1, ESE: 1, ENE: 1, WSW: 1 },\n brightnessMultiplier: 0.9,\n shortestDay: 6,\n longestDay: 18,\n colorShift: { dawnHue: 210, duskHue: 230, nightHue: 220, transitionMinutes: 75 },\n environmentBase: { hue: 200, saturation: 0.7 },\n environmentDark: null\n },\n temperate: {\n id: 'temperate',\n name: 'CALENDARIA.Weather.Climate.Temperate',\n description: 'CALENDARIA.Weather.Climate.TemperateDesc',\n temperatures: { Spring: { min: 8, max: 18 }, Summer: { min: 18, max: 30 }, Autumn: { min: 8, max: 18 }, Winter: { min: -5, max: 5 }, _default: { min: 8, max: 20 } },\n weather: {\n spring: { rain: 4, drizzle: 3, 'partly-cloudy': 3, clear: 2, mist: 2, cloudy: 2 },\n summer: { clear: 6, 'partly-cloudy': 4, thunderstorm: 2, rain: 2, cloudy: 1, mist: 1, drizzle: 1 },\n autumn: { cloudy: 4, rain: 3, fog: 3, 'partly-cloudy': 2, windy: 2, mist: 1, drizzle: 1, clear: 1 },\n winter: { snow: 5, blizzard: 2, fog: 2, overcast: 3, clear: 2, rain: 1, cloudy: 2, mist: 1, drizzle: 1 },\n default: { rain: 3, cloudy: 3, mist: 2, drizzle: 2, clear: 3 }\n },\n windSpeedRange: { min: 0, max: 3 },\n windDirections: { W: 20, WSW: 15, SW: 12, WNW: 10, NW: 8, S: 8, N: 5, NNW: 5, E: 4, SE: 4, NE: 3, SSW: 3, SSE: 1, ESE: 1, ENE: 1, NNE: 0 },\n brightnessMultiplier: 1.0,\n shortestDay: 8,\n longestDay: 16,\n colorShift: null,\n environmentBase: null,\n environmentDark: null\n },\n subtropical: {\n id: 'subtropical',\n name: 'CALENDARIA.Weather.Climate.Subtropical',\n description: 'CALENDARIA.Weather.Climate.SubtropicalDesc',\n temperatures: { Spring: { min: 15, max: 28 }, Summer: { min: 22, max: 35 }, Autumn: { min: 15, max: 28 }, Winter: { min: 5, max: 17 }, _default: { min: 12, max: 28 } },\n weather: {\n spring: { clear: 4, 'partly-cloudy': 4, cloudy: 3, rain: 3 },\n summer: { clear: 5, 'partly-cloudy': 4, cloudy: 2, rain: 5, drizzle: 2, thunderstorm: 3, sunshower: 1 },\n autumn: { clear: 3, 'partly-cloudy': 4, cloudy: 4, rain: 3 },\n winter: { clear: 2, 'partly-cloudy': 3, cloudy: 4, rain: 3, mist: 2, fog: 1 },\n default: { clear: 4, 'partly-cloudy': 5, cloudy: 3, rain: 2 }\n },\n windSpeedRange: { min: 0, max: 3 },\n windDirections: { SW: 18, SSW: 15, S: 12, WSW: 10, W: 8, SE: 8, SSE: 6, E: 5, NW: 4, N: 3, NE: 3, NNE: 2, NNW: 2, WNW: 2, ENE: 1, ESE: 1 },\n brightnessMultiplier: 1.05,\n shortestDay: 10,\n longestDay: 14,\n colorShift: { dawnHue: 30, duskHue: 20, nightHue: 240, transitionMinutes: 45 },\n environmentBase: null,\n environmentDark: null\n },\n tropical: {\n id: 'tropical',\n name: 'CALENDARIA.Weather.Climate.Tropical',\n description: 'CALENDARIA.Weather.Climate.TropicalDesc',\n temperatures: { Spring: { min: 24, max: 32 }, Summer: { min: 26, max: 35 }, Autumn: { min: 24, max: 32 }, Winter: { min: 22, max: 30 }, _default: { min: 24, max: 35 } },\n weather: {\n spring: { clear: 7, 'partly-cloudy': 5, rain: 6, thunderstorm: 3, fog: 2, sunshower: 1 },\n summer: { rain: 8, thunderstorm: 5, clear: 4, 'partly-cloudy': 4, fog: 2, sunshower: 2 },\n autumn: { rain: 7, 'partly-cloudy': 5, clear: 5, thunderstorm: 4, fog: 2, sunshower: 1 },\n winter: { clear: 8, 'partly-cloudy': 6, rain: 3, thunderstorm: 1, fog: 1, sunshower: 1 },\n default: { clear: 8, 'partly-cloudy': 5, rain: 7, thunderstorm: 3, fog: 2, sunshower: 1 }\n },\n windSpeedRange: { min: 0, max: 2 },\n windDirections: { E: 20, ENE: 15, NE: 12, ESE: 10, SE: 8, N: 5, NNE: 5, S: 5, SSE: 5, SSW: 4, NNW: 3, NW: 3, W: 2, WNW: 1, WSW: 1, SW: 1 },\n brightnessMultiplier: 1.1,\n shortestDay: 11,\n longestDay: 13,\n colorShift: { dawnHue: 25, duskHue: 15, nightHue: 235, transitionMinutes: 30 },\n environmentBase: { hue: 40, saturation: 0.9 },\n environmentDark: null\n },\n arid: {\n id: 'arid',\n name: 'CALENDARIA.Weather.Climate.Arid',\n description: 'CALENDARIA.Weather.Climate.AridDesc',\n temperatures: { Spring: { min: 18, max: 35 }, Summer: { min: 28, max: 48 }, Autumn: { min: 18, max: 35 }, Winter: { min: 5, max: 22 }, _default: { min: 15, max: 40 } },\n weather: {\n spring: { clear: 8, 'partly-cloudy': 4, sandstorm: 1, windy: 2 },\n summer: { clear: 10, 'partly-cloudy': 3, sandstorm: 2, windy: 1 },\n autumn: { clear: 7, 'partly-cloudy': 4, sandstorm: 2, windy: 1 },\n winter: { clear: 6, 'partly-cloudy': 4, sandstorm: 1, windy: 1, cloudy: 2, drizzle: 1 },\n default: { clear: 8, 'partly-cloudy': 4, sandstorm: 1, windy: 1 }\n },\n windSpeedRange: { min: 0, max: 4 },\n windDirections: { W: 12, NW: 10, N: 10, NNW: 8, WNW: 8, SW: 8, NE: 6, E: 6, NNE: 5, S: 5, SE: 5, WSW: 5, SSW: 4, ENE: 3, ESE: 3, SSE: 2 },\n brightnessMultiplier: 1.15,\n shortestDay: 10,\n longestDay: 14,\n colorShift: { dawnHue: 35, duskHue: 25, nightHue: 250, transitionMinutes: 40 },\n environmentBase: { hue: 35, saturation: 0.8 },\n environmentDark: null\n },\n polar: {\n id: 'polar',\n name: 'CALENDARIA.Weather.Climate.Polar',\n description: 'CALENDARIA.Weather.Climate.PolarDesc',\n temperatures: { Spring: { min: -20, max: -5 }, Summer: { min: -5, max: 10 }, Autumn: { min: -25, max: -10 }, Winter: { min: -50, max: -25 }, _default: { min: -30, max: -10 } },\n weather: {\n spring: { snow: 4, overcast: 3, blizzard: 2, windy: 2, clear: 2 },\n summer: { clear: 4, 'partly-cloudy': 3, snow: 2, overcast: 2, windy: 2, blizzard: 1, mist: 1 },\n autumn: { snow: 5, overcast: 3, blizzard: 3, windy: 2, clear: 1 },\n winter: { blizzard: 6, snow: 5, overcast: 2, windy: 3, clear: 1 },\n default: { snow: 4, overcast: 3, blizzard: 2, windy: 2, clear: 1 }\n },\n windSpeedRange: { min: 2, max: 5 },\n windDirections: { N: 30, NNE: 20, NE: 12, NNW: 10, NW: 8, E: 5, ENE: 5, W: 3, WNW: 3, SE: 1, S: 1, SSE: 1, ESE: 0, SSW: 0, SW: 0, WSW: 1 },\n brightnessMultiplier: 0.7,\n shortestDay: 0,\n longestDay: 24,\n colorShift: { dawnHue: 230, duskHue: 250, nightHue: 240, transitionMinutes: 120 },\n environmentBase: { hue: 210, saturation: 0.5 },\n environmentDark: { hue: 220, saturation: 0.4 }\n }\n};\n\n/**\n * Get a climate zone template by ID.\n * @param {string} id - Climate zone ID\n * @returns {object|null} Climate zone template or null\n */\nexport function getClimateZoneTemplate(id) {\n return CLIMATE_ZONE_TEMPLATES[id] ?? null;\n}\n\n/**\n * Get all climate zone template IDs.\n * @returns {string[]} Climate zone IDs\n */\nexport function getClimateZoneTemplateIds() {\n return Object.keys(CLIMATE_ZONE_TEMPLATES);\n}\n\n/**\n * Get a fully populated zone config object from a template.\n * @param {string} templateId - Climate zone template ID\n * @param {string[]} [seasonNames] - Season names for temperature and override keys\n * @returns {object|null} Populated zone config object\n */\nexport function getDefaultZoneConfig(templateId, seasonNames = ['CALENDARIA.Season.Spring', 'CALENDARIA.Season.Summer', 'CALENDARIA.Season.Autumn', 'CALENDARIA.Season.Winter']) {\n const template = getClimateZoneTemplate(templateId);\n if (!template) return null;\n const temperatures = { _default: template.temperatures._default ?? { min: 10, max: 22 } };\n for (const season of seasonNames) {\n const templateTemp = template.temperatures[season] ?? template.temperatures[season.toLowerCase()] ?? template.temperatures._default;\n if (templateTemp) temperatures[season] = { ...templateTemp };\n }\n const presets = [];\n const allWeatherKeys = template.weather ?? {};\n for (const preset of ALL_PRESETS) {\n const enabled = Object.values(allWeatherKeys).some((seasonWeights) => (seasonWeights[preset.id] ?? 0) > 0);\n presets.push({ id: preset.id, enabled, tempMin: null, tempMax: null });\n }\n const seasonOverrides = {};\n const defaultWeatherWeights = template.weather?.default ?? {};\n for (const season of seasonNames) {\n const normalizedSeason = normalizeSeasonName(season);\n const seasonWeather = template.weather?.[normalizedSeason];\n const effectiveWeather = seasonWeather && normalizedSeason !== 'default' ? seasonWeather : defaultWeatherWeights;\n const seasonTotal = Object.values(effectiveWeather).reduce((sum, w) => sum + w, 0);\n if (seasonTotal <= 0) continue;\n const seasonPresets = {};\n for (const [presetId, weight] of Object.entries(effectiveWeather)) {\n const chance = Math.round((weight / seasonTotal) * 100 * 100) / 100;\n seasonPresets[presetId] = { id: presetId, chance, enabled: chance > 0 };\n }\n const seasonTemp = template.temperatures[season] ?? template.temperatures[normalizedSeason.charAt(0).toUpperCase() + normalizedSeason.slice(1)];\n seasonOverrides[season] = { temperatures: seasonTemp ? { min: seasonTemp.min, max: seasonTemp.max } : null, presets: seasonPresets };\n }\n return {\n id: template.id,\n name: template.name,\n description: _loc(template.description) ?? '',\n brightnessMultiplier: template.brightnessMultiplier ?? 1.0,\n environmentBase: template.environmentBase ?? null,\n environmentDark: template.environmentDark ?? null,\n windSpeedRange: template.windSpeedRange ?? null,\n windDirections: template.windDirections ?? {},\n shortestDay: template.shortestDay ?? null,\n longestDay: template.longestDay ?? null,\n sunriseOverride: template.sunriseOverride ?? null,\n sunsetOverride: template.sunsetOverride ?? null,\n colorShift: template.colorShift ?? null,\n temperatures,\n presets,\n seasonOverrides: Object.keys(seasonOverrides).length > 0 ? seasonOverrides : {}\n };\n}\n\n/**\n * Normalize season name to match climate data keys.\n * @param {string} seasonName - Season name (may be localized)\n * @returns {string} Normalized season key\n */\nexport function normalizeSeasonName(seasonName) {\n if (!seasonName) return 'default';\n const lower = seasonName.toLowerCase();\n if (lower.includes('spring') || lower.includes('vernal')) return 'spring';\n if (lower.includes('summer') || lower.includes('estival')) return 'summer';\n if (lower.includes('autumn') || lower.includes('fall') || lower.includes('autumnal')) return 'autumn';\n if (lower.includes('winter') || lower.includes('hibernal')) return 'winter';\n return 'default';\n}\n\n/**\n * Get all template IDs as options for a dropdown.\n * @returns {Array<{value: string, label: string}>} Options array\n */\nexport function getClimateTemplateOptions() {\n const templates = Object.values(CLIMATE_ZONE_TEMPLATES)\n .map((t) => ({ value: t.id, label: t.name }))\n .sort((a, b) => a.label.localeCompare(b.label));\n return [{ value: 'custom', label: _loc('CALENDARIA.Weather.Climate.Custom') }, ...templates];\n}\n\n/**\n * Create a blank zone config with no presets, weights, or temperatures.\n * @param {string[]} [seasonNames] - Season names for empty override slots\n * @returns {object} Blank zone config\n */\nexport function getBlankZoneConfig(seasonNames = []) {\n const presets = ALL_PRESETS.map((p) => ({ id: p.id, enabled: false, tempMin: null, tempMax: null }));\n const seasonOverrides = {};\n for (const season of seasonNames) {\n seasonOverrides[season] = { temperatures: null, presets: {} };\n }\n return {\n id: 'custom',\n name: _loc('CALENDARIA.Weather.Climate.Custom'),\n description: '',\n brightnessMultiplier: 1.0,\n environmentBase: null,\n environmentDark: null,\n windSpeedRange: null,\n windDirections: {},\n shortestDay: null,\n longestDay: null,\n sunriseOverride: null,\n sunsetOverride: null,\n colorShift: null,\n temperatures: { _default: { min: 0, max: 0 } },\n presets,\n seasonOverrides\n };\n}\n","import { COMPASS_DIRECTIONS, MODULE, SETTINGS, WEATHER_PERIODS, WIND_SPEEDS } from '../constants.mjs';\nimport { getAllPresets, getPreset } from './data/weather-presets.mjs';\n\n/**\n * Seeded random number generator (mulberry32).\n * @param {number} seed - Seed value\n * @returns {Function} Random function returning 0-1\n */\nexport function seededRandom(seed) {\n return function () {\n let t = (seed += 0x6d2b79f5);\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n/**\n * Generate a seed from date components.\n * @param {number} year - Year\n * @param {number} month - Month (0-indexed)\n * @param {number} day - Day of month\n * @returns {number} Seed value\n */\nexport function dateSeed(year, month, day) {\n return year * 10000 + month * 100 + day + (game.settings.get(MODULE.ID, SETTINGS.WEATHER_SEED) || 0);\n}\n\n/**\n * Generate a seed for a specific intraday period.\n * @param {number} year - Year\n * @param {number} month - Month (0-indexed)\n * @param {number} day - Day of month\n * @param {number} periodIndex - Period index (0=night, 1=morning, 2=afternoon, 3=evening)\n * @returns {number} Period-specific seed value\n */\nexport function periodSeed(year, month, day, periodIndex) {\n return dateSeed(year, month, day) * 4 + periodIndex;\n}\n\n/**\n * Select a random item from weighted options.\n * @param {object} weights - Object mapping IDs to weights\n * @param {Function} [randomFn] - Random function\n * @returns {string} Selected ID\n */\nfunction weightedSelect(weights, randomFn = Math.random) {\n const entries = Object.entries(weights);\n if (entries.length === 0) return null;\n const totalWeight = entries.reduce((sum, [, w]) => sum + w, 0);\n if (totalWeight <= 0) {\n ATLAS.log(2, 'Weather selection: all weights zero, using random fallback');\n return entries[Math.floor(randomFn() * entries.length)][0];\n }\n let roll = randomFn() * totalWeight;\n for (const [id, weight] of entries) {\n roll -= weight;\n if (roll <= 0) return id;\n }\n return entries[entries.length - 1][0];\n}\n\n/**\n * Check whether a stored temperature value is a relative modifier (\"N+\"/\"N-\").\n * @param {number|string|null|undefined} value - Stored temperature value\n * @returns {boolean} True if value is a relative modifier string\n */\nexport function isRelativeTempModifier(value) {\n return typeof value === 'string' && /[+-]$/.test(value);\n}\n\n/**\n * Apply a temperature value that may be a relative modifier or absolute.\n * @param {number|string} value - Temperature value or \"N+\"/\"N-\" modifier string\n * @param {number} base - Base temperature to apply modifier against\n * @returns {number} Resolved temperature\n */\nexport function applyTempModifier(value, base) {\n if (typeof value === 'string') {\n if (value.endsWith('+')) {\n const delta = Number(value.slice(0, -1));\n return !isNaN(delta) ? base + delta : base;\n }\n if (value.endsWith('-')) {\n const delta = Number(value.slice(0, -1));\n return !isNaN(delta) ? base - delta : base;\n }\n const num = Number(value);\n return !isNaN(num) ? num : base;\n }\n return value ?? base;\n}\n\n/**\n * Merge season climate with zone overrides.\n * @param {object} seasonClimate - Season's base climate { temperatures, presets }\n * @param {object} zoneOverride - Zone's override for this season { temperatures, presets }\n * @param {object} zoneFallback - Zone's default config { temperatures, presets } for backward compat\n * @param {string} season - Season name for temperature lookup\n * @returns {object} Merged config { probabilities, tempRange }\n */\nexport function mergeClimateConfig(seasonClimate, zoneOverride, zoneFallback, season) {\n const probabilities = {};\n let tempRange = { min: 10, max: 22 };\n const rawZonePresets = zoneOverride?.presets;\n const zonePresets = rawZonePresets ? Object.values(rawZonePresets) : [];\n if (zonePresets.length) {\n for (const preset of Object.values(seasonClimate?.presets ?? {})) if (preset.chance > 0) probabilities[preset.id] = preset.chance;\n for (const preset of zonePresets) {\n if (typeof preset.chance === 'string' && /[+-]$/.test(preset.chance)) {\n const suffix = preset.chance.slice(-1);\n const value = Number(preset.chance.slice(0, -1));\n if (!isNaN(value)) {\n const delta = suffix === '+' ? value : -value;\n probabilities[preset.id] = Math.max(0, (probabilities[preset.id] ?? 0) + delta);\n if (probabilities[preset.id] === 0) delete probabilities[preset.id];\n }\n } else if (preset.chance === 0) {\n delete probabilities[preset.id];\n } else if (preset.chance > 0) {\n probabilities[preset.id] = preset.chance;\n }\n }\n } else {\n for (const preset of Object.values(seasonClimate?.presets ?? {})) if (preset.chance > 0) probabilities[preset.id] = preset.chance;\n }\n const baseMin = seasonClimate?.temperatures?.min ?? 10;\n const baseMax = seasonClimate?.temperatures?.max ?? 22;\n if (zoneOverride?.temperatures?.min != null || zoneOverride?.temperatures?.max != null) {\n tempRange = { min: applyTempModifier(zoneOverride.temperatures.min, baseMin), max: applyTempModifier(zoneOverride.temperatures.max, baseMax) };\n } else if (season && zoneFallback?.temperatures?.[season]) {\n const temps = zoneFallback.temperatures[season];\n tempRange = { min: applyTempModifier(temps.min, baseMin), max: applyTempModifier(temps.max, baseMax) };\n } else if (seasonClimate?.temperatures?.min != null || seasonClimate?.temperatures?.max != null) {\n tempRange = { min: baseMin, max: baseMax };\n } else if (zoneFallback?.temperatures?._default) {\n tempRange = zoneFallback.temperatures._default;\n }\n const rawFallbackPresets = zoneFallback?.presets;\n if (rawFallbackPresets && Object.keys(rawFallbackPresets).length > 0) {\n const seasonPresetMap = rawZonePresets ?? {};\n const enabledIds = new Set(\n Object.values(rawFallbackPresets)\n .filter((p) => {\n if (!p?.id) return false;\n const seasonP = seasonPresetMap[p.id];\n return (seasonP?.enabled ?? p.enabled) !== false;\n })\n .map((p) => p.id)\n );\n for (const id of Object.keys(probabilities)) if (!enabledIds.has(id)) delete probabilities[id];\n }\n return { probabilities, tempRange };\n}\n\n/**\n * Generate weather using season climate as base with zone overrides.\n * @param {object} options - Generation options\n * @param {object} [options.seasonClimate] - Season's base climate { temperatures, presets }\n * @param {object} [options.zoneConfig] - Climate zone config object from calendar (for backward compat)\n * @param {string} [options.season] - Season name for temperature lookup and zone overrides\n * @param {number} [options.seed] - Random seed for deterministic generation\n * @param {object[]} [options.customPresets] - Custom weather presets\n * @param {string} [options.currentWeatherId] - Current weather ID for inertia calculation\n * @param {number} [options.inertia] - How much to favor current weather (0-1)\n * @param {object|null} [options.previousWeather] - Previous weather for value-level blending { temperature, wind }\n * @returns {object} Generated weather { preset, temperature }\n */\nexport function generateWeather({ seasonClimate, zoneConfig, season, seed, customPresets = [], currentWeatherId = null, inertia = 0, previousWeather = null }) {\n const randomFn = seed != null ? seededRandom(seed) : Math.random;\n const zoneOverride = season && zoneConfig?.seasonOverrides?.[season];\n let { probabilities, tempRange } = mergeClimateConfig(seasonClimate, zoneOverride, zoneConfig, season);\n if (Object.keys(probabilities).length === 0) {\n const rawZonePresets = zoneConfig?.presets;\n const seasonPresetMap = zoneOverride?.presets ?? {};\n if (rawZonePresets && Object.keys(rawZonePresets).length > 0) {\n const enabledIds = new Set(\n Object.values(rawZonePresets)\n .filter((p) => {\n if (!p?.id) return false;\n return (seasonPresetMap[p.id]?.enabled ?? p.enabled) !== false;\n })\n .map((p) => p.id)\n );\n for (const preset of getAllPresets(customPresets)) if (enabledIds.has(preset.id)) probabilities[preset.id] = 1;\n }\n if (Object.keys(probabilities).length === 0) for (const preset of getAllPresets(customPresets)) probabilities[preset.id] = 1;\n if (Object.keys(probabilities).length === 0) probabilities.clear = 1;\n }\n for (const [id, weight] of Object.entries(probabilities)) {\n if (weight <= 0) continue;\n const seasonP = zoneOverride?.presets?.[id];\n const zoneP = Object.values(zoneConfig?.presets ?? {}).find((pp) => pp.id === id && pp.enabled !== false);\n const hasOverride = seasonP?.tempMin != null || seasonP?.tempMax != null || zoneP?.tempMin != null || zoneP?.tempMax != null;\n if (!hasOverride) continue;\n const p = getPreset(id, customPresets);\n const effMin = seasonP?.tempMin ?? zoneP?.tempMin ?? p?.tempMin;\n const effMax = seasonP?.tempMax ?? zoneP?.tempMax ?? p?.tempMax;\n if (isRelativeTempModifier(effMin) || isRelativeTempModifier(effMax)) continue;\n const resolvedMin = effMin != null ? applyTempModifier(effMin, tempRange.min) : null;\n const resolvedMax = effMax != null ? applyTempModifier(effMax, tempRange.max) : null;\n if (resolvedMin != null && resolvedMax != null && (resolvedMin > tempRange.max || resolvedMax < tempRange.min)) probabilities[id] = 0;\n }\n if (currentWeatherId && inertia > 0) probabilities = applyWeatherInertia(currentWeatherId, probabilities, inertia, customPresets, zoneConfig, season);\n const weatherId = weightedSelect(probabilities, randomFn);\n const preset = getPreset(weatherId, customPresets);\n let finalTempRange = { ...tempRange };\n const seasonPresetConfig = zoneOverride?.presets?.[weatherId];\n const zonePresetConfig = Object.values(zoneConfig?.presets ?? {}).find((p) => p.id === weatherId && p.enabled !== false);\n const hasPresetOverride = seasonPresetConfig?.tempMin != null || seasonPresetConfig?.tempMax != null || zonePresetConfig?.tempMin != null || zonePresetConfig?.tempMax != null;\n if (hasPresetOverride) {\n const effectiveTempMin = seasonPresetConfig?.tempMin ?? zonePresetConfig?.tempMin ?? preset?.tempMin;\n const effectiveTempMax = seasonPresetConfig?.tempMax ?? zonePresetConfig?.tempMax ?? preset?.tempMax;\n if (effectiveTempMin != null) {\n const resolved = applyTempModifier(effectiveTempMin, tempRange.min);\n finalTempRange.min = isRelativeTempModifier(effectiveTempMin) ? resolved : Math.max(finalTempRange.min, resolved);\n }\n if (effectiveTempMax != null) {\n const resolved = applyTempModifier(effectiveTempMax, tempRange.max);\n finalTempRange.max = isRelativeTempModifier(effectiveTempMax) ? resolved : Math.min(finalTempRange.max, resolved);\n }\n if (finalTempRange.min > finalTempRange.max) finalTempRange = { ...tempRange };\n }\n let temperature = Math.round(finalTempRange.min + randomFn() * (finalTempRange.max - finalTempRange.min));\n const resolvedPreset = preset || { id: weatherId, label: weatherId, icon: 'fa-question', color: '#888888' };\n const wind = generateWind(resolvedPreset, zoneConfig, randomFn);\n const precipitation = generatePrecipitation(resolvedPreset, randomFn);\n if (previousWeather && inertia > 0) {\n if (previousWeather.temperature != null) temperature = Math.round(previousWeather.temperature * inertia + temperature * (1 - inertia));\n if (previousWeather.wind && !wind.forced) {\n wind.speed = Math.round(previousWeather.wind.speed * inertia + wind.speed * (1 - inertia));\n wind.kph = resolveWindKph(wind.speed, randomFn);\n if (previousWeather.wind.direction != null && wind.direction != null) wind.direction = lerpAngle(previousWeather.wind.direction, wind.direction, 1 - inertia);\n }\n }\n temperature = Math.max(finalTempRange.min, Math.min(finalTempRange.max, temperature));\n return { preset: resolvedPreset, temperature, wind, precipitation, probabilities };\n}\n\n/**\n * Roll a temperature value for a preset using the same range resolution as generateWeather.\n * @param {object} options - Roll options\n * @param {string} options.presetId - Preset ID to roll against\n * @param {object} [options.seasonClimate] - Season's base climate { temperatures, presets }\n * @param {object} [options.zoneConfig] - Zone config { temperatures, presets, seasonOverrides }\n * @param {string} [options.season] - Season name for zone overrides / fallbacks\n * @param {object[]} [options.customPresets] - Custom presets list\n * @param {Function} [options.randomFn] - Random function (defaults to Math.random)\n * @returns {number} Rounded temperature value (internal units)\n */\nexport function rollPresetTemperature({ presetId, seasonClimate, zoneConfig, season, customPresets = [], randomFn = Math.random }) {\n const zoneOverride = season && zoneConfig?.seasonOverrides?.[season];\n const { tempRange } = mergeClimateConfig(seasonClimate, zoneOverride, zoneConfig, season);\n const preset = getPreset(presetId, customPresets);\n let finalTempRange = { ...tempRange };\n const seasonPresetConfig = zoneOverride?.presets?.[presetId];\n const zonePresetConfig = Object.values(zoneConfig?.presets ?? {}).find((p) => p.id === presetId && p.enabled !== false);\n const hasPresetOverride = seasonPresetConfig?.tempMin != null || seasonPresetConfig?.tempMax != null || zonePresetConfig?.tempMin != null || zonePresetConfig?.tempMax != null;\n if (hasPresetOverride) {\n const effectiveTempMin = seasonPresetConfig?.tempMin ?? zonePresetConfig?.tempMin ?? preset?.tempMin;\n const effectiveTempMax = seasonPresetConfig?.tempMax ?? zonePresetConfig?.tempMax ?? preset?.tempMax;\n if (effectiveTempMin != null) {\n const resolved = applyTempModifier(effectiveTempMin, tempRange.min);\n finalTempRange.min = isRelativeTempModifier(effectiveTempMin) ? resolved : Math.max(finalTempRange.min, resolved);\n }\n if (effectiveTempMax != null) {\n const resolved = applyTempModifier(effectiveTempMax, tempRange.max);\n finalTempRange.max = isRelativeTempModifier(effectiveTempMax) ? resolved : Math.min(finalTempRange.max, resolved);\n }\n if (finalTempRange.min > finalTempRange.max) finalTempRange = { ...tempRange };\n }\n return Math.round(finalTempRange.min + randomFn() * (finalTempRange.max - finalTempRange.min));\n}\n\n/**\n * Generate weather for all 4 intraday periods with chained inertia.\n * Period order: night → morning → afternoon → evening.\n * @param {object} options - Generation options\n * @param {object} [options.seasonClimate] - Season's base climate\n * @param {object} options.zoneConfig - Climate zone config\n * @param {string} [options.season] - Season name\n * @param {number} options.year - Year\n * @param {number} options.month - Month (0-indexed)\n * @param {number} options.dayOfMonth - Day of month (0-indexed)\n * @param {object[]} [options.customPresets] - Custom weather presets\n * @param {number} [options.carryOverChance] - Chance (0-100) that a period copies the previous period's weather instead of generating new (default 50)\n * @param {string} [options.currentWeatherId] - Current weather ID for first period's inertia\n * @param {number} [options.inertia] - Day-level inertia (0-1), used for all periods\n * @param {object|null} [options.previousWeather] - Previous weather for first period blending { temperature, wind }\n * @returns {{ dominant: object, periods: object }} Dominant weather and per-period breakdown\n */\nexport function generateIntradayWeather({\n seasonClimate,\n zoneConfig,\n season,\n year,\n month,\n dayOfMonth,\n customPresets = [],\n carryOverChance = 50,\n currentWeatherId = null,\n inertia = 0,\n previousWeather = null\n}) {\n const periodOrder = [WEATHER_PERIODS.NIGHT, WEATHER_PERIODS.MORNING, WEATHER_PERIODS.AFTERNOON, WEATHER_PERIODS.EVENING];\n const periods = {};\n let prevId = currentWeatherId;\n let prevWeather = previousWeather;\n let prevResult = null;\n for (const period of periodOrder) {\n const seed = periodSeed(year, month, dayOfMonth, period.index);\n const isFirst = period.index === 0;\n if (!isFirst && prevResult && carryOverChance > 0) {\n const rng = seededRandom(seed + 7919);\n if (rng() * 100 < carryOverChance) {\n periods[period.id] = { ...prevResult };\n continue;\n }\n }\n const weather = generateWeather({ seasonClimate, zoneConfig, season, seed, customPresets, currentWeatherId: prevId, inertia, previousWeather: prevWeather });\n periods[period.id] = weather;\n prevId = weather.preset.id;\n prevWeather = { temperature: weather.temperature, wind: weather.wind };\n prevResult = weather;\n }\n const dominant = periods.morning;\n return { dominant, periods };\n}\n\n/**\n * Apply forecast variance to a generated weather result.\n * @param {object} weather - Generated weather { preset, temperature }\n * @param {number} dayDistance - Days from today (1 = tomorrow)\n * @param {number} totalDays - Total forecast window\n * @param {number} accuracy - 0-100 accuracy setting\n * @param {Function} randomFn - Random function\n * @param {object[]} customPresets - Custom presets\n * @param {string[]} [validPresetIds] - Valid preset IDs for the season/zone (restricts variance pool)\n * @returns {object} Potentially modified weather with `isVaried` flag\n */\nexport function applyForecastVariance(weather, dayDistance, totalDays, accuracy, randomFn, customPresets, validPresetIds) {\n if (accuracy >= 100) return { ...weather, isVaried: false };\n const distanceFactor = dayDistance / totalDays;\n const varianceChance = (1 - accuracy / 100) * distanceFactor;\n let isVaried = false;\n let temperature = weather.temperature;\n let preset = weather.preset;\n const maxTempVariance = 8;\n const tempVariance = (randomFn() - 0.5) * 2 * maxTempVariance * (1 - accuracy / 100) * distanceFactor;\n temperature = Math.round(temperature + tempVariance);\n if (Math.abs(tempVariance) > 1) isVaried = true;\n if (randomFn() < varianceChance) {\n const validSet = validPresetIds?.length ? new Set(validPresetIds) : null;\n const sameCategory = getAllPresets(customPresets).filter((p) => p.category === preset.category && p.id !== preset.id && (!validSet || validSet.has(p.id)));\n if (sameCategory.length > 0) {\n preset = sameCategory[Math.floor(randomFn() * sameCategory.length)];\n isVaried = true;\n }\n }\n return { preset, temperature, isVaried };\n}\n\n/**\n * Generate a forecast for multiple days using zone config.\n * @param {object} options - Generation options\n * @param {object} options.zoneConfig - Climate zone config\n * @param {string} [options.season] - Season name (assumed constant for forecast period)\n * @param {number} options.startYear - Starting year\n * @param {number} options.startMonth - Starting month (0-indexed)\n * @param {number} options.startDayOfMonth - Starting day of month (0-indexed)\n * @param {number} [options.days] - Number of days to forecast\n * @param {object[]} [options.customPresets] - Custom weather presets\n * @param {Function} [options.getSeasonForDate] - Function to get season data for a date\n * @param {Function} [options.getDaysInMonth] - Function(month, year) returning days in that month. Set ._monthsPerYear for year rollover (default 12).\n * @param {string} [options.currentWeatherId] - Current weather ID for inertia chain start\n * @param {number} [options.inertia] - Inertia value (0-1) for path-dependent chaining\n * @param {number} [options.accuracy] - Forecast accuracy 0-100 (100 = perfect)\n * @param {object|null} [options.previousWeather] - Previous weather for value-level blending { temperature, wind }\n * @param {boolean} [options.intraday] - Generate per-period weather for each day\n * @param {number} [options.carryOverChance] - Chance (0-100) that a period copies the previous period's weather (default 50)\n * @returns {object[]} Array of weather forecasts\n */\nexport function generateForecast({\n zoneConfig,\n season,\n startYear,\n startMonth,\n startDayOfMonth,\n days = 7,\n customPresets = [],\n getSeasonForDate,\n getDaysInMonth,\n currentWeatherId = null,\n inertia = 0,\n accuracy = 100,\n previousWeather = null,\n intraday = false,\n carryOverChance = 50\n}) {\n const forecast = [];\n let year = startYear;\n let month = startMonth;\n let dayOfMonth = startDayOfMonth;\n let previousWeatherId = currentWeatherId;\n let prevWeather = previousWeather;\n for (let i = 0; i < days; i++) {\n const seasonData = getSeasonForDate ? getSeasonForDate(year, month, dayOfMonth) : null;\n const currentSeason = seasonData?.name ?? season;\n const seasonClimate = seasonData?.climate ?? null;\n if (intraday) {\n const result = generateIntradayWeather({\n seasonClimate,\n zoneConfig,\n season: currentSeason,\n year,\n month,\n dayOfMonth,\n customPresets,\n carryOverChance,\n currentWeatherId: previousWeatherId,\n inertia,\n previousWeather: prevWeather\n });\n previousWeatherId = result.periods.evening.preset.id;\n prevWeather = { temperature: result.periods.evening.temperature, wind: result.periods.evening.wind };\n const entry = { year, month, dayOfMonth, ...result.dominant, isVaried: false, periods: result.periods };\n if (accuracy < 100) {\n const seed = dateSeed(year, month, dayOfMonth);\n const validPresetIds = Object.keys(result.dominant.probabilities ?? {});\n const varied = applyForecastVariance(entry, i + 1, days, accuracy, seededRandom(seed + 1), customPresets, validPresetIds);\n forecast.push({ ...entry, ...varied, periods: entry.periods });\n } else {\n forecast.push(entry);\n }\n } else {\n const seed = dateSeed(year, month, dayOfMonth);\n const weather = generateWeather({ seasonClimate, zoneConfig, season: currentSeason, seed, customPresets, currentWeatherId: previousWeatherId, inertia, previousWeather: prevWeather });\n previousWeatherId = weather.preset.id;\n prevWeather = { temperature: weather.temperature, wind: weather.wind };\n if (accuracy < 100) {\n const validPresetIds = Object.keys(weather.probabilities ?? {});\n const varied = applyForecastVariance(weather, i + 1, days, accuracy, seededRandom(seed + 1), customPresets, validPresetIds);\n forecast.push({ year, month, dayOfMonth, ...varied });\n } else {\n forecast.push({ year, month, dayOfMonth, ...weather, isVaried: false });\n }\n }\n dayOfMonth++;\n if (getDaysInMonth) {\n const dim = getDaysInMonth(month, year);\n if (dayOfMonth >= dim) {\n dayOfMonth = 0;\n month++;\n const monthsPerYear = getDaysInMonth._monthsPerYear ?? 12;\n if (month >= monthsPerYear) {\n month = 0;\n year++;\n }\n }\n }\n }\n return forecast;\n}\n\n/**\n * Resolve a kph value for a wind speed scale value (0-5) deterministically.\n * @param {number} speed - Wind speed on 0-5 scale\n * @param {Function} [randomFn] - Random function\n * @returns {number} Speed in kph\n */\nfunction resolveWindKph(speed, randomFn = Math.random) {\n const speeds = Object.values(WIND_SPEEDS);\n const entry = speeds.find((w) => w.value === speed);\n if (!entry) return 0;\n const prevEntry = speeds.find((w) => w.value === speed - 1);\n const min = prevEntry ? prevEntry.kph + 1 : 0;\n const max = entry.kph;\n return Math.floor(randomFn() * (max - min + 1)) + min;\n}\n\n/**\n * Generate wind conditions based on preset, zone config, and forced wind rules.\n * @param {object} preset - Weather preset with wind defaults\n * @param {object} [zoneConfig] - Zone config (windDirections, windSpeedRange)\n * @param {Function} [randomFn] - Random function\n * @returns {{ speed: number, kph: number, direction: number|null, forced: boolean }} Wind conditions\n */\nfunction generateWind(preset, zoneConfig, randomFn = Math.random) {\n const compassValues = Object.values(COMPASS_DIRECTIONS);\n const randomCompass = () => compassValues[Math.floor(randomFn() * compassValues.length)];\n if (preset.wind?.forced) {\n const kph = resolveWindKph(preset.wind.speed, randomFn);\n return { speed: preset.wind.speed, kph, direction: preset.wind.direction ?? randomCompass(), forced: true };\n }\n const baseSpeed = preset.wind?.speed ?? 0;\n const zoneMin = zoneConfig?.windSpeedRange?.min ?? 0;\n const zoneMax = zoneConfig?.windSpeedRange?.max ?? 5;\n const variance = Math.round((randomFn() - 0.5) * 2);\n const speed = Math.max(zoneMin, Math.min(zoneMax, baseSpeed + variance));\n const kph = resolveWindKph(speed, randomFn);\n let direction;\n const dirWeights = zoneConfig?.windDirections ?? {};\n if (Object.keys(dirWeights).length > 0) {\n const dirId = weightedSelect(dirWeights, randomFn);\n direction = COMPASS_DIRECTIONS[dirId] ?? randomCompass();\n } else {\n direction = preset.wind?.direction ?? randomCompass();\n }\n return { speed, kph, direction, forced: false };\n}\n\n/**\n * Generate precipitation based on preset defaults with intensity variance.\n * @param {object} preset - Weather preset with precipitation defaults\n * @param {Function} [randomFn] - Random function\n * @returns {{ type: string|null, intensity: number }} Precipitation conditions\n */\nfunction generatePrecipitation(preset, randomFn = Math.random) {\n const type = preset.precipitation?.type ?? null;\n if (!type) return { type: null, intensity: 0 };\n const baseIntensity = preset.precipitation?.intensity ?? 0.5;\n const variance = (randomFn() - 0.5) * 0.3;\n const intensity = Math.max(0.1, Math.min(1, baseIntensity + variance));\n return { type, intensity: Math.round(intensity * 100) / 100 };\n}\n\n/**\n * Interpolate between two angles (degrees) taking the shortest path.\n * @param {number} from - Start angle in degrees\n * @param {number} to - End angle in degrees\n * @param {number} t - Interpolation factor (0-1)\n * @returns {number} Interpolated angle snapped to nearest compass point\n */\nfunction lerpAngle(from, to, t) {\n let diff = to - from;\n if (diff > 180) diff -= 360;\n else if (diff < -180) diff += 360;\n const result = (((from + diff * t) % 360) + 360) % 360;\n return Math.round(result / 22.5) * 22.5;\n}\n\n/**\n * Apply weather transition smoothing.\n * @param {string} currentWeatherId - Current weather ID\n * @param {object} probabilities - Base probabilities\n * @param {number} [inertia] - How much to favor current weather (0-1)\n * @param {object[]} [customPresets] - Custom presets for weight lookup\n * @param {object} [zoneConfig] - Zone config for per-preset inertiaWeight overrides\n * @param {string} [season] - Season name for per-season inertiaWeight lookup\n * @returns {object} Adjusted probabilities\n */\nexport function applyWeatherInertia(currentWeatherId, probabilities, inertia = 0.3, customPresets = [], zoneConfig = null, season = null) {\n if (!currentWeatherId || !probabilities[currentWeatherId]) return probabilities;\n const seasonPreset = season ? zoneConfig?.seasonOverrides?.[season]?.presets?.[currentWeatherId] : null;\n const zonePreset = zoneConfig?.presets ? Object.values(zoneConfig.presets).find((p) => p.id === currentWeatherId) : null;\n const preset = getPreset(currentWeatherId, customPresets);\n const weight = seasonPreset?.inertiaWeight ?? zonePreset?.inertiaWeight ?? preset?.inertiaWeight ?? 1;\n const effectiveInertia = Math.min(1, inertia * weight);\n if (effectiveInertia <= 0) return probabilities;\n const adjusted = { ...probabilities };\n const currentWeight = adjusted[currentWeatherId] || 0;\n const totalOther = Object.values(adjusted).reduce((sum, w) => sum + w, 0) - currentWeight;\n if (totalOther > 0) {\n const boost = totalOther * effectiveInertia;\n adjusted[currentWeatherId] = currentWeight + boost;\n for (const id of Object.keys(adjusted)) if (id !== currentWeatherId) adjusted[id] *= 1 - effectiveInertia;\n }\n return adjusted;\n}\n","/**\n * Execute a macro by its ID.\n * @param {string} macroId - The ID of the macro to execute\n * @param {object} [context] - Context data to pass to the macro (available as `scope` in macro)\n * @returns {Promise<*>} Result of macro execution, or undefined if not found\n */\nexport async function executeMacroById(macroId, context = {}) {\n if (!macroId) return;\n const macro = game.macros.get(macroId);\n if (!macro) return;\n return macro.execute(context);\n}\n\n/**\n * Get macros visible to the current user for selection UI.\n * @param {object} [options] - Filter options\n * @param {string} [options.includeId] - Macro ID to retain even when not visible, so a stored selection is not dropped\n * @returns {Array<{id: string, name: string, type: string}>} Array of macro options\n */\nexport function getAvailableMacros({ includeId } = {}) {\n return (game.macros?.contents ?? []).filter((m) => m.visible || m.id === includeId).map((m) => ({ id: m.id, name: m.name, type: m.type }));\n}\n","import { BigCal, Chronicle, CinematicOverlay, HUD, MiniCal, Stopwatch, SunDial, TimeKeeper } from '../applications/_module.mjs';\nimport { CalendarManager } from '../calendar/_module.mjs';\nimport { HOOKS, MODULE, SETTINGS, SOCKET_TYPES } from '../constants.mjs';\nimport { NoteManager } from '../notes/_module.mjs';\nimport { WeatherManager } from '../weather/_module.mjs';\nimport { canViewBigCal, canViewChronicle, canViewHUD, canViewMiniCal, canViewStopwatch, canViewSunDial, canViewTimeKeeper } from './_module.mjs';\n\n/**\n * Socket manager for handling multiplayer synchronization.\n */\nexport class CalendariaSocket {\n /**\n * Socket message types for different sync operations.\n * @enum {string}\n * @readonly\n */\n static TYPES = {\n /** Clock start/stop state update */\n CLOCK_UPDATE: 'clockUpdate',\n /** World time change */\n DATE_CHANGE: 'dateChange',\n /** Calendar note CRUD operation */\n NOTE_UPDATE: 'noteUpdate',\n /** Active calendar change */\n CALENDAR_SWITCH: 'calendarSwitch'\n };\n\n /**\n * Initialize the socket system and register message handlers.\n * @returns {void}\n */\n static initialize() {\n game.socket.on(`module.${MODULE.ID}`, this.#onMessage.bind(this));\n ATLAS.log(3, 'Socket system initialized');\n }\n\n /**\n * Emit a raw socket message to all connected clients.\n * @param {string} type - The message type from CalendariaSocket.TYPES\n * @param {object} data - The data payload to send\n * @returns {void}\n */\n static emit(type, data) {\n game.socket.emit(`module.${MODULE.ID}`, { type, data });\n }\n\n /**\n * Emit a calendar switch message to all connected clients.\n * @param {string} calendarId - The ID of the calendar to switch to\n * @returns {void}\n */\n static emitCalendarSwitch(calendarId) {\n if (!game.user.isGM) return;\n this.emit(SOCKET_TYPES.CALENDAR_SWITCH, { calendarId });\n }\n\n /**\n * Emit a date/time change message to all connected clients.\n * @param {number} worldTime - The new world time in seconds\n * @param {number} delta - The time delta in seconds\n * @returns {void}\n */\n static emitDateChange(worldTime, delta) {\n if (!this.isPrimaryGM()) return;\n this.emit(SOCKET_TYPES.DATE_CHANGE, { worldTime, delta });\n }\n\n /**\n * Emit a note update message to all connected clients.\n * @param {'created'|'updated'|'deleted'} action - The type of note operation\n * @param {object} noteData - The note data (stub for created/updated, id for deleted)\n * @param {string} noteData.id - The journal page ID\n * @param {string} [noteData.name] - The note name (for created/updated)\n * @param {object} [noteData.flagData] - The note's calendar data (for created/updated)\n * @returns {void}\n */\n static emitNoteUpdate(action, noteData) {\n if (!game.user.isGM) return;\n this.emit(SOCKET_TYPES.NOTE_UPDATE, { action, ...noteData });\n }\n\n /**\n * Emit a clock state update to all connected clients.\n * @param {boolean} running - Whether the real-time clock is running\n * @param {number} [ratio] - The real-time to game-time ratio\n * @returns {void}\n */\n static emitClockUpdate(running, ratio = 1) {\n if (!this.isPrimaryGM()) return;\n this.emit(SOCKET_TYPES.CLOCK_UPDATE, { running, ratio });\n }\n\n /**\n * Broadcast cinematic play payload to all clients.\n * @param {object} payload - CinematicPayload\n */\n static emitCinematicPlay(payload) {\n if (!this.isPrimaryGM()) return;\n this.emit(SOCKET_TYPES.CINEMATIC_PLAY, payload);\n }\n\n /**\n * Broadcast cinematic abort to all clients.\n */\n static emitCinematicAbort() {\n if (!this.isPrimaryGM()) return;\n this.emit(SOCKET_TYPES.CINEMATIC_ABORT, {});\n }\n\n /**\n * Handle incoming socket messages and route to appropriate handlers.\n * @private\n * @param {object} message - The incoming socket message\n * @param {string} message.type - The message type\n * @param {object} message.data - The message data payload\n * @returns {void}\n */\n static #onMessage({ type, data }) {\n switch (type) {\n case SOCKET_TYPES.CLOCK_UPDATE:\n this.#handleClockUpdate(data);\n break;\n case SOCKET_TYPES.CREATE_NOTE:\n this.#handleCreateNote(data);\n break;\n case SOCKET_TYPES.CREATE_NOTE_COMPLETE:\n this.#handleCreateNoteComplete(data);\n break;\n case SOCKET_TYPES.DATE_CHANGE:\n this.#handleDateChange(data);\n break;\n case SOCKET_TYPES.NOTE_UPDATE:\n this.#handleNoteUpdate(data);\n break;\n case SOCKET_TYPES.OWNERSHIP_UPDATE:\n this.#handleOwnershipUpdate(data);\n break;\n case SOCKET_TYPES.CALENDAR_SWITCH:\n this.#handleCalendarSwitch(data);\n break;\n case SOCKET_TYPES.WEATHER_CHANGE:\n this.#handleWeatherChange(data);\n break;\n case SOCKET_TYPES.WEATHER_REQUEST:\n this.#handleWeatherRequest(data);\n break;\n case SOCKET_TYPES.TIME_REQUEST:\n this.#handleTimeRequest(data);\n break;\n case SOCKET_TYPES.CALENDAR_REQUEST:\n this.#handleCalendarRequest(data);\n break;\n case SOCKET_TYPES.REMINDER_NOTIFY:\n this.#handleReminderNotify(data);\n break;\n case SOCKET_TYPES.CINEMATIC_PLAY:\n this.#handleCinematicPlay(data);\n break;\n case SOCKET_TYPES.CINEMATIC_ABORT:\n this.#handleCinematicAbort();\n break;\n case SOCKET_TYPES.BIG_CAL_VISIBILITY:\n this.#handleBigCalVisibility(data);\n break;\n case SOCKET_TYPES.CHRONICLE_VISIBILITY:\n this.#handleChronicleVisibility(data);\n break;\n case SOCKET_TYPES.HUD_VISIBILITY:\n this.#handleHUDVisibility(data);\n break;\n case SOCKET_TYPES.MINI_CAL_VISIBILITY:\n this.#handleMiniCalVisibility(data);\n break;\n case SOCKET_TYPES.STOPWATCH_VISIBILITY:\n this.#handleStopwatchVisibility(data);\n break;\n case SOCKET_TYPES.TIME_KEEPER_VISIBILITY:\n this.#handleTimeKeeperVisibility(data);\n break;\n case SOCKET_TYPES.SUN_DIAL_VISIBILITY:\n this.#handleSunDialVisibility(data);\n break;\n default:\n ATLAS.log(1, `Unknown socket message type: ${type}`);\n }\n }\n\n /**\n * Handle remote cinematic play command.\n * @private\n * @param {object} data - CinematicPayload\n */\n static #handleCinematicPlay(data) {\n ATLAS.log(3, 'Handling remote cinematic play');\n CinematicOverlay.play(data);\n }\n\n /**\n * Handle remote cinematic abort command.\n * @private\n */\n static #handleCinematicAbort() {\n ATLAS.log(3, 'Handling remote cinematic abort');\n CinematicOverlay.abort();\n }\n\n /**\n * Handle remote calendar switch messages.\n * @private\n * @param {object} data - The calendar switch data\n * @param {string} data.calendarId - The ID of the calendar to switch to\n * @returns {void}\n */\n static #handleCalendarSwitch(data) {\n const { calendarId } = data;\n if (!calendarId) return;\n ATLAS.log(3, `Handling remote calendar switch to: ${calendarId}`);\n CalendarManager.handleRemoteSwitch(calendarId);\n }\n\n /**\n * Handle remote date/time change messages.\n * @private\n * @param {object} data - The date change data\n * @param {number} data.worldTime - The new world time in seconds\n * @param {number} data.delta - The time delta in seconds\n * @returns {void}\n */\n static #handleDateChange(data) {\n ATLAS.log(3, 'Handling remote date change', data);\n Hooks.callAll(HOOKS.REMOTE_DATE_CHANGE, data);\n }\n\n /**\n * Handle remote note update messages.\n * @private\n * @param {object} data - The note update data\n * @param {'created'|'updated'|'deleted'} data.action - The type of operation\n * @param {string} data.id - The journal page ID\n * @param {string} [data.name] - The note name\n * @param {object} [data.flagData] - The note's calendar data\n * @returns {void}\n */\n static #handleNoteUpdate(data) {\n const { action, id } = data;\n if (!action || !id) return;\n ATLAS.log(3, `Handling remote note ${action}: ${id}`);\n foundry.applications.instances.get('calendaria-big-cal')?.render();\n switch (action) {\n case 'created':\n Hooks.callAll(HOOKS.NOTE_CREATED, data);\n break;\n case 'updated':\n Hooks.callAll(HOOKS.NOTE_UPDATED, data);\n break;\n case 'deleted':\n Hooks.callAll(HOOKS.NOTE_DELETED, id);\n break;\n }\n }\n\n /**\n * Handle ownership update request from a player.\n * @private\n * @param {object} data - The ownership update data\n * @param {string} data.journalId - The journal entry ID\n * @param {object} data.ownership - Ownership updates {userId: level}\n */\n static async #handleOwnershipUpdate(data) {\n if (!this.isPrimaryGM()) return;\n const { journalId, ownership } = data;\n if (!journalId || !ownership) return;\n const journal = game.journal.get(journalId);\n if (!journal) return;\n ATLAS.log(3, `Primary GM handling ownership update for journal: ${journalId}`);\n await journal.update({ ownership });\n }\n\n /**\n * Handle remote clock state update messages.\n * @private\n * @param {object} data - The clock update data\n * @param {boolean} data.running - Whether the clock is running\n * @param {number} data.ratio - The real-time to game-time ratio\n * @returns {void}\n */\n static #handleClockUpdate(data) {\n const { running, ratio } = data;\n ATLAS.log(3, `Handling remote clock update: running=${running}, ratio=${ratio}`);\n Hooks.callAll('calendaria.clockUpdate', { running, ratio });\n }\n\n /**\n * Handle note creation request from non-GM users.\n * @private\n * @param {object} data - The note creation data\n * @param {string} data.name - Note name\n * @param {string} data.content - Note content (HTML)\n * @param {object} data.noteData - Calendar note data\n * @param {string} data.calendarId - Calendar ID\n * @param {object} data.journalData - Additional journal data\n * @param {string} data.requesterId - User ID of the requesting player\n * @returns {void}\n */\n static async #handleCreateNote(data) {\n if (!this.isPrimaryGM()) return;\n const { name, content, noteData, calendarId, journalData, requesterId } = data;\n ATLAS.log(3, `Primary GM handling note creation request: ${name}`);\n const noteDataWithAuthor = { ...noteData, author: requesterId };\n const page = await NoteManager.createNote({ name, content, noteData: noteDataWithAuthor, calendarId, journalData, creatorId: requesterId });\n if (page && requesterId) this.emit(SOCKET_TYPES.CREATE_NOTE_COMPLETE, { pageId: page.id, journalId: page.parent.id, requesterId });\n }\n\n /**\n * Handle note creation completion on requesting client.\n * @private\n * @param {object} data - The completion data\n * @param {string} data.pageId - Created page ID\n * @param {string} data.journalId - Parent journal ID\n * @param {string} data.requesterId - User ID of the requesting player\n * @returns {void}\n */\n static #handleCreateNoteComplete(data) {\n const { pageId, journalId, requesterId, openSheet = 'edit' } = data;\n if (game.user.id !== requesterId) return;\n if (!openSheet) return;\n ATLAS.log(3, `Note creation complete, opening editor for: ${pageId}`);\n const journal = game.journal.get(journalId);\n const page = journal?.pages.get(pageId);\n if (page) page.sheet.render(true, { mode: openSheet });\n }\n\n /**\n * Handle remote weather change messages.\n * @private\n * @param {object} data - The weather change data\n * @param {object} data.weather - The new weather state\n * @returns {void}\n */\n static #handleWeatherChange(data) {\n const { weather } = data;\n ATLAS.log(3, `Handling remote weather change: ${weather?.id ?? 'cleared'}`);\n WeatherManager.handleRemoteWeatherChange(data);\n }\n\n /**\n * Handle weather change request from non-GM users.\n * @private\n * @param {object} data - The weather request data\n * @param {string} [data.action] - The action: 'set', 'generate', or 'clear'\n * @param {string} [data.presetId] - The preset ID (for 'set' action)\n * @param {object} [data.options] - Additional options\n * @returns {void}\n */\n static async #handleWeatherRequest(data) {\n if (!this.isPrimaryGM()) return;\n const { action, presetId, options = {} } = data;\n ATLAS.log(3, `Primary GM handling weather request: ${action}`, data);\n switch (action) {\n case 'set':\n await WeatherManager.setWeather(presetId, { ...options, fromSocket: true });\n break;\n case 'generate':\n await WeatherManager.generateAndSetWeather({ ...options, fromSocket: true });\n break;\n case 'clear':\n await WeatherManager.clearWeather(true, true, options.zoneId);\n break;\n }\n }\n\n /**\n * Handle time change request from non-GM users.\n * @private\n * @param {object} data - The time request data\n * @param {string} data.action - The action: 'advance', 'set', or 'jump'\n * @param {number} [data.delta] - Time delta in seconds (for 'advance')\n * @param {object} [data.components] - Time components (for 'set')\n * @param {object} [data.date] - Date object (for 'jump')\n * @param {boolean|null} [data.cinematicOverride] - Explicit cinematic override (true/false); null/undefined means threshold-gated\n * @returns {void}\n */\n static async #handleTimeRequest(data) {\n if (!this.isPrimaryGM()) return;\n const { action, delta, components, date, cinematicOverride = null } = data;\n ATLAS.log(3, `Primary GM handling time request: ${action}`, data);\n const runAdvance = async (seconds) => {\n if (cinematicOverride === true) await CinematicOverlay.triggerFromAdvance(seconds);\n else if (cinematicOverride === false) await game.time.advance(seconds);\n else await CinematicOverlay.gatedAdvance(seconds);\n };\n switch (action) {\n case 'advance':\n await runAdvance(delta);\n break;\n case 'set': {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return;\n const currentComponents = game.time.components;\n const merged = { ...currentComponents, ...components };\n const targetSeconds = calendar.componentsToTime(merged);\n const timeDelta = targetSeconds - game.time.worldTime;\n await runAdvance(timeDelta);\n break;\n }\n case 'jump': {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar || !date) return;\n const current = game.time.components;\n const targetComponents = { ...current, year: date.year, month: date.month, dayOfMonth: date.day };\n const targetSeconds = calendar.componentsToTime(targetComponents);\n const timeDelta = targetSeconds - game.time.worldTime;\n await runAdvance(timeDelta);\n break;\n }\n }\n }\n\n /**\n * Handle calendar switch request from non-GM users.\n * @private\n * @param {object} data - The calendar request data\n * @param {string} data.calendarId - The calendar ID to switch to\n * @returns {void}\n */\n static async #handleCalendarRequest(data) {\n if (!this.isPrimaryGM()) return;\n const { calendarId } = data;\n ATLAS.log(3, `Primary GM handling calendar switch request: ${calendarId}`);\n await CalendarManager.switchCalendar(calendarId);\n }\n\n /**\n * Handle remote reminder notification messages.\n * @private\n * @param {object} data - The reminder notification data\n * @returns {void}\n */\n static #handleReminderNotify(data) {\n ATLAS.log(3, `Handling reminder notification for ${data.noteName}`);\n Hooks.callAll(HOOKS.REMINDER_RECEIVED, data);\n }\n\n /**\n * Handle BigCal visibility commands from GM.\n * @private\n * @param {object} data - The BigCal visibility data\n * @param {boolean} data.visible - Whether BigCal should be visible\n * @returns {void}\n */\n static #handleBigCalVisibility(data) {\n if (game.user.isGM) return;\n const { visible } = data;\n ATLAS.log(3, `Handling BigCal visibility: ${visible}`);\n if (visible && canViewBigCal()) BigCal.show();\n else if (!visible) BigCal.hide();\n }\n\n /**\n * Handle Chronicle visibility commands from GM.\n * @private\n * @param {object} data - The Chronicle visibility data\n * @param {boolean} data.visible - Whether Chronicle should be visible\n * @returns {void}\n */\n static #handleChronicleVisibility(data) {\n if (game.user.isGM) return;\n const { visible } = data;\n ATLAS.log(3, `Handling Chronicle visibility: ${visible}`);\n if (visible && canViewChronicle()) Chronicle.show({ silent: true });\n else if (!visible) Chronicle.hide();\n }\n\n /**\n * Handle HUD visibility commands from GM.\n * @private\n * @param {object} data - The HUD visibility data\n * @param {boolean} data.visible - Whether HUD should be visible\n * @returns {void}\n */\n static #handleHUDVisibility(data) {\n if (game.user.isGM) return;\n const { visible } = data;\n ATLAS.log(3, `Handling HUD visibility: ${visible}`);\n if (visible && canViewHUD() && game.settings.get(MODULE.ID, SETTINGS.SHOW_CALENDAR_HUD)) HUD.show();\n else if (!visible) HUD.hide();\n }\n\n /**\n * Handle MiniCal visibility commands from GM.\n * @private\n * @param {object} data - The MiniCal visibility data\n * @param {boolean} data.visible - Whether MiniCal should be visible\n * @returns {void}\n */\n static #handleMiniCalVisibility(data) {\n if (game.user.isGM) return;\n const { visible } = data;\n ATLAS.log(3, `Handling MiniCal visibility: ${visible}`);\n if (visible && canViewMiniCal()) MiniCal.show({ silent: true });\n else if (!visible) MiniCal.hide();\n }\n\n /**\n * Handle TimeKeeper visibility commands from GM.\n * @private\n * @param {object} data - The TimeKeeper visibility data\n * @param {boolean} data.visible - Whether TimeKeeper should be visible\n * @returns {void}\n */\n static #handleTimeKeeperVisibility(data) {\n if (game.user.isGM) return;\n const { visible } = data;\n ATLAS.log(3, `Handling TimeKeeper visibility: ${visible}`);\n if (visible && canViewTimeKeeper()) TimeKeeper.show({ silent: true });\n else if (!visible) TimeKeeper.hide();\n }\n\n /**\n * Handle Stop Watch visibility commands from GM.\n * @private\n * @param {object} data - The Stop Watch visibility data\n * @param {boolean} data.visible - Whether Stop Watch should be visible\n * @returns {void}\n */\n static #handleStopwatchVisibility(data) {\n if (game.user.isGM) return;\n const { visible } = data;\n ATLAS.log(3, `Handling Stop Watch visibility: ${visible}`);\n if (visible && canViewStopwatch()) Stopwatch.show();\n else if (!visible) Stopwatch.hide();\n }\n\n /**\n * Handle Sun Dial visibility commands from GM.\n * @private\n * @param {object} data - The Sun Dial visibility data\n * @param {boolean} data.visible - Whether Sun Dial should be visible\n * @returns {void}\n */\n static #handleSunDialVisibility(data) {\n if (game.user.isGM) return;\n const { visible } = data;\n ATLAS.log(3, `Handling Sun Dial visibility: ${visible}`);\n if (visible && canViewSunDial()) SunDial.show({ silent: true });\n else if (!visible) SunDial.hide();\n }\n\n /**\n * Determine if the current user is the primary GM.\n * @returns {boolean} True if the current user is the primary GM\n */\n static isPrimaryGM() {\n if (!game.user.isGM) return false;\n const primaryGMOverride = game.settings.get(MODULE.ID, SETTINGS.PRIMARY_GM);\n if (primaryGMOverride) return primaryGMOverride === game.user.id;\n const activeGMs = game.users.filter((u) => u.isGM && u.active);\n if (activeGMs.length === 0) return false;\n const primaryGM = activeGMs.sort((a, b) => a.id.localeCompare(b.id))[0];\n return primaryGM.id === game.user.id;\n }\n\n /**\n * Get the current primary GM user.\n * @returns {object|null} The primary GM user, or null if none active\n */\n static getPrimaryGM() {\n const primaryGMOverride = game.settings.get(MODULE.ID, SETTINGS.PRIMARY_GM);\n if (primaryGMOverride) return game.users.get(primaryGMOverride) ?? null;\n const activeGMs = game.users.filter((u) => u.isGM && u.active);\n if (activeGMs.length === 0) return null;\n return activeGMs.sort((a, b) => a.id.localeCompare(b.id))[0];\n }\n}\n","import { isBundledCalendar } from '../calendar/calendar-loader.mjs';\nimport CalendarManager from '../calendar/calendar-manager.mjs';\nimport { COMPASS_DIRECTIONS, HOOKS, MODULE, SCENE_FLAGS, SETTINGS, WEATHER_PERIODS, WIND_SPEEDS } from '../constants.mjs';\nimport { executeMacroById } from '../utils/macro-utils.mjs';\nimport { canChangeWeather } from '../utils/permissions.mjs';\nimport { CalendariaSocket } from '../utils/socket.mjs';\nimport { CLIMATE_ZONE_TEMPLATES } from './data/climate-data.mjs';\nimport { ALL_PRESETS, resolveWeatherSoundPath, getAllPresets, getPreset, WEATHER_CATEGORIES } from './data/weather-presets.mjs';\nimport { applyForecastVariance, applyTempModifier, dateSeed, generateForecast, generateIntradayWeather, generateWeather, mergeClimateConfig, seededRandom } from './weather-generator.mjs';\n\n/**\n * Weather Manager.\n */\nexport default class WeatherManager {\n /** @type {object} Current weather keyed by zone ID */\n static #currentWeatherByZone = {};\n\n /** @type {boolean} Whether the manager is initialized */\n static #initialized = false;\n\n /** @type {{ key: string, forecast: object[] }|null} Cached legacy forecast to prevent re-randomization on re-render */\n static #legacyForecastCache = null;\n\n /** @type {Promise | null} Promise of the currently running day-change auto-gen work. Null when idle. */\n static #dayChangeWork = null;\n\n /** @type {Set} Zone IDs already warned about, so per-tick lookups do not spam the log */\n static #warnedZoneIds = new Set();\n\n /** @type {string} Default zone key used when no climate zone is configured */\n static DEFAULT_ZONE = '_default';\n\n /**\n * Resolve the effective fxPreset for a preset, checking visual overrides for built-in presets.\n * @param {object} preset - Weather preset object\n * @returns {string|null} FXMaster preset name or null\n */\n static #resolveFxPreset(preset) {\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[preset.id];\n if (overrides?.fxPreset !== undefined) return overrides.fxPreset;\n return preset.fxPreset ?? null;\n }\n\n /**\n * Resolve the effective soundFx for a preset, checking visual overrides for built-in presets.\n * @param {object} preset - Weather preset object\n * @returns {string|null} Full Foundry-resolvable sound path or null\n */\n static #resolveSoundFx(preset) {\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[preset.id];\n if (overrides?.soundFx !== undefined) return resolveWeatherSoundPath(overrides.soundFx);\n return resolveWeatherSoundPath(preset.soundFx);\n }\n\n /**\n * Resolve the effective fxMacro for a preset, checking visual overrides.\n * @param {object} preset - Weather preset object\n * @returns {string|null} Macro ID or null\n */\n static #resolveFxMacro(preset) {\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[preset.id];\n if (overrides?.fxMacro !== undefined) return overrides.fxMacro;\n return preset.fxMacro ?? null;\n }\n\n /**\n * Resolve effective environmentBase, merging visual overrides for built-in presets.\n * @param {object} preset - Weather preset object\n * @returns {object|null} Merged environment base config or null\n */\n static #resolveEnvironmentBase(preset) {\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[preset.id];\n const base = preset.environmentBase ?? {};\n const ov = overrides?.environmentBase ?? {};\n const merged = { ...base, ...Object.fromEntries(Object.entries(ov).filter(([, v]) => v != null)) };\n return Object.keys(merged).length ? merged : null;\n }\n\n /**\n * Resolve effective environmentDark, merging visual overrides for built-in presets.\n * @param {object} preset - Weather preset object\n * @returns {object|null} Merged environment dark config or null\n */\n static #resolveEnvironmentDark(preset) {\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[preset.id];\n const base = preset.environmentDark ?? {};\n const ov = overrides?.environmentDark ?? {};\n const merged = { ...base, ...Object.fromEntries(Object.entries(ov).filter(([, v]) => v != null)) };\n return Object.keys(merged).length ? merged : null;\n }\n\n /**\n * Resolve effective environmentCycle, checking visual overrides for built-in presets.\n * @param {object} preset - Weather preset object\n * @returns {boolean|null} Blend ambience override or null\n */\n static #resolveEnvironmentCycle(preset) {\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[preset.id];\n if (overrides?.environmentCycle != null) return overrides.environmentCycle;\n return preset.environmentCycle ?? null;\n }\n\n /**\n * Resolve the effective darknessPenalty for a preset, checking visual overrides for built-in presets.\n * @param {object} preset - Weather preset object\n * @returns {number} Darkness added to the scene by this weather\n */\n static #resolveDarknessPenalty(preset) {\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[preset.id];\n if (overrides?.darknessPenalty != null) return overrides.darknessPenalty;\n return preset.darknessPenalty ?? 0;\n }\n\n /**\n * Build a full weather state object from a generator result.\n * @param {object} result - Generator result { preset, temperature, wind, precipitation }\n * @param {string} [season] - Current season name\n * @returns {object} Full weather object ready for storage\n * @private\n */\n static #buildWeatherFromResult(result, season = null) {\n return {\n id: result.preset.id,\n label: result.preset.label,\n description: result.preset.description,\n icon: result.preset.icon,\n color: result.preset.color,\n category: result.preset.category,\n temperature: result.temperature,\n wind: result.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: result.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: this.#resolveDarknessPenalty(result.preset),\n environmentBase: this.#resolveEnvironmentBase(result.preset),\n environmentDark: this.#resolveEnvironmentDark(result.preset),\n environmentCycle: this.#resolveEnvironmentCycle(result.preset),\n fxPreset: this.#resolveFxPreset(result.preset),\n soundFx: this.#resolveSoundFx(result.preset),\n fxMacro: this.#resolveFxMacro(result.preset),\n setAt: game.time.worldTime,\n setBy: game.user.id,\n generated: true,\n season\n };\n }\n\n /**\n * Initialize the weather manager.\n */\n static async initialize() {\n if (this.#initialized) return;\n this.#currentWeatherByZone = game.settings.get(MODULE.ID, SETTINGS.CURRENT_WEATHER) || {};\n if ('undefined' in this.#currentWeatherByZone) {\n this.#currentWeatherByZone[this.DEFAULT_ZONE] = this.#currentWeatherByZone['undefined'];\n delete this.#currentWeatherByZone['undefined'];\n if (CalendariaSocket.isPrimaryGM()) {\n await game.settings.set(MODULE.ID, SETTINGS.CURRENT_WEATHER, this.#currentWeatherByZone);\n ATLAS.log(3, 'Migrated stale \"undefined\" weather zone key to _default');\n }\n }\n Hooks.on(HOOKS.DAY_CHANGE, this.#onDayChange.bind(this));\n Hooks.on('updateScene', this.#onSceneActivated.bind(this));\n Hooks.on(HOOKS.MIDNIGHT, () => this.#onPeriodThreshold(WEATHER_PERIODS.NIGHT.id));\n Hooks.on(HOOKS.SUNRISE, () => this.#onPeriodThreshold(WEATHER_PERIODS.MORNING.id));\n Hooks.on(HOOKS.MIDDAY, () => this.#onPeriodThreshold(WEATHER_PERIODS.AFTERNOON.id));\n Hooks.on(HOOKS.SUNSET, () => this.#onPeriodThreshold(WEATHER_PERIODS.EVENING.id));\n if (CalendariaSocket.isPrimaryGM() && !game.settings.get(MODULE.ID, SETTINGS.WEATHER_DAY_INDEX_MIGRATED)) await this.#migrateWeatherDayIndex();\n if (CalendariaSocket.isPrimaryGM()) await this.#migrateLegacySoundKeys();\n if (CalendariaSocket.isPrimaryGM() && !game.settings.get(MODULE.ID, SETTINGS.WEATHER_YEAR_KEY_MIGRATED)) {\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN, {});\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, {});\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_YEAR_KEY_MIGRATED, true);\n ATLAS.log(3, 'Cleared weather history and forecast plan for year key migration');\n }\n if (CalendariaSocket.isPrimaryGM()) {\n const calendar = CalendarManager.getActiveCalendar();\n if (calendar?.weather?.autoGenerate !== undefined) if (calendar.metadata?.id) await game.settings.set(MODULE.ID, SETTINGS.AUTO_GENERATE_WEATHER, !!calendar.weather.autoGenerate);\n const autoGenerate = game.settings.get(MODULE.ID, SETTINGS.AUTO_GENERATE_WEATHER);\n if (Object.keys(this.#currentWeatherByZone).length) {\n const components = game.time.components;\n const zones = this.#getEffectiveZones();\n for (const zone of zones) {\n const weather = this.#currentWeatherByZone[zone.id];\n if (weather) {\n const existing = this.getWeatherForDate(components.year, components.month, components.dayOfMonth ?? 0, zone.id);\n if (!existing) await this.#recordWeatherHistory(weather, zone.id);\n } else if (autoGenerate) {\n await this.generateAndSetWeather({ zoneId: zone.id, broadcast: false, _fromPlan: true });\n }\n }\n if (autoGenerate) await this.#ensureForecastPlan();\n } else if (autoGenerate) {\n const zones = this.#getEffectiveZones();\n for (const zone of zones) await this.generateAndSetWeather({ zoneId: zone.id, broadcast: false, _fromPlan: true });\n await this.#ensureForecastPlan();\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { bulk: true });\n CalendariaSocket.emit('weatherChange', { weatherByZone: this.#currentWeatherByZone, bulk: true });\n ATLAS.log(3, 'Generated initial weather and forecast plan for fresh world');\n }\n }\n this.#initialized = true;\n ATLAS.log(3, 'WeatherManager initialized');\n }\n\n /**\n * Re-resolve environment overrides for a preset in all cached weather zones.\n * @param {string} presetId - The preset ID whose overrides changed\n */\n static refreshEnvironmentOverrides(presetId) {\n const customPresets = this.getCustomPresets();\n const preset = getPreset(presetId, customPresets);\n if (!preset) return;\n for (const weather of Object.values(this.#currentWeatherByZone)) {\n if (weather?.id !== presetId) continue;\n weather.darknessPenalty = this.#resolveDarknessPenalty(preset);\n weather.environmentBase = this.#resolveEnvironmentBase(preset);\n weather.environmentDark = this.#resolveEnvironmentDark(preset);\n weather.environmentCycle = this.#resolveEnvironmentCycle(preset);\n }\n }\n\n /**\n * Get the current weather for a zone.\n * @param {string} [zoneId] - Zone ID (resolves from active scene if omitted)\n * @param {object} [scene] - Scene to resolve zone from\n * @returns {object|null} Current weather state\n */\n static getCurrentWeather(zoneId, scene) {\n const resolvedZoneId = zoneId ?? this.getActiveZone(null, scene ?? game.scenes?.active)?.id ?? this.DEFAULT_ZONE;\n return this.#currentWeatherByZone[resolvedZoneId] ?? null;\n }\n\n /**\n * Resolve the display label for a weather preset, checking per-zone alias then Weather Editor override.\n * @param {string} presetId - Preset ID\n * @param {string} fallbackLabel - Fallback label (localization key or plain text)\n * @param {string} [calendarId] - Calendar ID for alias lookup\n * @param {string} [zoneId] - Zone ID for alias lookup\n * @returns {string} Resolved display label\n */\n static resolveDisplayLabel(presetId, fallbackLabel, calendarId, zoneId) {\n if (calendarId && zoneId) {\n const aliases = game.settings.get(MODULE.ID, SETTINGS.WEATHER_PRESET_ALIASES) || {};\n const alias = aliases[calendarId]?.[zoneId]?.[presetId];\n if (alias) return alias;\n }\n const overrides = (game.settings.get(MODULE.ID, SETTINGS.WEATHER_VISUAL_OVERRIDES) || {})[presetId];\n if (overrides?.label) return overrides.label;\n return _loc(fallbackLabel);\n }\n\n /**\n * Determine the current intraday period based on the world clock.\n * @returns {string} Period ID: 'night', 'morning', 'afternoon', or 'evening'\n */\n static getCurrentPeriod() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return WEATHER_PERIODS.MORNING.id;\n const zone = this.getActiveZone(null, game.scenes?.active);\n const sunrise = calendar.sunrise?.(undefined, zone) ?? 6;\n const sunset = calendar.sunset?.(undefined, zone) ?? 18;\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const midday = hoursPerDay / 2;\n const components = game.time.components;\n const hour = components.hour + (components.minute ?? 0) / (calendar?.days?.minutesPerHour ?? 60);\n if (hour < sunrise) return WEATHER_PERIODS.NIGHT.id;\n if (hour < midday) return WEATHER_PERIODS.MORNING.id;\n if (hour < sunset) return WEATHER_PERIODS.AFTERNOON.id;\n return WEATHER_PERIODS.EVENING.id;\n }\n\n /**\n * Handle a time-of-day threshold crossing for intraday weather.\n * @param {string} periodId - The new period ID\n * @private\n */\n static async #onPeriodThreshold(periodId) {\n if (!game.settings.get(MODULE.ID, SETTINGS.INTRADAY_WEATHER)) return;\n if (!CalendariaSocket.isPrimaryGM()) return;\n const zones = this.#getEffectiveZones();\n let anyChanged = false;\n for (const zone of zones) {\n const weather = this.#currentWeatherByZone[zone.id];\n if (!weather?.periods) continue;\n const periodWeather = weather.periods[periodId];\n if (!periodWeather) continue;\n Object.assign(weather, {\n id: periodWeather.preset?.id ?? periodWeather.id,\n label: periodWeather.preset?.label ?? periodWeather.label,\n description: periodWeather.preset?.description ?? periodWeather.description ?? '',\n icon: periodWeather.preset?.icon ?? periodWeather.icon,\n color: periodWeather.preset?.color ?? periodWeather.color,\n category: periodWeather.preset?.category ?? periodWeather.category,\n temperature: periodWeather.temperature,\n wind: periodWeather.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: periodWeather.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: periodWeather.preset ? this.#resolveDarknessPenalty(periodWeather.preset) : (periodWeather.darknessPenalty ?? 0),\n activePeriod: periodId\n });\n const customPresets = this.getCustomPresets();\n const preset = getPreset(weather.id, customPresets);\n if (preset) {\n weather.environmentBase = this.#resolveEnvironmentBase(preset);\n weather.environmentDark = this.#resolveEnvironmentDark(preset);\n weather.environmentCycle = this.#resolveEnvironmentCycle(preset);\n weather.fxPreset = this.#resolveFxPreset(preset);\n weather.soundFx = this.#resolveSoundFx(preset);\n weather.fxMacro = this.#resolveFxMacro(preset);\n }\n anyChanged = true;\n }\n if (!anyChanged) return;\n await game.settings.set(MODULE.ID, SETTINGS.CURRENT_WEATHER, this.#currentWeatherByZone);\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { bulk: true });\n Hooks.callAll(HOOKS.WEATHER_PERIOD_CHANGE, { period: periodId });\n CalendariaSocket.emit('weatherChange', { weatherByZone: this.#currentWeatherByZone, bulk: true });\n ATLAS.log(3, `Intraday weather period changed to: ${periodId}`);\n }\n\n /**\n * Get temperature for current weather, generating if missing.\n * @param {string} [zoneId] - Zone ID (resolves from active scene if omitted)\n * @returns {number|null} Temperature or null if no weather/zone\n */\n static getTemperature(zoneId) {\n const weather = this.getCurrentWeather(zoneId);\n if (!weather) return null;\n if (weather.temperature != null) return weather.temperature;\n return this.#generateTemperatureForPreset(weather.id);\n }\n\n /**\n * Set the current weather by preset ID.\n * @param {string} presetId - Weather preset ID\n * @param {object} [options] - Additional options\n * @param {number} [options.temperature] - Optional temperature override\n * @param {object} [options.wind] - Wind override { speed, direction, forced }\n * @param {object} [options.precipitation] - Precipitation override { type, intensity }\n * @param {boolean} [options.broadcast] - Whether to broadcast to other clients\n * @param {boolean} [options.fromSocket] - Whether this is a GM executing a socket request\n * @param {string} [options.period] - Specific period to set (when intraday enabled; defaults to current period)\n * @param {boolean} [options.allPeriods] - Set all periods to this weather (when intraday enabled)\n * @returns {Promise} The set weather\n */\n static async setWeather(presetId, options = {}) {\n const zoneId = 'zoneId' in options ? options.zoneId : (this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE);\n if (!options.fromSocket && !canChangeWeather()) {\n ATLAS.log(1, 'User lacks permission to set weather');\n ui.notifications.error('CALENDARIA.Permissions.NoAccess', { localize: true });\n return this.getCurrentWeather(zoneId);\n }\n if (!options.fromSocket && !game.user.isGM && canChangeWeather()) {\n CalendariaSocket.emit('weatherRequest', { action: 'set', presetId, options: { temperature: options.temperature, zoneId } });\n return this.getCurrentWeather(zoneId);\n }\n const customPresets = this.getCustomPresets();\n const preset = getPreset(presetId, customPresets);\n if (!preset) {\n ATLAS.log(2, `Weather preset not found: ${presetId}`);\n ui.notifications.warn(_loc('CALENDARIA.Weather.Error.PresetNotFound', { id: presetId }));\n return this.getCurrentWeather(zoneId);\n }\n const temperature = options.temperature ?? this.#generateTemperatureForPreset(presetId);\n const normalizedOptionWind = options.wind ? { ...options.wind, direction: this.#normalizeWindDirection(options.wind.direction) } : null;\n const weather = {\n id: preset.id,\n label: preset.label,\n description: preset.description,\n icon: preset.icon,\n color: preset.color,\n category: preset.category,\n temperature,\n wind: normalizedOptionWind ?? preset.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: options.precipitation ?? preset.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: this.#resolveDarknessPenalty(preset),\n environmentBase: this.#resolveEnvironmentBase(preset),\n environmentDark: this.#resolveEnvironmentDark(preset),\n environmentCycle: this.#resolveEnvironmentCycle(preset),\n fxPreset: options.fxPreset ?? this.#resolveFxPreset(preset),\n soundFx: options.soundFx ?? this.#resolveSoundFx(preset),\n fxMacro: options.fxMacro ?? this.#resolveFxMacro(preset),\n fxDensity: options.fxDensity ?? preset.fxDensity ?? null,\n fxSpeed: options.fxSpeed ?? preset.fxSpeed ?? null,\n fxColor: options.fxColor ?? preset.fxColor ?? null,\n setAt: game.time.worldTime,\n setBy: game.user.id\n };\n const intradayEnabled = game.settings.get(MODULE.ID, SETTINGS.INTRADAY_WEATHER);\n if (intradayEnabled && !options.allPeriods) {\n const targetPeriod = options.period ?? this.getCurrentPeriod();\n const currentPeriod = this.getCurrentPeriod();\n const existing = this.#currentWeatherByZone[zoneId];\n const periods = existing?.periods ? { ...existing.periods } : {};\n periods[targetPeriod] = { ...weather };\n if (targetPeriod !== currentPeriod && existing) {\n existing.periods = periods;\n await this.#saveWeather(existing, options.broadcast !== false, zoneId);\n } else {\n weather.activePeriod = currentPeriod;\n weather.periods = periods;\n await this.#saveWeather(weather, options.broadcast !== false, zoneId);\n }\n } else if (intradayEnabled && options.allPeriods) {\n const periods = {};\n for (const p of Object.values(WEATHER_PERIODS)) periods[p.id] = { ...weather };\n weather.activePeriod = this.getCurrentPeriod();\n weather.periods = periods;\n await this.#saveWeather(weather, options.broadcast !== false, zoneId);\n } else {\n await this.#saveWeather(weather, options.broadcast !== false, zoneId);\n }\n if (game.settings.get(MODULE.ID, SETTINGS.GM_OVERRIDE_CLEARS_FORECAST)) {\n await this.#clearForecastPlan(zoneId);\n await this.#ensureForecastPlan();\n }\n return weather;\n }\n\n /**\n * Set custom weather with arbitrary values.\n * @param {object} weatherData - Weather data\n * @param {string} weatherData.label - Display label\n * @param {string} [weatherData.icon] - Icon class\n * @param {string} [weatherData.color] - Display color\n * @param {string} [weatherData.description] - Description\n * @param {number} [weatherData.temperature] - Temperature\n * @param {string} [weatherData.period] - Specific period to set (when intraday enabled)\n * @param {boolean} [weatherData.allPeriods] - Set all periods to this weather\n * @param {boolean} [broadcast] - Whether to broadcast\n * @returns {Promise} The set weather\n */\n static async setCustomWeather(weatherData, broadcast = true) {\n const zoneId = weatherData.zoneId ?? this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE;\n if (!canChangeWeather()) {\n ui.notifications.error('CALENDARIA.Permissions.NoAccess', { localize: true });\n return this.getCurrentWeather(zoneId);\n }\n if (!game.user.isGM) {\n ui.notifications.warn('CALENDARIA.Weather.Error.CustomRequiresGM', { localize: true });\n return this.getCurrentWeather(zoneId);\n }\n const weather = {\n id: 'custom',\n label: weatherData.label,\n description: weatherData.description || '',\n icon: weatherData.icon || 'fa-question',\n color: weatherData.color || '#888888',\n category: 'custom',\n temperature: weatherData.temperature ?? null,\n wind: weatherData.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: weatherData.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: weatherData.darknessPenalty ?? 0,\n environmentBase: weatherData.environmentBase ?? null,\n environmentDark: weatherData.environmentDark ?? null,\n fxPreset: weatherData.fxPreset ?? null,\n soundFx: weatherData.soundFx ?? null,\n fxMacro: weatherData.fxMacro ?? null,\n fxDensity: weatherData.fxDensity ?? null,\n fxSpeed: weatherData.fxSpeed ?? null,\n fxColor: weatherData.fxColor ?? null,\n setAt: game.time.worldTime,\n setBy: game.user.id\n };\n const intradayEnabled = game.settings.get(MODULE.ID, SETTINGS.INTRADAY_WEATHER);\n if (intradayEnabled && !weatherData.allPeriods) {\n const targetPeriod = weatherData.period ?? this.getCurrentPeriod();\n const currentPeriod = this.getCurrentPeriod();\n const existing = this.#currentWeatherByZone[zoneId];\n const periods = existing?.periods ? { ...existing.periods } : {};\n periods[targetPeriod] = { ...weather };\n if (targetPeriod !== currentPeriod && existing) {\n existing.periods = periods;\n await this.#saveWeather(existing, broadcast, zoneId);\n } else {\n weather.activePeriod = currentPeriod;\n weather.periods = periods;\n await this.#saveWeather(weather, broadcast, zoneId);\n }\n } else if (intradayEnabled && weatherData.allPeriods) {\n const periods = {};\n for (const p of Object.values(WEATHER_PERIODS)) periods[p.id] = { ...weather };\n weather.activePeriod = this.getCurrentPeriod();\n weather.periods = periods;\n await this.#saveWeather(weather, broadcast, zoneId);\n } else {\n await this.#saveWeather(weather, broadcast, zoneId);\n }\n if (game.settings.get(MODULE.ID, SETTINGS.GM_OVERRIDE_CLEARS_FORECAST)) {\n await this.#clearForecastPlan(zoneId);\n await this.#ensureForecastPlan();\n }\n return weather;\n }\n\n /**\n * Clear the current weather.\n * @param {boolean} [broadcast] - Whether to broadcast\n * @param {boolean} [fromSocket] - Whether this was triggered by a socket event\n * @param {string} [zoneId] - Zone ID to clear weather for\n * @returns {Promise}\n */\n static async clearWeather(broadcast = true, fromSocket = false, zoneId) {\n if (!fromSocket && !canChangeWeather()) return;\n const resolvedZoneId = zoneId ?? this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE;\n if (!fromSocket && !game.user.isGM && canChangeWeather()) {\n CalendariaSocket.emit('weatherRequest', { action: 'clear', options: { zoneId: resolvedZoneId } });\n return;\n }\n await this.#saveWeather(null, broadcast, resolvedZoneId);\n ATLAS.log(3, `Weather cleared for zone ${resolvedZoneId}`);\n }\n\n /**\n * Save weather to settings and optionally broadcast.\n * @param {object|null} weather - Weather to save\n * @param {boolean} broadcast - Whether to broadcast\n * @param {string} [zoneId] - Zone ID for this weather entry\n * @private\n */\n static async #saveWeather(weather, broadcast, zoneId) {\n this.#legacyForecastCache = null;\n zoneId ??= this.DEFAULT_ZONE;\n const previous = this.#currentWeatherByZone[zoneId] ?? null;\n if (weather) this.#currentWeatherByZone[zoneId] = weather;\n else delete this.#currentWeatherByZone[zoneId];\n await game.settings.set(MODULE.ID, SETTINGS.CURRENT_WEATHER, this.#currentWeatherByZone);\n if (weather && CalendariaSocket.isPrimaryGM()) await this.#recordWeatherHistory(weather, zoneId);\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { previous, current: weather, zoneId });\n if (broadcast) CalendariaSocket.emit('weatherChange', { weather, zoneId });\n if (weather?.fxMacro && CalendariaSocket.isPrimaryGM()) executeMacroById(weather.fxMacro, { weather, previousWeather: previous, zoneId });\n ATLAS.log(3, `Weather changed for zone ${zoneId}:`, weather?.id ?? 'cleared');\n }\n\n /**\n * Handle remote weather change.\n * @param {object} data - Socket data\n */\n static handleRemoteWeatherChange(data) {\n if (data.bulk) {\n this.#currentWeatherByZone = { ...data.weatherByZone };\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { bulk: true, remote: true });\n return;\n }\n const { weather, zoneId } = data;\n if (weather) this.#currentWeatherByZone[zoneId] = weather;\n else delete this.#currentWeatherByZone[zoneId];\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { previous: null, current: weather, zoneId, remote: true });\n }\n\n /**\n * Generate and set weather based on active calendar's climate zone.\n * @param {object} [options] - Generation options\n * @param {string} [options.zoneId] - Zone ID override (uses active if not provided)\n * @param {string} [options.season] - Season name override (uses current if not provided)\n * @param {boolean} [options.broadcast] - Whether to broadcast\n * @param {boolean} [options.randomize] - Use true randomness instead of date-seeded generation\n * @returns {Promise} Generated weather\n */\n static async generateAndSetWeather(options = {}) {\n const zoneId = 'zoneId' in options ? options.zoneId : (this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE);\n if (!options.fromSocket && !canChangeWeather()) {\n ATLAS.log(1, 'User lacks permission to generate weather');\n return this.getCurrentWeather(zoneId);\n }\n if (!options.fromSocket && !game.user.isGM && canChangeWeather()) {\n CalendariaSocket.emit('weatherRequest', { action: 'generate', options: { zoneId, season: options.season, randomize: options.randomize } });\n return this.getCurrentWeather(zoneId);\n }\n const zoneConfig = this.getActiveZone(zoneId);\n const seasonData = this.#getCurrentSeason();\n const season = options.season || (seasonData?.name ?? null);\n const seasonClimate = seasonData?.climate ?? null;\n const customPresets = this.getCustomPresets();\n const currentWeather = this.#currentWeatherByZone[zoneId];\n const currentWeatherId = currentWeather?.id ?? null;\n let inertia = game.settings.get(MODULE.ID, SETTINGS.WEATHER_INERTIA) ?? 0.3;\n if (currentWeather?.season && season !== currentWeather.season) inertia *= 0.5;\n const prevWeather = currentWeather ? { temperature: currentWeather.temperature, wind: currentWeather.wind } : null;\n const components = game.time.components;\n let seed;\n if (options.randomize) {\n seed = null;\n } else {\n seed = dateSeed(components.year, components.month, components.dayOfMonth ?? 0);\n }\n const intradayEnabled = game.settings.get(MODULE.ID, SETTINGS.INTRADAY_WEATHER);\n let weather;\n if (intradayEnabled && !options.randomize) {\n const carryOverChance = game.settings.get(MODULE.ID, SETTINGS.INTRADAY_CARRY_OVER) ?? 50;\n const intradayResult = generateIntradayWeather({\n seasonClimate,\n zoneConfig,\n season,\n year: components.year,\n month: components.month,\n dayOfMonth: components.dayOfMonth ?? 0,\n customPresets,\n carryOverChance,\n currentWeatherId,\n inertia,\n previousWeather: prevWeather\n });\n const activePeriod = this.getCurrentPeriod();\n const activeResult = intradayResult.periods[activePeriod];\n weather = this.#buildWeatherFromResult(activeResult, season);\n weather.activePeriod = activePeriod;\n weather.periods = {};\n for (const [periodId, periodResult] of Object.entries(intradayResult.periods)) weather.periods[periodId] = this.#buildWeatherFromResult(periodResult, season);\n } else {\n const result = generateWeather({ seasonClimate, zoneConfig, season, seed, customPresets, currentWeatherId, inertia, previousWeather: prevWeather });\n weather = this.#buildWeatherFromResult(result, season);\n }\n await this.#saveWeather(weather, options.broadcast !== false, zoneId);\n ATLAS.log(3, `Weather generated for zone ${zoneId}: ${weather.preset}`);\n if (!options._fromPlan && game.settings.get(MODULE.ID, SETTINGS.GM_OVERRIDE_CLEARS_FORECAST)) {\n await this.#clearForecastPlan(zoneId);\n await this.#ensureForecastPlan();\n }\n return weather;\n }\n\n /**\n * Regenerate weather for all zones — clears forecast, generates new current weather, rebuilds plan.\n */\n static async regenerateAllWeather() {\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_SEED, Math.floor(Math.random() * 0x7fffffff));\n const zones = this.#getEffectiveZones();\n for (const zone of zones) await this.generateAndSetWeather({ zoneId: zone.id, broadcast: false });\n await this.#clearForecastPlan();\n await this.#ensureForecastPlan();\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { bulk: true });\n CalendariaSocket.emit('weatherChange', { weatherByZone: this.#currentWeatherByZone, bulk: true });\n ATLAS.log(3, `Regenerated weather for ${zones.length} zones`);\n }\n\n /**\n * Generate a weather forecast using the stored forecast plan.\n * @param {object} [options] - Forecast options\n * @param {number} [options.days] - Number of days\n * @param {string} [options.zoneId] - Zone ID override\n * @param {number} [options.accuracy] - Forecast accuracy 0-100 (default: from setting)\n * @returns {object[]} Forecast array\n */\n static getForecast(options = {}) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return [];\n const zoneId = 'zoneId' in options ? options.zoneId : (this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE);\n const maxDays = game.settings.get(MODULE.ID, SETTINGS.FORECAST_DAYS) ?? 7;\n const days = Math.min(options.days || maxDays, maxDays);\n const accuracy = options.accuracy ?? game.settings.get(MODULE.ID, SETTINGS.FORECAST_ACCURACY) ?? 70;\n const isGM = game.user.isGM;\n const customPresets = this.getCustomPresets();\n const fullPlan = game.settings.get(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN) || {};\n const plan = zoneId ? (fullPlan[zoneId] ?? {}) : {};\n const components = game.time.components;\n const getDaysInMonth = this.#makeDaysInMonth(calendar);\n const zoneConfig = this.getActiveZone(zoneId);\n const seasonResolver = this.#makeSeasonResolver(calendar);\n let year = components.year;\n let month = components.month;\n let dayOfMonth = components.dayOfMonth ?? 0;\n const result = [];\n for (let i = 0; i < days; i++) {\n const entry = plan[year]?.[month]?.[dayOfMonth];\n if (!entry) return this.#getForecastLegacy({ ...options, zoneId });\n const preset = {\n id: entry.id,\n label: entry.label,\n icon: entry.icon,\n color: entry.color,\n category: entry.category,\n description: entry.description ?? '',\n darknessPenalty: entry.darknessPenalty ?? 0\n };\n let forecastEntry = { year, month, dayOfMonth, preset, temperature: entry.temperature, wind: entry.wind, precipitation: entry.precipitation, isVaried: false };\n if (entry.periods && isGM) forecastEntry.periods = entry.periods;\n if (!isGM && accuracy < 100) {\n const seed = dateSeed(year, month, dayOfMonth);\n const seasonData = seasonResolver(year, month, dayOfMonth);\n const zoneOverride = seasonData?.name && zoneConfig?.seasonOverrides?.[seasonData.name];\n const { probabilities } = mergeClimateConfig(seasonData?.climate, zoneOverride, zoneConfig, seasonData?.name);\n const validPresetIds = Object.keys(probabilities ?? {});\n const varied = applyForecastVariance({ preset, temperature: entry.temperature }, i + 1, days, accuracy, seededRandom(seed + 1), customPresets, validPresetIds);\n forecastEntry = { year, month, dayOfMonth, ...varied, wind: entry.wind, precipitation: entry.precipitation };\n }\n result.push(forecastEntry);\n dayOfMonth++;\n const dim = getDaysInMonth(month, year);\n if (dayOfMonth >= dim) {\n dayOfMonth = 0;\n month++;\n if (month >= (getDaysInMonth._monthsPerYear ?? 12)) {\n month = 0;\n year++;\n }\n }\n }\n return result;\n }\n\n /**\n * Legacy on-demand forecast generation (fallback when plan is unavailable).\n * @param {object} [options] - Forecast options\n * @returns {object[]} Forecast array\n * @private\n */\n static #getForecastLegacy(options = {}) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return [];\n const zoneId = 'zoneId' in options ? options.zoneId : (this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE);\n const maxDays = game.settings.get(MODULE.ID, SETTINGS.FORECAST_DAYS) ?? 7;\n const days = Math.min(options.days || maxDays, maxDays);\n const components = game.time.components;\n const accuracy = options.accuracy ?? game.settings.get(MODULE.ID, SETTINGS.FORECAST_ACCURACY) ?? 70;\n const cacheKey = `${zoneId}|${components.year}|${components.month}|${components.dayOfMonth}|${days}|${accuracy}`;\n if (this.#legacyForecastCache?.key === cacheKey) return this.#legacyForecastCache.forecast;\n const zoneConfig = this.getActiveZone(zoneId);\n const customPresets = this.getCustomPresets();\n const currentWeatherId = this.#currentWeatherByZone[zoneId]?.id ?? null;\n const inertia = game.settings.get(MODULE.ID, SETTINGS.WEATHER_INERTIA) ?? 0.3;\n const currentWeather = this.#currentWeatherByZone[zoneId];\n const prevWeather = currentWeather ? { temperature: currentWeather.temperature, wind: currentWeather.wind } : null;\n const forecast = generateForecast({\n zoneConfig,\n startYear: components.year,\n startMonth: components.month,\n startDayOfMonth: components.dayOfMonth ?? 0,\n days,\n customPresets,\n currentWeatherId,\n inertia,\n accuracy,\n previousWeather: prevWeather,\n getSeasonForDate: this.#makeSeasonResolver(calendar),\n getDaysInMonth: this.#makeDaysInMonth(calendar)\n });\n this.#legacyForecastCache = { key: cacheKey, forecast };\n if (!game.user.isGM) return forecast.map(({ periods: _, ...rest }) => rest);\n return forecast;\n }\n\n /**\n * Handle scene activation — sync weather to the new scene's climate zone.\n * @param {object} scene - The activated scene document\n * @param {object} change - The change data\n * @private\n */\n static async #onSceneActivated(scene, change) {\n if (!change.active) return;\n if (!CalendariaSocket.isPrimaryGM()) return;\n const zone = this.getActiveZone(null, scene) ?? this.getActiveZone();\n const zoneId = zone?.id ?? this.DEFAULT_ZONE;\n const weather = this.#currentWeatherByZone[zoneId];\n if (weather) {\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { previous: null, current: weather, zoneId });\n return;\n }\n if (!game.settings.get(MODULE.ID, SETTINGS.AUTO_GENERATE_WEATHER)) return;\n await this.generateAndSetWeather({ zoneId });\n await this.#ensureForecastPlan();\n ATLAS.log(3, `Auto-generated weather for zone \"${zoneId}\" on scene activation`);\n }\n\n /**\n * Return a promise that resolves when the currently running day-change auto-gen work is complete. Resolves immediately if no work is in flight.\n * @returns {Promise}\n */\n static whenDayChangeSettled() {\n return this.#dayChangeWork ?? Promise.resolve();\n }\n\n /**\n * Handle day change for auto-generation.\n * @param {object} data - Hook data with previous/current components and calendar\n * @private\n */\n static async #onDayChange(data) {\n const work = this.#doDayChangeWork(data);\n this.#dayChangeWork = work;\n try {\n await work;\n } finally {\n if (this.#dayChangeWork === work) this.#dayChangeWork = null;\n }\n }\n\n /**\n * Core day-change work. Wrapped by `#onDayChange` so the in-flight promise is externally awaitable via `whenDayChangeSettled()`.\n * @param {object} data - Hook data with previous/current components and calendar\n * @private\n */\n static async #doDayChangeWork(data) {\n this.#legacyForecastCache = null;\n if (!CalendariaSocket.isPrimaryGM()) return;\n const calendar = CalendarManager.getActiveCalendar();\n const autoGenerate = game.settings.get(MODULE.ID, SETTINGS.AUTO_GENERATE_WEATHER) ?? false;\n const zones = this.#getEffectiveZones();\n const maxDays = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY_DAYS) ?? 365;\n const components = game.time.components;\n const year = components.year;\n const month = components.month;\n const dayOfMonth = components.dayOfMonth ?? 0;\n const history = maxDays > 0 ? game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY) || {} : null;\n const yearZero = calendar?.years?.yearZero ?? 0;\n if (history && data.previous) {\n const prevYear = data.previous.year - yearZero;\n const prevMonth = data.previous.month;\n const prevDay = data.previous.dayOfMonth ?? 0;\n for (const zone of zones) {\n const weather = this.#currentWeatherByZone[zone.id];\n if (weather) this.#addHistoryEntry(history, prevYear, prevMonth, prevDay, zone.id, weather);\n }\n }\n if (!autoGenerate) {\n if (history) {\n this.#pruneHistory(history, maxDays);\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, history);\n }\n return;\n }\n const gap = this.#calcDayGap(data, calendar);\n if (gap > 1) {\n await this.#backfillHistory(data, calendar, gap);\n return;\n }\n await this.#ensureForecastPlan();\n const seasonData = calendar.getCurrentSeason?.({ ...data.current, year: data.current.year - yearZero });\n const season = seasonData?.name ?? null;\n const customPresets = this.getCustomPresets();\n const intradayEnabled = game.settings.get(MODULE.ID, SETTINGS.INTRADAY_WEATHER);\n const activePeriod = intradayEnabled ? this.getCurrentPeriod() : null;\n const weatherUpdates = {};\n for (const zone of zones) {\n const planEntry = this.#getFromForecastPlan(data.current.year - yearZero, data.current.month, data.current.dayOfMonth ?? 0, zone.id);\n if (planEntry) {\n const activeEntry = intradayEnabled && planEntry.periods?.[activePeriod] ? planEntry.periods[activePeriod] : planEntry;\n weatherUpdates[zone.id] = {\n id: activeEntry.id,\n label: activeEntry.label,\n description: activeEntry.description ?? '',\n icon: activeEntry.icon,\n color: activeEntry.color,\n category: activeEntry.category,\n temperature: activeEntry.temperature,\n wind: activeEntry.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: activeEntry.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: activeEntry.darknessPenalty ?? 0,\n environmentBase: activeEntry.environmentBase ?? null,\n environmentDark: activeEntry.environmentDark ?? null,\n fxPreset: activeEntry.fxPreset ?? null,\n soundFx: activeEntry.soundFx ?? null,\n setAt: game.time.worldTime,\n setBy: game.user.id,\n generated: true,\n season\n };\n if (intradayEnabled && planEntry.periods) {\n weatherUpdates[zone.id].activePeriod = activePeriod;\n weatherUpdates[zone.id].periods = planEntry.periods;\n }\n } else {\n const curDay = data.current.dayOfMonth ?? 0;\n const histEntry = history?.[data.current.year]?.[data.current.month]?.[curDay]?.[zone.id];\n if (histEntry) {\n const preset = getPreset(histEntry.id, customPresets) ?? {};\n weatherUpdates[zone.id] = {\n id: histEntry.id,\n label: histEntry.label,\n description: preset.description ?? '',\n icon: histEntry.icon,\n color: histEntry.color,\n category: histEntry.category,\n temperature: histEntry.temperature,\n wind: histEntry.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: histEntry.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: this.#resolveDarknessPenalty(preset),\n environmentBase: this.#resolveEnvironmentBase(preset),\n environmentDark: this.#resolveEnvironmentDark(preset),\n environmentCycle: this.#resolveEnvironmentCycle(preset),\n fxPreset: this.#resolveFxPreset(preset),\n soundFx: this.#resolveSoundFx(preset),\n fxMacro: this.#resolveFxMacro(preset),\n setAt: game.time.worldTime,\n setBy: game.user.id,\n generated: true,\n season\n };\n } else {\n const zoneConfig = this.getActiveZone(zone.id);\n const seasonClimate = seasonData?.climate ?? null;\n const currentWeather = this.#currentWeatherByZone[zone.id];\n let inertia = game.settings.get(MODULE.ID, SETTINGS.WEATHER_INERTIA) ?? 0.3;\n if (currentWeather?.season && season !== currentWeather.season) inertia *= 0.5;\n const prevWeather = currentWeather ? { temperature: currentWeather.temperature, wind: currentWeather.wind } : null;\n if (intradayEnabled) {\n const carryOverChance = game.settings.get(MODULE.ID, SETTINGS.INTRADAY_CARRY_OVER) ?? 50;\n const intradayResult = generateIntradayWeather({\n seasonClimate,\n zoneConfig,\n season,\n year: data.current.year - yearZero,\n month: data.current.month,\n dayOfMonth: curDay,\n customPresets,\n carryOverChance,\n currentWeatherId: currentWeather?.id ?? null,\n inertia,\n previousWeather: prevWeather\n });\n const activeResult = intradayResult.periods[activePeriod];\n weatherUpdates[zone.id] = this.#buildWeatherFromResult(activeResult, season);\n weatherUpdates[zone.id].activePeriod = activePeriod;\n weatherUpdates[zone.id].periods = {};\n for (const [periodId, periodResult] of Object.entries(intradayResult.periods)) {\n weatherUpdates[zone.id].periods[periodId] = this.#buildWeatherFromResult(periodResult, season);\n }\n } else {\n const seed = dateSeed(data.current.year - yearZero, data.current.month, curDay);\n const result = generateWeather({ seasonClimate, zoneConfig, season, seed, customPresets, currentWeatherId: currentWeather?.id ?? null, inertia, previousWeather: prevWeather });\n weatherUpdates[zone.id] = this.#buildWeatherFromResult(result, season);\n }\n }\n }\n }\n for (const [zid, weather] of Object.entries(weatherUpdates)) this.#currentWeatherByZone[zid] = weather;\n if (history) {\n for (const [zid, weather] of Object.entries(weatherUpdates)) this.#addHistoryEntry(history, year, month, dayOfMonth, zid, weather);\n this.#pruneHistory(history, maxDays);\n }\n await Promise.all([game.settings.set(MODULE.ID, SETTINGS.CURRENT_WEATHER, this.#currentWeatherByZone), history ? game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, history) : null]);\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { bulk: true });\n CalendariaSocket.emit('weatherChange', { weatherByZone: this.#currentWeatherByZone, bulk: true });\n }\n\n /**\n * Calculate the number of days between previous and current date from hook data.\n * @param {object} data - Hook data with previous/current components\n * @param {object} calendar - Active calendar\n * @returns {number} Number of days jumped (1 = normal single day advance)\n * @private\n */\n static #calcDayGap(data, calendar) {\n if (!data?.previous || !data?.current || !calendar) return 1;\n const yearZero = calendar.years?.yearZero ?? 0;\n const prevTime = calendar.componentsToTime({ year: data.previous.year - yearZero, month: data.previous.month, dayOfMonth: data.previous.dayOfMonth ?? 0, hour: 0, minute: 0, second: 0 });\n const currTime = calendar.componentsToTime({ year: data.current.year - yearZero, month: data.current.month, dayOfMonth: data.current.dayOfMonth ?? 0, hour: 0, minute: 0, second: 0 });\n const secondsPerDay = (calendar.days?.hoursPerDay ?? 24) * (calendar.days?.minutesPerHour ?? 60) * (calendar.days?.secondsPerMinute ?? 60);\n return Math.max(1, Math.round((currTime - prevTime) / secondsPerDay));\n }\n\n /**\n * Backfill weather history for a multi-day jump.\n * @param {object} data - Hook data with current components\n * @param {object} calendar - Active calendar\n * @param {number} gap - Number of days jumped\n * @private\n */\n static async #backfillHistory(data, calendar, gap) {\n const maxDays = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY_DAYS) ?? 365;\n const zones = this.#getEffectiveZones();\n if (maxDays === 0) {\n for (const zone of zones) await this.generateAndSetWeather({ zoneId: zone.id, _fromPlan: true });\n return;\n }\n const daysToFill = Math.min(gap - 1, maxDays);\n const yearZero = calendar.years?.yearZero ?? 0;\n const currentYear = data.current.year;\n const currentMonth = data.current.month;\n const customPresets = this.getCustomPresets();\n const inertia = game.settings.get(MODULE.ID, SETTINGS.WEATHER_INERTIA) ?? 0.3;\n const secondsPerDay = (calendar.days?.hoursPerDay ?? 24) * (calendar.days?.minutesPerHour ?? 60) * (calendar.days?.secondsPerMinute ?? 60);\n const currentTime = calendar.componentsToTime({ year: currentYear - yearZero, month: currentMonth, dayOfMonth: data.current.dayOfMonth ?? 0, hour: 0, minute: 0, second: 0 });\n const startTime = currentTime - daysToFill * secondsPerDay;\n const startComponents = calendar.timeToComponents(startTime);\n const startYear = startComponents.year;\n const startMonth = startComponents.month;\n const startDayOfMonth = startComponents.dayOfMonth ?? 0;\n const fullPlan = game.settings.get(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN) || {};\n const history = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY) || {};\n const seasonData = calendar.getCurrentSeason?.(game.time.components);\n const season = seasonData?.name ?? null;\n for (const zone of zones) {\n const zoneConfig = zone;\n const zonePlan = fullPlan[zone.id] ?? {};\n const forecast = generateForecast({\n zoneConfig,\n startYear,\n startMonth,\n startDayOfMonth,\n days: daysToFill + 1,\n customPresets,\n currentWeatherId: null,\n inertia,\n getSeasonForDate: this.#makeSeasonResolver(calendar),\n getDaysInMonth: this.#makeDaysInMonth(calendar)\n });\n for (let i = 0; i < forecast.length - 1; i++) {\n const f = forecast[i];\n const planEntry = zonePlan[f.year]?.[f.month]?.[f.dayOfMonth];\n const entry = planEntry ?? {\n id: f.preset.id,\n label: _loc(f.preset.label),\n icon: f.preset.icon,\n color: f.preset.color,\n category: f.preset.category,\n temperature: f.temperature,\n wind: f.wind ?? null,\n precipitation: f.precipitation ?? null\n };\n history[f.year] ??= {};\n history[f.year][f.month] ??= {};\n history[f.year][f.month][f.dayOfMonth] ??= {};\n history[f.year][f.month][f.dayOfMonth][zone.id] = {\n id: entry.id,\n label: planEntry ? entry.label : _loc(f.preset.label),\n icon: entry.icon,\n color: entry.color,\n category: entry.category,\n temperature: entry.temperature,\n wind: entry.wind ?? null,\n precipitation: entry.precipitation ?? null,\n generated: true,\n zoneId: zone.id\n };\n }\n const todayF = forecast[forecast.length - 1];\n const todayPlan = zonePlan[todayF.year]?.[todayF.month]?.[todayF.dayOfMonth];\n if (todayPlan) {\n this.#currentWeatherByZone[zone.id] = {\n id: todayPlan.id,\n label: todayPlan.label,\n description: todayPlan.description ?? '',\n icon: todayPlan.icon,\n color: todayPlan.color,\n category: todayPlan.category,\n temperature: todayPlan.temperature,\n wind: todayPlan.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: todayPlan.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: todayPlan.darknessPenalty ?? 0,\n environmentBase: todayPlan.environmentBase ?? null,\n environmentDark: todayPlan.environmentDark ?? null,\n fxPreset: todayPlan.fxPreset ?? null,\n soundFx: todayPlan.soundFx ?? null,\n setAt: game.time.worldTime,\n setBy: game.user.id,\n generated: true,\n season\n };\n } else {\n this.#currentWeatherByZone[zone.id] = {\n id: todayF.preset.id,\n label: todayF.preset.label,\n description: todayF.preset.description,\n icon: todayF.preset.icon,\n color: todayF.preset.color,\n category: todayF.preset.category,\n temperature: todayF.temperature,\n wind: todayF.wind ?? { speed: 0, direction: null, forced: false },\n precipitation: todayF.precipitation ?? { type: null, intensity: 0 },\n darknessPenalty: this.#resolveDarknessPenalty(todayF.preset),\n environmentBase: this.#resolveEnvironmentBase(todayF.preset),\n environmentDark: this.#resolveEnvironmentDark(todayF.preset),\n environmentCycle: this.#resolveEnvironmentCycle(todayF.preset),\n fxPreset: this.#resolveFxPreset(todayF.preset),\n soundFx: this.#resolveSoundFx(todayF.preset),\n fxMacro: this.#resolveFxMacro(todayF.preset),\n setAt: game.time.worldTime,\n setBy: game.user.id,\n generated: true,\n season\n };\n }\n }\n this.#pruneHistory(history, maxDays);\n await Promise.all([game.settings.set(MODULE.ID, SETTINGS.CURRENT_WEATHER, this.#currentWeatherByZone), game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, history)]);\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { bulk: true });\n CalendariaSocket.emit('weatherChange', { weatherByZone: this.#currentWeatherByZone, bulk: true });\n await this.#clearForecastPlan();\n await this.#ensureForecastPlan();\n ATLAS.log(3, `Backfilled ${daysToFill} days of weather history for ${zones.length} zones`);\n }\n\n /**\n * Add a weather entry to a history object in memory (no DB write).\n * @param {object} history - Nested history object (mutated)\n * @param {number} year - Year key\n * @param {number} month - Month key\n * @param {number} dayOfMonth - Day key\n * @param {string} zoneId - Zone ID\n * @param {object} weather - Weather state to record\n * @private\n */\n static #addHistoryEntry(history, year, month, dayOfMonth, zoneId, weather) {\n history[year] ??= {};\n history[year][month] ??= {};\n history[year][month][dayOfMonth] ??= {};\n history[year][month][dayOfMonth][zoneId] = {\n id: weather.id,\n label: _loc(weather.label),\n icon: weather.icon,\n color: weather.color,\n category: weather.category,\n temperature: weather.temperature,\n wind: weather.wind ?? null,\n precipitation: weather.precipitation ?? null,\n generated: weather.generated ?? false,\n zoneId\n };\n }\n\n /**\n * Record weather for today into history storage.\n * @param {object} weather - Weather state to record\n * @param {string} [zoneId] - Zone ID (resolves from active zone if omitted)\n * @private\n */\n static async #recordWeatherHistory(weather, zoneId) {\n const maxDays = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY_DAYS) ?? 365;\n if (maxDays === 0) return;\n const resolvedZoneId = zoneId ?? this.getActiveZone()?.id ?? this.DEFAULT_ZONE;\n const components = game.time.components;\n const year = components.year;\n const month = components.month;\n const dayOfMonth = components.dayOfMonth ?? 0;\n const history = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY) || {};\n history[year] ??= {};\n history[year][month] ??= {};\n history[year][month][dayOfMonth] ??= {};\n history[year][month][dayOfMonth][resolvedZoneId] = {\n id: weather.id,\n label: _loc(weather.label),\n icon: weather.icon,\n color: weather.color,\n category: weather.category,\n temperature: weather.temperature,\n wind: weather.wind ?? null,\n precipitation: weather.precipitation ?? null,\n generated: weather.generated ?? false,\n zoneId: resolvedZoneId\n };\n this.#pruneHistory(history, maxDays);\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, history);\n }\n\n /**\n * Prune history to stay within max day count, removing oldest day entries first.\n * @param {object} history - Nested history object (mutated)\n * @param {number} maxDays - Maximum day entries to retain\n * @private\n */\n static #pruneHistory(history, maxDays) {\n const entries = [];\n for (const [y, months] of Object.entries(history)) for (const [m, days] of Object.entries(months)) for (const d of Object.keys(days)) entries.push([Number(y), Number(m), Number(d)]);\n const unique = [...new Set(entries.map((e) => `${e[0]}-${e[1]}-${e[2]}`))].map((k) => k.split('-').map(Number));\n if (unique.length <= maxDays) return;\n unique.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);\n const toRemove = unique.length - maxDays;\n for (let i = 0; i < toRemove; i++) {\n const [y, m, d] = unique[i];\n if (!history[y]?.[m]) continue;\n delete history[y][m][d];\n if (!Object.keys(history[y][m]).length) delete history[y][m];\n if (history[y] && !Object.keys(history[y]).length) delete history[y];\n }\n }\n\n /**\n * Migrate weather history and forecast plan day keys from 1-indexed to 0-indexed.\n * @since 0.11.0\n * @deprecated Remove in 1.1.0\n * @private\n */\n static async #migrateWeatherDayIndex() {\n let changed = false;\n const history = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY) || {};\n if (Object.keys(history).length) {\n const migrated = this.#shiftDayKeys(history);\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, migrated);\n changed = true;\n ATLAS.log(3, 'Migrated weather history day keys from 1-indexed to 0-indexed');\n }\n const plan = game.settings.get(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN) || {};\n if (Object.keys(plan).length) {\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN, {});\n changed = true;\n ATLAS.log(3, 'Cleared forecast plan for 0-indexed day migration');\n }\n if (changed) ATLAS.log(3, 'Weather day index migration complete');\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_DAY_INDEX_MIGRATED, true);\n }\n\n /**\n * Migrate legacy bare-key soundFx values to full paths in custom presets and current weather.\n * @since 1.0.0\n * @deprecated Remove in 1.2.0\n * @private\n */\n static async #migrateLegacySoundKeys() {\n let changed = false;\n const customPresets = this.getCustomPresets();\n for (const preset of customPresets) {\n if (preset.soundFx && !preset.soundFx.includes('/')) {\n preset.soundFx = resolveWeatherSoundPath(preset.soundFx);\n changed = true;\n }\n }\n if (changed) {\n await game.settings.set(MODULE.ID, SETTINGS.CUSTOM_WEATHER_PRESETS, customPresets);\n ATLAS.log(3, 'Migrated custom preset soundFx keys to full paths');\n }\n let weatherChanged = false;\n for (const weather of Object.values(this.#currentWeatherByZone)) {\n if (weather?.soundFx && !weather.soundFx.includes('/')) {\n weather.soundFx = resolveWeatherSoundPath(weather.soundFx);\n weatherChanged = true;\n }\n }\n if (weatherChanged) {\n await game.settings.set(MODULE.ID, SETTINGS.CURRENT_WEATHER, this.#currentWeatherByZone);\n ATLAS.log(3, 'Migrated current weather soundFx keys to full paths');\n }\n }\n\n /**\n * Shift all day-level keys in a nested year→month→day object by -1.\n * @param {object} data - Nested object (year → month → day → value)\n * @returns {object} New object with shifted day keys\n * @private\n */\n static #shiftDayKeys(data) {\n const result = {};\n for (const [year, months] of Object.entries(data)) {\n result[year] = {};\n for (const [month, days] of Object.entries(months)) {\n result[year][month] = {};\n for (const [day, value] of Object.entries(days)) {\n const newDay = Math.max(0, Number(day) - 1);\n result[year][month][newDay] = value;\n }\n }\n }\n return result;\n }\n\n /**\n * Ensure the forecast plan has enough future entries.\n * @private\n */\n static async #ensureForecastPlan() {\n if (!CalendariaSocket.isPrimaryGM()) return;\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return;\n const zones = this.#getEffectiveZones();\n const forecastDays = game.settings.get(MODULE.ID, SETTINGS.FORECAST_DAYS) ?? 7;\n const components = game.time.components;\n const todayYear = components.year;\n const todayMonth = components.month;\n const todayDayOfMonth = components.dayOfMonth ?? 0;\n const todayKey = todayYear * 10000 + todayMonth * 100 + todayDayOfMonth;\n const plan = game.settings.get(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN) || {};\n const getDaysInMonth = this.#makeDaysInMonth(calendar);\n const customPresets = this.getCustomPresets();\n const inertia = game.settings.get(MODULE.ID, SETTINGS.WEATHER_INERTIA) ?? 0.3;\n const intradayEnabled = game.settings.get(MODULE.ID, SETTINGS.INTRADAY_WEATHER);\n const carryOverChance = intradayEnabled ? (game.settings.get(MODULE.ID, SETTINGS.INTRADAY_CARRY_OVER) ?? 50) : undefined;\n const needed = forecastDays + 1;\n let anyChanged = false;\n for (const zone of zones) {\n const zonePlan = plan[zone.id] ?? {};\n const entries = [];\n for (const [y, months] of Object.entries(zonePlan)) {\n for (const [m, days] of Object.entries(months)) {\n for (const [d, entry] of Object.entries(days)) {\n const key = Number(y) * 10000 + Number(m) * 100 + Number(d);\n entries.push({ key, year: Number(y), month: Number(m), dayOfMonth: Number(d), entry });\n }\n }\n }\n entries.sort((a, b) => a.key - b.key);\n const futureEntries = entries.filter((e) => e.key >= todayKey);\n if (futureEntries.length >= needed) {\n const pastCount = entries.length - futureEntries.length;\n if (pastCount > 0) {\n this.#prunePlanEntries(zonePlan, todayKey);\n plan[zone.id] = zonePlan;\n anyChanged = true;\n }\n continue;\n }\n let startYear, startMonth, startDayOfMonth, chainWeatherId;\n const lastFuture = futureEntries[futureEntries.length - 1];\n if (lastFuture) {\n startYear = lastFuture.year;\n startMonth = lastFuture.month;\n startDayOfMonth = lastFuture.dayOfMonth + 1;\n const lastEntry = lastFuture.entry;\n chainWeatherId = intradayEnabled && lastEntry.periods?.evening ? lastEntry.periods.evening.id : lastEntry.id;\n const dim = getDaysInMonth(startMonth, startYear);\n if (startDayOfMonth >= dim) {\n startDayOfMonth = 0;\n startMonth++;\n if (startMonth >= (getDaysInMonth._monthsPerYear ?? 12)) {\n startMonth = 0;\n startYear++;\n }\n }\n } else {\n startYear = todayYear;\n startMonth = todayMonth;\n startDayOfMonth = todayDayOfMonth;\n const currentWeather = this.#currentWeatherByZone[zone.id];\n chainWeatherId = intradayEnabled && currentWeather?.periods?.evening ? currentWeather.periods.evening.id : (currentWeather?.id ?? null);\n }\n const toGenerate = needed - futureEntries.length;\n const chainWeather = lastFuture?.entry ?? this.#currentWeatherByZone[zone.id];\n let chainPrevWeather;\n if (intradayEnabled && chainWeather?.periods?.evening) {\n const ev = chainWeather.periods.evening;\n chainPrevWeather = { temperature: ev.temperature, wind: ev.wind };\n } else {\n chainPrevWeather = chainWeather ? { temperature: chainWeather.temperature, wind: chainWeather.wind } : null;\n }\n const forecast = generateForecast({\n zoneConfig: zone,\n startYear,\n startMonth,\n startDayOfMonth,\n days: toGenerate,\n customPresets,\n currentWeatherId: chainWeatherId,\n inertia,\n accuracy: 100,\n previousWeather: chainPrevWeather,\n getSeasonForDate: this.#makeSeasonResolver(calendar),\n getDaysInMonth,\n intraday: intradayEnabled,\n carryOverChance\n });\n for (const f of forecast) {\n zonePlan[f.year] ??= {};\n zonePlan[f.year][f.month] ??= {};\n const planEntry = {\n id: f.preset.id,\n label: f.preset.label,\n icon: f.preset.icon,\n color: f.preset.color,\n category: f.preset.category,\n description: f.preset.description ?? '',\n temperature: f.temperature,\n wind: f.wind ?? null,\n precipitation: f.precipitation ?? null,\n darknessPenalty: this.#resolveDarknessPenalty(f.preset),\n environmentBase: this.#resolveEnvironmentBase(f.preset),\n environmentDark: this.#resolveEnvironmentDark(f.preset),\n environmentCycle: this.#resolveEnvironmentCycle(f.preset),\n fxPreset: this.#resolveFxPreset(f.preset),\n soundFx: this.#resolveSoundFx(f.preset),\n fxMacro: this.#resolveFxMacro(f.preset)\n };\n if (intradayEnabled && f.periods) {\n planEntry.periods = {};\n for (const [periodId, periodResult] of Object.entries(f.periods)) {\n const pPreset = periodResult.preset ?? getPreset(periodResult.id, customPresets);\n planEntry.periods[periodId] = {\n id: periodResult.preset?.id ?? periodResult.id,\n label: periodResult.preset?.label ?? periodResult.label,\n icon: periodResult.preset?.icon ?? periodResult.icon,\n color: periodResult.preset?.color ?? periodResult.color,\n category: periodResult.preset?.category ?? periodResult.category,\n description: periodResult.preset?.description ?? '',\n temperature: periodResult.temperature,\n wind: periodResult.wind ?? null,\n precipitation: periodResult.precipitation ?? null,\n darknessPenalty: pPreset ? this.#resolveDarknessPenalty(pPreset) : (periodResult.darknessPenalty ?? 0),\n environmentBase: pPreset ? this.#resolveEnvironmentBase(pPreset) : null,\n environmentDark: pPreset ? this.#resolveEnvironmentDark(pPreset) : null,\n fxPreset: pPreset ? this.#resolveFxPreset(pPreset) : null,\n soundFx: pPreset ? this.#resolveSoundFx(pPreset) : null\n };\n }\n }\n zonePlan[f.year][f.month][f.dayOfMonth] = planEntry;\n }\n this.#prunePlanEntries(zonePlan, todayKey);\n plan[zone.id] = zonePlan;\n anyChanged = true;\n ATLAS.log(3, `Forecast plan updated for zone ${zone.id}: ${toGenerate} entries generated`);\n }\n const activeZoneIds = new Set(zones.map((z) => z.id));\n for (const planZoneId of Object.keys(plan)) {\n if (!activeZoneIds.has(planZoneId)) {\n delete plan[planZoneId];\n anyChanged = true;\n ATLAS.log(3, `Removed stale forecast plan for defunct zone ${planZoneId}`);\n }\n }\n if (anyChanged) await game.settings.set(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN, plan);\n }\n\n /**\n * Look up a forecast plan entry for a specific date and zone.\n * @param {number} year - Display year\n * @param {number} month - Month (0-indexed)\n * @param {number} dayOfMonth - Day of month (0-indexed)\n * @param {string} [zoneId] - Zone ID\n * @returns {object|null} Plan entry or null\n * @private\n */\n static #getFromForecastPlan(year, month, dayOfMonth, zoneId) {\n const plan = game.settings.get(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN) || {};\n const resolvedZoneId = zoneId ?? this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE;\n return plan[resolvedZoneId]?.[year]?.[month]?.[dayOfMonth] ?? null;\n }\n\n /**\n * Clear the stored forecast plan, optionally scoped to a single zone.\n * @param {string} [zoneId] - Zone ID to clear; clears all zones if omitted\n * @private\n */\n static async #clearForecastPlan(zoneId) {\n if (zoneId) {\n const plan = game.settings.get(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN) || {};\n delete plan[zoneId];\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN, plan);\n ATLAS.log(3, `Forecast plan cleared for zone ${zoneId}`);\n } else {\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_FORECAST_PLAN, {});\n ATLAS.log(3, 'Forecast plan cleared for all zones');\n }\n }\n\n /**\n * Remove plan entries before today.\n * @param {object} plan - Nested year→month→day plan object (mutated)\n * @param {number} todayKey - Comparable key for today (year*10000 + month*100 + day)\n * @private\n */\n static #prunePlanEntries(plan, todayKey) {\n for (const [year, months] of Object.entries(plan)) {\n for (const [month, days] of Object.entries(months)) {\n for (const day of Object.keys(days)) {\n const key = Number(year) * 10000 + Number(month) * 100 + Number(day);\n if (key < todayKey) delete days[day];\n }\n if (!Object.keys(days).length) delete months[month];\n }\n if (!Object.keys(months).length) delete plan[year];\n }\n }\n\n /**\n * Build a getDaysInMonth callback for generateForecast.\n * @param {object} calendar - Active calendar\n * @returns {Function} getDaysInMonth(month, internalYear)\n * @private\n */\n static #makeDaysInMonth(calendar) {\n const fn = (month, year) => calendar.getDaysInMonth?.(month, year) ?? 30;\n fn._monthsPerYear = calendar.monthsArray?.length ?? 12;\n return fn;\n }\n\n /**\n * Build a getSeasonForDate callback for generateForecast.\n * @param {object} calendar - Active calendar\n * @returns {Function} getSeasonForDate(internalYear, month, day)\n * @private\n */\n static #makeSeasonResolver(calendar) {\n return (year, month, dayOfMonth) => {\n const season = calendar.getCurrentSeason?.({ year, month, dayOfMonth });\n if (!season) return null;\n return { name: season.name, climate: season.climate };\n };\n }\n\n /**\n * Get historical weather for a specific date.\n * @param {number} year - Display year\n * @param {number} month - Month (0-indexed)\n * @param {number} dayOfMonth - Day of month (0-indexed)\n * @param {string} [zoneId] - Zone ID filter (resolves from active scene if omitted)\n * @returns {object|null} Historical weather entry or null\n */\n static getWeatherForDate(year, month, dayOfMonth, zoneId) {\n const history = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY) || {};\n const dayData = history[year]?.[month]?.[dayOfMonth];\n if (!dayData) return null;\n if (dayData.id !== undefined) return !zoneId || dayData.zoneId === zoneId ? dayData : null;\n const resolvedZoneId = zoneId ?? this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE;\n if (dayData[resolvedZoneId]) return dayData[resolvedZoneId];\n const firstKey = Object.keys(dayData)[0];\n return firstKey ? dayData[firstKey] : null;\n }\n\n /**\n * Get weather history as the raw nested object, or a flat array for a specific year/month.\n * @param {object} [options] - Filter options\n * @param {number} [options.year] - Filter to a specific year\n * @param {number} [options.month] - Filter to a specific month (requires year)\n * @param {string} [options.zoneId] - Zone ID filter (resolves from active scene if omitted)\n * @returns {object|object[]} Nested history object, or array of { year, month, dayOfMonth, ...entry }\n */\n static getWeatherHistory(options = {}) {\n const history = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY) || {};\n if (options.year == null) return history;\n const yearData = history[options.year];\n if (!yearData) return [];\n const resolvedZoneId = 'zoneId' in options ? options.zoneId : (this.getActiveZone(null, game.scenes?.active)?.id ?? this.DEFAULT_ZONE);\n const results = [];\n const months = options.month != null ? { [options.month]: yearData[options.month] } : yearData;\n for (const [m, days] of Object.entries(months)) {\n if (!days) continue;\n for (const [d, dayData] of Object.entries(days)) {\n if (dayData.id !== undefined) {\n if (!resolvedZoneId || dayData.zoneId === resolvedZoneId) results.push({ year: options.year, month: Number(m), dayOfMonth: Number(d), ...dayData });\n } else {\n const entry = dayData[resolvedZoneId] ?? dayData[Object.keys(dayData)[0]];\n if (entry) results.push({ year: options.year, month: Number(m), dayOfMonth: Number(d), ...entry });\n }\n }\n }\n return results.sort((a, b) => a.month - b.month || a.dayOfMonth - b.dayOfMonth);\n }\n\n /**\n * Clear weather history entries and optionally the forecast plan.\n * @param {object} [options] - Clear options\n * @param {boolean} [options.future] - Only clear entries after the current date\n * @param {boolean} [options.all] - Clear all history (ignores year/month filters)\n * @param {number} [options.year] - Clear entries for a specific year (0-indexed internal year)\n * @param {number} [options.month] - Clear entries for a specific month within the year (0-indexed)\n * @param {boolean} [options.clearForecast] - Also clear the forecast plan (default: true)\n * @returns {Promise} Number of entries removed\n */\n static async clearWeatherHistory(options = {}) {\n if (!game.user?.isGM) return 0;\n const history = game.settings.get(MODULE.ID, SETTINGS.WEATHER_HISTORY) || {};\n let removed = 0;\n if (options.all) {\n for (const y of Object.keys(history)) for (const m of Object.keys(history[y] ?? {})) removed += Object.keys(history[y][m] ?? {}).length;\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, {});\n } else if (options.future) {\n const components = game.time.components;\n const todayYear = components.year;\n const todayMonth = components.month;\n const todayDay = components.dayOfMonth ?? 0;\n const todayKey = todayYear * 10000 + todayMonth * 100 + todayDay;\n for (const [y, months] of Object.entries(history)) {\n for (const [m, days] of Object.entries(months ?? {})) {\n for (const d of Object.keys(days ?? {})) {\n const key = Number(y) * 10000 + Number(m) * 100 + Number(d);\n if (key > todayKey) {\n delete days[d];\n removed++;\n }\n }\n if (Object.keys(days).length === 0) delete months[m];\n }\n if (Object.keys(months ?? {}).length === 0) delete history[y];\n }\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, history);\n } else if (options.year != null) {\n if (options.month != null) {\n removed = Object.keys(history[options.year]?.[options.month] ?? {}).length;\n if (history[options.year]) delete history[options.year][options.month];\n } else {\n for (const m of Object.keys(history[options.year] ?? {})) removed += Object.keys(history[options.year][m] ?? {}).length;\n delete history[options.year];\n }\n await game.settings.set(MODULE.ID, SETTINGS.WEATHER_HISTORY, history);\n }\n if (options.clearForecast !== false) await this.#clearForecastPlan();\n ATLAS.log(3, `Cleared ${removed} weather history entries`);\n return removed;\n }\n\n /**\n * Get current season object.\n * @returns {object|null} Season object with name, climate, etc.\n * @private\n */\n static #getCurrentSeason() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar?.getCurrentSeason) return null;\n return calendar.getCurrentSeason(game.time.components);\n }\n\n /**\n * Generate temperature for a preset based on active zone and season.\n * @param {string} presetId - Weather preset ID\n * @returns {number|null} Generated temperature or null if no config\n * @private\n */\n static #generateTemperatureForPreset(presetId) {\n const zoneConfig = this.getActiveZone();\n const seasonData = this.#getCurrentSeason();\n const season = seasonData?.name ?? null;\n const seasonClimate = seasonData?.climate;\n if (!zoneConfig && !seasonClimate) {\n const customPresets = this.getCustomPresets();\n const preset = getPreset(presetId, customPresets);\n const min = preset?.tempMin ?? 10;\n const max = preset?.tempMax ?? 25;\n return Math.round(min + Math.random() * (max - min));\n }\n const zoneOverride = season && zoneConfig?.seasonOverrides?.[season];\n const { tempRange } = mergeClimateConfig(seasonClimate, zoneOverride, zoneConfig, season);\n let finalRange = { ...tempRange };\n const customPresets = this.getCustomPresets();\n const preset = getPreset(presetId, customPresets);\n const seasonPresetConfig = zoneOverride?.presets?.[presetId];\n const presetConfig = Object.values(zoneConfig?.presets ?? {}).find((p) => p.id === presetId && p.enabled !== false);\n const effectiveMin = seasonPresetConfig?.tempMin ?? presetConfig?.tempMin ?? preset?.tempMin;\n const effectiveMax = seasonPresetConfig?.tempMax ?? presetConfig?.tempMax ?? preset?.tempMax;\n if (effectiveMin != null) finalRange.min = Math.max(finalRange.min, applyTempModifier(effectiveMin, tempRange.min));\n if (effectiveMax != null) finalRange.max = Math.min(finalRange.max, applyTempModifier(effectiveMax, tempRange.max));\n if (finalRange.min > finalRange.max) finalRange = { ...tempRange };\n return Math.round(finalRange.min + Math.random() * (finalRange.max - finalRange.min));\n }\n\n /**\n * Warn once per unmatched zone id, since zone lookups run on every world tick.\n * @param {string|null} zoneId - The zone ID that did not match\n * @param {string} [fallbackId] - The zone ID being used instead\n * @returns {null} Always null, so callers can use it inline\n * @private\n */\n static #warnStaleZone(zoneId, fallbackId) {\n if (!zoneId || this.#warnedZoneIds.has(zoneId)) return null;\n this.#warnedZoneIds.add(zoneId);\n const suffix = fallbackId ? `, falling back to '${fallbackId}'` : ', ignoring it';\n ATLAS.log(2, `Climate zone '${zoneId}' not found in the active calendar${suffix}`);\n return null;\n }\n\n /**\n * Get the active climate zone config from the calendar.\n * @param {string} [zoneId] - Optional zone ID override\n * @param {object} [scene] - Optional scene to check for scene-level override\n * @returns {object|null} Zone config object\n */\n static getActiveZone(zoneId, scene) {\n const calendar = CalendarManager.getActiveCalendar();\n const zones = calendar?.weatherZonesArray;\n if (!zones?.length) return null;\n const rawOverride = scene?.getFlag?.(MODULE.ID, SCENE_FLAGS.CLIMATE_ZONE_OVERRIDE) || null;\n if (rawOverride === 'none' && zoneId == null) return null;\n // A scene override left behind by a different calendar must not outrank the active calendar's own zone.\n const sceneOverride = rawOverride && zones.some((z) => z.id === rawOverride) ? rawOverride : this.#warnStaleZone(rawOverride);\n const targetId = zoneId ?? sceneOverride ?? calendar.weather.activeZone;\n if (!targetId) return null;\n const match = zones.find((z) => z.id === targetId);\n if (!match) this.#warnStaleZone(targetId, zones[0]?.id);\n return match ?? zones[0] ?? null;\n }\n\n /**\n * Apply a zone's season-name alias to a resolved season object.\n * @param {object|null} season - Season object from `getCurrentSeason`\n * @param {object|null} zone - Zone config (with `seasonAliases`)\n * @returns {object|null} Season with alias applied, or the original season when no alias is set\n */\n static applySeasonAlias(season, zone) {\n if (!season || !zone?.seasonAliases) return season;\n const alias = zone.seasonAliases[season.name];\n if (!alias) return season;\n const aliasedName = alias.name?.trim() || season.name;\n const aliasedAbbr = alias.abbreviation?.trim() || season.abbreviation;\n const aliasedIcon = alias.icon?.trim() || season.icon;\n const aliasedColor = alias.color?.trim() || season.color;\n if (aliasedName === season.name && aliasedAbbr === season.abbreviation && aliasedIcon === season.icon && aliasedColor === season.color) return season;\n return { ...season, name: aliasedName, abbreviation: aliasedAbbr, icon: aliasedIcon, color: aliasedColor };\n }\n\n /**\n * Resolve the season for the current time and apply the active zone's alias if present.\n * @param {object} [options] - Resolution options\n * @param {object} [options.scene] - Scene to resolve zone against (defaults to `game.scenes?.active`)\n * @param {string} [options.zoneId] - Explicit zone ID override\n * @param {number|object} [options.time] - Time to resolve season for (defaults to current world time)\n * @returns {object|null} Aliased season object or null\n */\n static getAliasedSeason({ scene, zoneId, time } = {}) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar?.getCurrentSeason) return null;\n const season = calendar.getCurrentSeason(time ?? game.time.components);\n const zone = this.getActiveZone(zoneId, scene ?? game.scenes?.active);\n return this.applySeasonAlias(season, zone);\n }\n\n /**\n * Check if a scene has explicitly opted out of climate zones.\n * @param {object} [scene] - Scene to check (defaults to active scene)\n * @returns {boolean} True if the scene has \"No Zone\" set\n */\n static isZoneDisabled(scene) {\n return (scene ?? game.scenes?.active)?.getFlag?.(MODULE.ID, SCENE_FLAGS.CLIMATE_ZONE_OVERRIDE) === 'none';\n }\n\n /**\n * Set a scene-level climate zone override via scene flag.\n * @param {object} scene - Scene document to set the flag on\n * @param {string|null} zoneId - Zone ID to set, or null to clear the override\n * @returns {Promise}\n */\n static async setSceneZoneOverride(scene, zoneId) {\n if (!scene) return;\n if (zoneId === null) await scene.setFlag(MODULE.ID, SCENE_FLAGS.CLIMATE_ZONE_OVERRIDE, 'none');\n else if (zoneId) await scene.setFlag(MODULE.ID, SCENE_FLAGS.CLIMATE_ZONE_OVERRIDE, zoneId);\n else await scene.unsetFlag(MODULE.ID, SCENE_FLAGS.CLIMATE_ZONE_OVERRIDE);\n ATLAS.log(3, `Scene zone override ${zoneId !== null ? `set to ${zoneId || 'default'}` : 'set to none'} for scene ${scene.name}`);\n }\n\n /**\n * Set the active climate zone on the calendar.\n * @param {string} zoneId - Zone ID to set as active\n * @returns {Promise}\n */\n static async setActiveZone(zoneId) {\n if (!canChangeWeather()) return;\n const calendar = CalendarManager.getActiveCalendar();\n const calendarId = calendar?.metadata?.id;\n if (!calendarId) return;\n const calendarData = CalendarManager.getCalendar(calendarId)?.toObject();\n if (!calendarData?.weather) return;\n if (zoneId) {\n const zones = calendarData.weather.zones ? Object.values(calendarData.weather.zones) : [];\n const zone = zones.find((z) => z.id === zoneId);\n if (!zone) return;\n }\n calendarData.weather.activeZone = zoneId ?? null;\n if (isBundledCalendar(calendarId)) await CalendarManager.saveDefaultOverride(calendarId, calendarData);\n else await CalendarManager.updateCustomCalendar(calendarId, calendarData);\n Hooks.callAll(HOOKS.WEATHER_CHANGE, { bulk: true });\n CalendariaSocket.emit('weatherChange', { weatherByZone: this.#currentWeatherByZone, bulk: true });\n ATLAS.log(3, `Active climate zone set to: ${zoneId}`);\n }\n\n /**\n * Get all climate zones for the active calendar.\n * @returns {object[]} Array of zone config objects\n */\n static getCalendarZones() {\n const calendar = CalendarManager.getActiveCalendar();\n return calendar?.weatherZonesArray ?? [];\n }\n\n /**\n * Get effective zones for weather operations, always returning at least a default entry.\n * @returns {object[]} Array of zone-like objects with at least an `id` property\n * @private\n */\n static #getEffectiveZones() {\n const zones = this.getCalendarZones();\n if (zones.length) return zones;\n return [{ id: this.DEFAULT_ZONE }];\n }\n\n /**\n * Get all available climate zone templates.\n * @returns {object[]} Climate zone template objects\n */\n static getClimateZoneTemplates() {\n return Object.values(CLIMATE_ZONE_TEMPLATES);\n }\n\n /**\n * Create a new climate zone on the active calendar.\n * @param {object} zoneConfig - Zone configuration\n * @param {string} zoneConfig.name - Zone name (required)\n * @param {string} [zoneConfig.id] - Zone ID (auto-generated from name if omitted)\n * @returns {Promise} Created zone or null on failure\n */\n static async createClimateZone(zoneConfig) {\n if (!game.user.isGM) {\n ATLAS.log(1, 'createClimateZone: GM only');\n return null;\n }\n if (!zoneConfig?.name) {\n ATLAS.log(1, 'createClimateZone: name is required');\n return null;\n }\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) {\n ATLAS.log(1, 'createClimateZone: no active calendar');\n return null;\n }\n const calendarData = calendar.toObject();\n if (!calendarData.weather) calendarData.weather = { activeZone: null, zones: {} };\n if (!calendarData.weather.zones) calendarData.weather.zones = {};\n const id =\n zoneConfig.id ||\n zoneConfig.name\n .toLowerCase()\n .replace(/\\s+/g, '-')\n .replace(/[^\\da-z-]/g, '');\n const existingIds = Object.values(calendarData.weather.zones).map((z) => z.id);\n if (existingIds.includes(id)) {\n ATLAS.log(1, `createClimateZone: zone ID \"${id}\" already exists`);\n return null;\n }\n const zone = {\n id,\n name: zoneConfig.name,\n description: zoneConfig.description ?? '',\n brightnessMultiplier: zoneConfig.brightnessMultiplier ?? 1.0,\n environmentBase: zoneConfig.environmentBase ?? null,\n environmentDark: zoneConfig.environmentDark ?? null,\n windSpeedRange: zoneConfig.windSpeedRange ?? null,\n windDirections: zoneConfig.windDirections ?? {},\n shortestDay: zoneConfig.shortestDay ?? null,\n longestDay: zoneConfig.longestDay ?? null,\n colorShift: zoneConfig.colorShift ?? null,\n temperatures: zoneConfig.temperatures ?? { _default: { min: 10, max: 22 } },\n presets: zoneConfig.presets ?? [],\n seasonOverrides: zoneConfig.seasonOverrides ?? {}\n };\n const zoneKey = foundry.utils.randomID();\n calendarData.weather.zones[zoneKey] = zone;\n if (!calendarData.weather.activeZone) calendarData.weather.activeZone = id;\n const calendarId = calendar.metadata.id;\n if (isBundledCalendar(calendarId)) await CalendarManager.saveDefaultOverride(calendarId, calendarData);\n else await CalendarManager.updateCustomCalendar(calendarId, calendarData);\n return zone;\n }\n\n /**\n * Get custom weather presets.\n * @returns {object[]} Custom presets\n */\n static getCustomPresets() {\n return game.settings.get(MODULE.ID, SETTINGS.CUSTOM_WEATHER_PRESETS) || [];\n }\n\n /**\n * Add a custom weather preset.\n * @param {object} preset - Preset to add\n * @param {string} preset.id - Unique ID\n * @param {string} preset.label - Display label\n * @param {string} [preset.icon] - Icon class\n * @param {string} [preset.color] - Display color\n * @param {string} [preset.description] - Description\n * @returns {Promise} The added preset\n */\n static async addCustomPreset(preset) {\n if (!canChangeWeather()) return null;\n const customPresets = this.getCustomPresets();\n if (customPresets.some((p) => p.id === preset.id) || ALL_PRESETS.some((p) => p.id === preset.id)) {\n ATLAS.log(2, `Weather preset ID already exists: ${preset.id}`);\n ui.notifications.warn(_loc('CALENDARIA.Weather.Error.DuplicateId', { id: preset.id }));\n return null;\n }\n const newPreset = {\n id: preset.id,\n label: preset.label,\n description: preset.description || '',\n icon: preset.icon || 'fa-question',\n color: preset.color || '#888888',\n category: 'custom',\n ...(preset.wind && { wind: preset.wind }),\n ...(preset.precipitation && { precipitation: preset.precipitation }),\n ...(preset.tempMin != null && { tempMin: preset.tempMin }),\n ...(preset.tempMax != null && { tempMax: preset.tempMax }),\n ...(preset.hudEffect && { hudEffect: preset.hudEffect }),\n ...(preset.fxPreset && { fxPreset: preset.fxPreset }),\n ...(preset.soundFx && { soundFx: preset.soundFx }),\n ...(preset.fxMacro && { fxMacro: preset.fxMacro }),\n ...(preset.inertiaWeight != null && { inertiaWeight: preset.inertiaWeight }),\n ...(preset.chance != null && { chance: preset.chance }),\n ...(preset.darknessPenalty != null && { darknessPenalty: preset.darknessPenalty })\n };\n customPresets.push(newPreset);\n await game.settings.set(MODULE.ID, SETTINGS.CUSTOM_WEATHER_PRESETS, customPresets);\n ATLAS.log(3, `Added custom weather preset: ${preset.id}`);\n return newPreset;\n }\n\n /**\n * Remove a custom weather preset.\n * @param {string} presetId - Preset ID to remove\n * @returns {Promise} True if removed\n */\n static async removeCustomPreset(presetId) {\n if (!canChangeWeather()) return false;\n const customPresets = this.getCustomPresets();\n const index = customPresets.findIndex((p) => p.id === presetId);\n if (index === -1) return false;\n customPresets.splice(index, 1);\n await game.settings.set(MODULE.ID, SETTINGS.CUSTOM_WEATHER_PRESETS, customPresets);\n ATLAS.log(3, `Removed custom weather preset: ${presetId}`);\n return true;\n }\n\n /**\n * Update a custom weather preset.\n * @param {string} presetId - Preset ID to update\n * @param {object} updates - Updates to apply\n * @returns {Promise} Updated preset or null\n */\n static async updateCustomPreset(presetId, updates) {\n if (!canChangeWeather()) return null;\n const customPresets = this.getCustomPresets();\n const index = customPresets.findIndex((p) => p.id === presetId);\n if (index === -1) return null;\n const preset = customPresets[index];\n Object.assign(preset, updates);\n preset.category = 'custom';\n await game.settings.set(MODULE.ID, SETTINGS.CUSTOM_WEATHER_PRESETS, customPresets);\n ATLAS.log(3, `Updated custom weather preset: ${presetId}`);\n return preset;\n }\n\n /**\n * Get all weather presets (built-in + custom).\n * @returns {object[]} All presets\n */\n static getAllPresets() {\n return getAllPresets(this.getCustomPresets());\n }\n\n /**\n * Get a weather preset by ID.\n * @param {string} presetId - Preset ID\n * @returns {object|null} Preset or null\n */\n static getPreset(presetId) {\n return getPreset(presetId, this.getCustomPresets());\n }\n\n /**\n * Get presets grouped by category.\n * @returns {object} Presets by category\n */\n static getPresetsByCategory() {\n const all = this.getAllPresets();\n const grouped = {};\n for (const category of Object.keys(WEATHER_CATEGORIES)) grouped[category] = all.filter((p) => p.category === category);\n return grouped;\n }\n\n /**\n * Get weather categories.\n * @returns {object} Category definitions\n */\n static getCategories() {\n return WEATHER_CATEGORIES;\n }\n\n /**\n * Format a temperature value with the configured unit.\n * @param {number} celsius - Temperature in Celsius\n * @returns {string} Formatted temperature with unit symbol\n */\n static formatTemperature(celsius) {\n if (celsius == null) return '';\n const unit = game.settings.get(MODULE.ID, SETTINGS.TEMPERATURE_UNIT);\n const showBoth = game.settings.get(MODULE.ID, SETTINGS.TEMPERATURE_SHOW_BOTH);\n const c = `${Math.round(celsius)}°C`;\n const f = `${Math.round((celsius * 9) / 5 + 32)}°F`;\n if (!showBoth) return unit === 'fahrenheit' ? f : c;\n return unit === 'fahrenheit' ? `${f} (${c})` : `${c} (${f})`;\n }\n\n /**\n * Format a wind speed value with the configured unit.\n * @param {number} kph - Wind speed in kph\n * @returns {string} Formatted wind speed with unit\n */\n static formatWindSpeed(kph) {\n if (kph == null) return '';\n const unit = game.settings.get(MODULE.ID, SETTINGS.WIND_SPEED_UNIT);\n if (unit === 'mph') return `${Math.round(kph * 0.621371)} ${_loc('CALENDARIA.Settings.WindSpeedUnit.MphAbbr')}`;\n return `${Math.round(kph)} ${_loc('CALENDARIA.Settings.WindSpeedUnit.KphAbbr')}`;\n }\n\n /**\n * Format a precipitation rate with the configured unit.\n * @param {number} mmhr - Precipitation in mm/hr\n * @returns {string} Formatted precipitation with unit\n */\n static formatPrecipitation(mmhr) {\n if (mmhr == null || mmhr === 0) return '';\n const unit = game.settings.get(MODULE.ID, SETTINGS.PRECIPITATION_UNIT);\n if (unit === 'imperial') return `${(mmhr * 0.03937).toFixed(2)} ${_loc('CALENDARIA.Settings.PrecipitationUnit.ImperialAbbr')}`;\n return `${mmhr.toFixed(1)} ${_loc('CALENDARIA.Settings.PrecipitationUnit.MetricAbbr')}`;\n }\n\n /**\n * Get the localized label for a wind speed value (0-5 scale).\n * @param {number} speed - Wind speed on 0-5 scale\n * @returns {string} Localized wind speed label\n */\n static getWindSpeedLabel(speed) {\n if (speed == null) return '';\n const entry = Object.values(WIND_SPEEDS).find((w) => w.value === speed);\n return entry ? _loc(entry.label) : '';\n }\n\n /**\n * Normalize a wind direction input to degrees.\n * @param {number|string|null|undefined} value - Raw direction input\n * @returns {number|null} Direction in degrees wrapped to 0-360, or null for unrecognized/missing input\n * @private\n */\n static #normalizeWindDirection(value) {\n if (value == null) return null;\n if (typeof value === 'number') {\n if (!Number.isFinite(value)) return null;\n const wrapped = ((value % 360) + 360) % 360;\n return wrapped;\n }\n if (typeof value === 'string') {\n const key = value.trim().toUpperCase();\n const deg = COMPASS_DIRECTIONS[key];\n return typeof deg === 'number' ? deg : null;\n }\n return null;\n }\n\n /**\n * Get the nearest compass direction abbreviation for a degree value.\n * @param {number|null} degrees - Direction in degrees (0-360)\n * @returns {string} Compass abbreviation (e.g. \"NNE\") or empty string\n */\n static getWindDirectionLabel(degrees) {\n if (degrees == null) return '';\n const entries = Object.entries(COMPASS_DIRECTIONS);\n let closest = entries[0];\n let minDiff = 360;\n for (const [id, deg] of entries) {\n const diff = Math.abs(((degrees - deg + 540) % 360) - 180);\n if (diff < minDiff) {\n minDiff = diff;\n closest = [id, deg];\n }\n }\n return _loc(`CALENDARIA.Wind.${closest[0]}`);\n }\n\n /**\n * Get a randomized kph value for a wind speed scale value (0-5).\n * @param {number} speed - Wind speed on 0-5 scale\n * @returns {number} Speed in kph\n */\n static getWindSpeedKph(speed) {\n const speeds = Object.values(WIND_SPEEDS);\n const entry = speeds.find((w) => w.value === speed);\n if (!entry) return 0;\n const prevEntry = speeds.find((w) => w.value === speed - 1);\n const min = prevEntry ? prevEntry.kph + 1 : 0;\n const max = entry.kph;\n return Math.floor(Math.random() * (max - min + 1)) + min;\n }\n\n /**\n * Build a weight tooltip showing the source breakdown for a preset.\n * @param {string} id - Preset ID\n * @param {number} weight - Final effective weight\n * @param {number} totalWeight - Total weight of all presets\n * @param {object} [seasonClimate] - Season climate data\n * @param {object} [zoneOverride] - Zone season override data\n * @returns {{ weightTooltip: string, percentTooltip: string }} HTML tooltip strings\n */\n static #buildTooltips(id, weight, totalWeight, seasonClimate, zoneOverride) {\n const seasonPreset = Object.values(seasonClimate?.presets ?? {}).find((p) => p.id === id);\n const zoneOverridePreset = zoneOverride?.presets ? Object.values(zoneOverride.presets).find((p) => p.id === id) : null;\n const seasonVal = seasonPreset?.chance ?? 0;\n let zoneVal = 0;\n if (zoneOverridePreset) {\n const ch = zoneOverridePreset.chance;\n zoneVal = typeof ch === 'string' && /[+-]$/.test(ch) ? ch : (ch ?? 0);\n }\n const sLabel = _loc('CALENDARIA.WeatherProbability.SeasonBase');\n const zLabel = _loc('CALENDARIA.WeatherProbability.ZoneOverride');\n const eLabel = _loc('CALENDARIA.WeatherProbability.Effective');\n const weightTooltip = `

${sLabel}: ${seasonVal}

` + `

${zLabel}: ${zoneVal}

` + `

${eLabel}: ${weight}

`;\n const percent = totalWeight > 0 ? Math.round((weight / totalWeight) * 1000) / 10 : 0;\n const percentTooltip = `

${weight} / ${totalWeight}

` + `

${percent}% ${_loc('CALENDARIA.WeatherProbability.Likely')}

`;\n return { weightTooltip, percentTooltip };\n }\n\n /**\n * Compute the effective probability breakdown for a zone and season.\n * @param {object} [options] - Options\n * @param {string} [options.zoneId] - Zone ID (defaults to active zone)\n * @param {string} [options.season] - Season name (defaults to current season)\n * @returns {object} { zone: { id, name }, season, entries: [{ id, label, icon, color, weight, percent }], tempRange: { min, max } }\n */\n static getWeatherProbabilities({ zoneId, season } = {}) {\n const isNoZone = zoneId === 'none' || (zoneId == null && this.isZoneDisabled());\n const zone = isNoZone ? null : (this.getActiveZone(zoneId) ?? { id: this.DEFAULT_ZONE, name: 'Default' });\n const seasonData = this.#getCurrentSeason();\n const calendar = CalendarManager.getActiveCalendar();\n const seasons = calendar?.seasonsArray ?? [];\n let resolvedSeason = season;\n if (resolvedSeason) {\n const match = seasons.find((s) => _loc(s.name).toLowerCase() === resolvedSeason.toLowerCase());\n if (match) resolvedSeason = match.name;\n else resolvedSeason = seasonData?.name ?? null;\n } else {\n resolvedSeason = seasonData?.name ?? null;\n }\n const resolvedSeasonObj = seasons.find((s) => s.name === resolvedSeason);\n const seasonClimate = resolvedSeasonObj?.climate ?? seasonData?.climate ?? null;\n const zoneOverride = resolvedSeason && zone?.seasonOverrides?.[resolvedSeason];\n const { probabilities, tempRange } = mergeClimateConfig(seasonClimate, zoneOverride, isNoZone ? null : zone, resolvedSeason);\n const customPresets = this.getCustomPresets();\n const totalWeight = Object.values(probabilities).reduce((sum, w) => sum + w, 0);\n const calendarId = calendar?.metadata?.id;\n const entries = Object.entries(probabilities)\n .map(([id, weight]) => {\n const preset = getPreset(id, customPresets);\n return {\n id,\n label: preset ? this.resolveDisplayLabel(id, preset.label, calendarId, zone?.id) : id,\n icon: preset?.icon ?? 'fa-question',\n color: preset?.color ?? '#888888',\n weight,\n percent: totalWeight > 0 ? Math.round((weight / totalWeight) * 1000) / 10 : 0,\n ...this.#buildTooltips(id, weight, totalWeight, seasonClimate, zoneOverride)\n };\n })\n .sort((a, b) => b.percent - a.percent);\n return {\n zone: isNoZone ? { id: 'none', name: _loc('CALENDARIA.Weather.Picker.NoZone') } : { id: zone.id, name: zone.name ? _loc(zone.name) : zone.id },\n season: resolvedSeason ? _loc(resolvedSeason) : null,\n entries,\n tempRange\n };\n }\n\n /**\n * Compute probability breakdown from raw climate data (for editors working with unsaved data).\n * @param {object} options - Raw climate inputs\n * @param {object} options.seasonClimate - Season's climate { temperatures, presets }\n * @param {object} options.zoneConfig - Zone config object with presets, seasonOverrides, etc.\n * @param {string} options.season - Season name\n * @param {string} [options.calendarId] - Calendar ID for alias lookup\n * @returns {object} { entries: [{ id, label, icon, color, weight, percent }], tempRange: { min, max } }\n */\n static computeProbabilitiesFromRaw({ seasonClimate, zoneConfig, season, calendarId } = {}) {\n const zoneOverride = season && zoneConfig?.seasonOverrides?.[season];\n const { probabilities, tempRange } = mergeClimateConfig(seasonClimate, zoneOverride, zoneConfig, season);\n const customPresets = this.getCustomPresets();\n const totalWeight = Object.values(probabilities).reduce((sum, w) => sum + w, 0);\n const zoneId = zoneConfig?.id;\n const entries = Object.entries(probabilities)\n .map(([id, weight]) => {\n const preset = getPreset(id, customPresets);\n return {\n id,\n label: preset ? this.resolveDisplayLabel(id, preset.label, calendarId, zoneId) : id,\n icon: preset?.icon ?? 'fa-question',\n color: preset?.color ?? '#888888',\n weight,\n percent: totalWeight > 0 ? Math.round((weight / totalWeight) * 1000) / 10 : 0,\n ...this.#buildTooltips(id, weight, totalWeight, seasonClimate, zoneOverride)\n };\n })\n .sort((a, b) => b.percent - a.percent);\n return { entries, tempRange };\n }\n\n /**\n * Build a rich HTML tooltip for weather display.\n * @param {object} options - Paramters for HTML tooltips\n * @param {string} options.label - Weather label\n * @param {string} [options.description] - Weather description\n * @param {string} [options.temp] - Formatted temperature string\n * @param {number} [options.windSpeed] - Wind speed (0-5 scale)\n * @param {number|null} [options.windKph] - Pre-computed wind speed in kph (avoids re-randomizing)\n * @param {number|null} [options.windDirection] - Wind direction in degrees\n * @param {string|null} [options.precipType] - Precipitation type key\n * @param {number} [options.precipIntensity] - Precipitation intensity (0-1)\n * @returns {string} HTML-encoded string for data-tooltip-html\n */\n static buildWeatherTooltip({ label, description, temp, windSpeed, windKph, windDirection, precipType, precipIntensity }) {\n const esc = (s) => (s ?? '').replace(/&/g, '&').replace(//g, '>');\n const rows = [];\n const desc = description && description !== label ? esc(description) : '';\n rows.push(`
${esc(label)}${desc ? ` — ${desc}` : ''}
`);\n if (temp != null) rows.push(`
${esc(temp)}
`);\n if (windSpeed > 0) {\n const windLabel = this.getWindSpeedLabel(windSpeed);\n const resolvedKph = windKph ?? this.getWindSpeedKph(windSpeed);\n const windFormatted = this.formatWindSpeed(resolvedKph);\n const dirLabel = this.getWindDirectionLabel(windDirection);\n rows.push(`
${esc(windLabel)}${dirLabel ? ` ${esc(dirLabel)}` : ''} · ${esc(windFormatted)}
`);\n }\n if (precipType) {\n const precipLabel = _loc(`CALENDARIA.Weather.Precipitation.${precipType.charAt(0).toUpperCase() + precipType.slice(1)}`);\n const rate = this.formatPrecipitation((precipIntensity ?? 0) * 10);\n rows.push(`
${esc(precipLabel)}${rate ? ` · ${esc(rate)}` : ''}
`);\n }\n const html = `
${rows.join('')}
`;\n return html.replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"');\n }\n\n /**\n * Build a weather tooltip with intraday period breakdown appended.\n * @param {object} mainTooltipArgs - Arguments for buildWeatherTooltip()\n * @param {object} [periods] - Period data { night, morning, afternoon, evening }\n * @param {string} [activePeriod] - Currently active period ID\n * @returns {string} HTML-encoded tooltip string\n */\n static buildWeatherTooltipWithPeriods(mainTooltipArgs, periods, activePeriod) {\n if (!periods) return this.buildWeatherTooltip(mainTooltipArgs);\n const esc = (s) => (s ?? '').replace(/&/g, '&').replace(//g, '>');\n const rows = [];\n const desc = mainTooltipArgs.description && mainTooltipArgs.description !== mainTooltipArgs.label ? esc(mainTooltipArgs.description) : '';\n rows.push(`
${esc(mainTooltipArgs.label)}${desc ? ` — ${desc}` : ''}
`);\n if (mainTooltipArgs.temp != null) rows.push(`
${esc(mainTooltipArgs.temp)}
`);\n if (mainTooltipArgs.windSpeed > 0) {\n const windLabel = this.getWindSpeedLabel(mainTooltipArgs.windSpeed);\n const resolvedKph = mainTooltipArgs.windKph ?? this.getWindSpeedKph(mainTooltipArgs.windSpeed);\n const windFormatted = this.formatWindSpeed(resolvedKph);\n const dirLabel = this.getWindDirectionLabel(mainTooltipArgs.windDirection);\n rows.push(`
${esc(windLabel)}${dirLabel ? ` ${esc(dirLabel)}` : ''} · ${esc(windFormatted)}
`);\n }\n if (mainTooltipArgs.precipType) {\n const precipLabel = _loc(`CALENDARIA.Weather.Precipitation.${mainTooltipArgs.precipType.charAt(0).toUpperCase() + mainTooltipArgs.precipType.slice(1)}`);\n const rate = this.formatPrecipitation((mainTooltipArgs.precipIntensity ?? 0) * 10);\n rows.push(`
${esc(precipLabel)}${rate ? ` · ${esc(rate)}` : ''}
`);\n }\n rows.push('
');\n for (const period of Object.values(WEATHER_PERIODS)) {\n const pw = periods[period.id];\n if (!pw) continue;\n const pLabel = _loc(pw.preset?.label ?? pw.label ?? pw.id);\n const pTemp = this.formatTemperature(pw.temperature);\n const isActive = activePeriod === period.id;\n const periodLabel = _loc(period.label);\n const marker = isActive ? ' ●' : '';\n rows.push(`
${esc(periodLabel)}${marker}: ${esc(pLabel)} · ${esc(pTemp)}
`);\n }\n const html = `
${rows.join('')}
`;\n return html.replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"');\n }\n}\n","import { HOOKS, MODULE, SCENE_FLAGS, SETTINGS } from '../constants.mjs';\nimport { resolveWeatherSoundPath } from './data/weather-presets.mjs';\nimport WeatherManager from './weather-manager.mjs';\n\n/** Crossfade duration in milliseconds. */\nconst FADE_MS = 2000;\n\n/** @type {object|null} Currently playing weather ambient sound. */\nlet activeSound = null;\n\n/** @type {string|null} Full path of the currently playing sound (for dedup). */\nlet activeSoundKey = null;\n\n/** @type {boolean} Whether the current client is inside a core SuppressWeather region, muting Calendaria's audio. */\nlet suppressedByRegion = false;\n\n/** @type {Set} Cached scene regions with an active `suppressWeather` behavior. */\nlet suppressRegions = new Set();\n\n/** @type {Set} Cached tokens whose actor the current user owns, used when nothing is actively controlled. */\nlet ownedTokens = new Set();\n\n/**\n * Initialize weather sound hooks.\n */\nexport function initializeWeatherSound() {\n Hooks.on(HOOKS.WEATHER_CHANGE, onWeatherChange);\n Hooks.on('canvasReady', onCanvasReady);\n Hooks.on('updateScene', onSceneUpdate);\n for (const hook of ['createRegion', 'updateRegion', 'deleteRegion', 'createRegionBehavior', 'updateRegionBehavior', 'deleteRegionBehavior']) {\n Hooks.on(hook, () => {\n rebuildSuppressRegions();\n applyRegionSuppression();\n });\n }\n for (const hook of ['createToken', 'deleteToken', 'updateToken']) {\n Hooks.on(hook, () => {\n rebuildOwnedTokens();\n applyRegionSuppression();\n });\n }\n Hooks.on('controlToken', applyRegionSuppression);\n Hooks.on('refreshToken', onRefreshToken);\n rebuildSuppressRegions();\n rebuildOwnedTokens();\n if (!isSoundDisabledForScene(canvas?.scene)) {\n const weather = WeatherManager.getCurrentWeather();\n playSound(weather || null);\n }\n ATLAS.log(3, 'WeatherSound initialized');\n}\n\n/** Rebuild the cached set of regions whose `suppressWeather` behavior is active on the current scene. */\nfunction rebuildSuppressRegions() {\n suppressRegions = new Set();\n const scene = canvas?.scene;\n if (!scene) return;\n for (const region of scene.regions) if (region.behaviors.some((b) => b.type === 'suppressWeather' && !b.disabled)) suppressRegions.add(region);\n}\n\n/** Rebuild the cached set of placed tokens whose actor the current user owns. */\nfunction rebuildOwnedTokens() {\n ownedTokens = new Set();\n if (!canvas?.tokens) return;\n for (const t of canvas.tokens.placeables) if (t.actor?.isOwner) ownedTokens.add(t);\n}\n\n/**\n * Return true when any of the current user's relevant tokens is inside a cached suppress-weather region.\n * @returns {boolean} True when a relevant token is inside any cached suppress-weather region\n */\nfunction isInsideSuppressWeatherRegion() {\n if (!suppressRegions.size) return false;\n const controlled = canvas.tokens.controlled;\n const tokens = controlled.length ? controlled : ownedTokens;\n if ((tokens.size ?? tokens.length) === 0) return false;\n for (const region of suppressRegions) {\n for (const token of tokens) {\n const c = token.center;\n if (!c) continue;\n if (region.testPoint({ x: c.x, y: c.y, elevation: token.document?.elevation ?? 0 })) return true;\n }\n }\n return false;\n}\n\n/**\n * Refresh suppression state and fade the active sound to/from silence when it changes.\n */\nfunction applyRegionSuppression() {\n if (!activeSound) return;\n const next = isInsideSuppressWeatherRegion();\n if (next === suppressedByRegion) return;\n suppressedByRegion = next;\n const baseVolume = game.settings.get(MODULE.ID, SETTINGS.WEATHER_SOUND_VOLUME) ?? 0.5;\n const muffle = game.settings.get(MODULE.ID, SETTINGS.WEATHER_SUPPRESS_MUFFLE) ?? 0;\n activeSound.fade(next ? baseVolume * muffle : baseVolume, { duration: FADE_MS });\n}\n\n/**\n * Per-frame token refresh handler — only triggers suppression checks for tokens the user actually cares about.\n * @param {object} token - The refreshing token placeable\n */\nfunction onRefreshToken(token) {\n if (!activeSound || !suppressRegions.size) return;\n const controlled = canvas.tokens.controlled;\n const relevant = controlled.length ? controlled.includes(token) : ownedTokens.has(token);\n if (!relevant) return;\n applyRegionSuppression();\n}\n\n/**\n * On scene update, re-sync sound if the weather disable flag changed or scene activated.\n * @param {object} scene - Updated scene document\n * @param {object} change - Change data\n */\nfunction onSceneUpdate(scene, change) {\n if (change.active) {\n if (isSoundDisabledForScene(scene)) {\n stopSound();\n } else {\n const weather = WeatherManager.getCurrentWeather(null, scene);\n playSound(weather || null);\n }\n return;\n }\n if (scene !== canvas?.scene) return;\n const flat = foundry.utils.flattenObject(change);\n const fxKey = `flags.${MODULE.ID}.${SCENE_FLAGS.WEATHER_FX_DISABLED}`;\n const soundKey = `flags.${MODULE.ID}.${SCENE_FLAGS.WEATHER_SOUND_DISABLED}`;\n if (!(fxKey in flat) && !(soundKey in flat)) return;\n if (isSoundDisabledForScene(scene)) {\n stopSound();\n } else {\n const weather = WeatherManager.getCurrentWeather();\n playSound(weather || null);\n }\n}\n\n/**\n * Handle canvas ready — sync sound to the new scene's weather.\n */\nfunction onCanvasReady() {\n rebuildSuppressRegions();\n rebuildOwnedTokens();\n suppressedByRegion = false;\n const scene = canvas?.scene;\n if (!scene) return;\n if (isSoundDisabledForScene(scene)) {\n stopSound();\n return;\n }\n const weather = WeatherManager.getCurrentWeather();\n playSound(weather || null);\n}\n\n/**\n * Handle weather change hook — play or stop sound.\n * @param {object} payload - Weather change hook payload\n * @param {object|null} payload.current - Current weather data\n * @param {boolean} payload.bulk - Whether this is a bulk weather change\n * @param {boolean} payload.visualOnly - Whether this is a visual-only refresh (skip sound)\n */\nfunction onWeatherChange({ current, bulk, visualOnly } = {}) {\n if (visualOnly) return;\n if (bulk) {\n const weather = WeatherManager.getCurrentWeather();\n playSound(weather || null);\n return;\n }\n const scene = game.scenes?.active;\n if (isSoundDisabledForScene(scene)) {\n stopSound();\n return;\n }\n playSound(current || null);\n}\n\n/**\n * Fade out and stop a sound, then clean up.\n * @param {object} sound - The sound to fade out\n */\nasync function fadeOutAndStop(sound) {\n try {\n await sound.fade(0, { duration: FADE_MS });\n } finally {\n sound.stop();\n }\n}\n\n/**\n * Check if weather sound is disabled for a scene (per-scene flag or FX disabled).\n * @param {object} [scene] - Scene document\n * @returns {boolean} Whether sound is disabled\n */\nfunction isSoundDisabledForScene(scene) {\n if (!scene) return false;\n return scene.getFlag(MODULE.ID, SCENE_FLAGS.WEATHER_SOUND_DISABLED) || scene.getFlag(MODULE.ID, SCENE_FLAGS.WEATHER_FX_OVERRIDE) === 'off';\n}\n\n/**\n * Play the ambient sound loop for the current weather with crossfade.\n * @param {object|null} weather - Current weather state\n */\nasync function playSound(weather) {\n if (!game.settings.get(MODULE.ID, SETTINGS.WEATHER_SOUND_FX)) {\n stopSound();\n return;\n }\n if (game.audio.locked) {\n game.audio.awaitFirstGesture().then(() => playSound(weather));\n return;\n }\n const soundKey = weather?.soundFx || null;\n const src = resolveWeatherSoundPath(soundKey);\n if (src === activeSoundKey && activeSound?.playing) return;\n const oldSound = activeSound;\n activeSound = null;\n activeSoundKey = null;\n if (oldSound) await fadeOutAndStop(oldSound);\n if (!src) return;\n try {\n const sound = await game.audio.play(src, { loop: true, volume: 0, context: game.audio.environment });\n const baseVolume = game.settings.get(MODULE.ID, SETTINGS.WEATHER_SOUND_VOLUME) ?? 0.5;\n const muffle = game.settings.get(MODULE.ID, SETTINGS.WEATHER_SUPPRESS_MUFFLE) ?? 0;\n suppressedByRegion = isInsideSuppressWeatherRegion();\n sound.fade(suppressedByRegion ? baseVolume * muffle : baseVolume, { duration: FADE_MS });\n activeSound = sound;\n activeSoundKey = src;\n ATLAS.log(3, `Playing \"${src}\"${suppressedByRegion ? ' (suppressed by region)' : ''}`);\n } catch (error) {\n ATLAS.log(1, 'Weather sound playback failed:', error);\n }\n}\n\n/**\n * Fade out and stop the currently playing weather sound.\n */\nasync function stopSound() {\n if (activeSound) {\n const stoppingKey = activeSoundKey;\n const sound = activeSound;\n activeSound = null;\n activeSoundKey = null;\n ATLAS.log(3, `Stopping \"${stoppingKey}\"`);\n try {\n await fadeOutAndStop(sound);\n } catch (error) {\n ATLAS.log(1, 'Weather sound stop failed:', error);\n }\n }\n}\n\n/**\n * Play an arbitrary audio file as the weather sound with crossfade.\n * @param {string} src - Foundry audio path (e.g., \"modules/my-mod/sounds/rain.ogg\")\n * @param {object} [options] - Playback options\n * @param {number} [options.volume] - Volume override (0-1, defaults to Weather Sound Volume setting)\n * @param {number} [options.fade] - Fade-in duration in ms (default: 2000)\n * @param {boolean} [options.loop] - Loop playback (default: true)\n * @param {object} [options.context] - Audio context override (default: game.audio.environment)\n * @returns {Promise} True if playback started successfully\n */\nexport async function playStandaloneSound(src, options = {}) {\n if (!game.settings.get(MODULE.ID, SETTINGS.WEATHER_SOUND_FX)) {\n ATLAS.log(2, 'Weather sounds are disabled');\n return false;\n }\n if (isSoundDisabledForScene(canvas?.scene)) {\n ATLAS.log(2, 'Weather sounds are disabled for this scene');\n return false;\n }\n if (game.audio.locked) {\n game.audio.awaitFirstGesture().then(() => playStandaloneSound(src, options));\n return false;\n }\n if (src === activeSoundKey && activeSound?.playing) return true;\n const oldSound = activeSound;\n activeSound = null;\n activeSoundKey = null;\n if (oldSound) await fadeOutAndStop(oldSound);\n const fadeDuration = options.fade ?? FADE_MS;\n try {\n const sound = await game.audio.play(src, { loop: options.loop ?? true, volume: 0, context: options.context ?? game.audio.environment });\n const volume = options.volume ?? game.settings.get(MODULE.ID, SETTINGS.WEATHER_SOUND_VOLUME) ?? 0.5;\n sound.fade(volume, { duration: fadeDuration });\n activeSound = sound;\n activeSoundKey = src;\n ATLAS.log(3, `Standalone playing \"${src}\"`);\n return true;\n } catch (error) {\n ATLAS.log(1, 'Standalone sound playback failed:', error);\n return false;\n }\n}\n\n/**\n * Stop the current weather sound with configurable fade-out.\n * @param {object} [options] - Stop options\n * @param {number} [options.fade] - Fade-out duration in ms (default: 2000)\n * @returns {Promise} True if a sound was stopped\n */\nexport async function stopStandaloneSound(options = {}) {\n if (!activeSound) return false;\n const stoppingKey = activeSoundKey;\n const sound = activeSound;\n activeSound = null;\n activeSoundKey = null;\n ATLAS.log(3, `Standalone stopping \"${stoppingKey}\"`);\n try {\n const fadeDuration = options.fade ?? FADE_MS;\n await sound.fade(0, { duration: fadeDuration });\n sound.stop();\n return true;\n } catch (error) {\n ATLAS.log(1, 'Standalone sound stop failed:', error);\n return false;\n }\n}\n","import { CalendarManager } from '../../calendar/_module.mjs';\nimport { MOON_VISIBILITY } from '../../constants.mjs';\n\n/**\n * Whether a moon should be rendered for the current user.\n * @param {object} moon - Moon definition\n * @returns {boolean} True when the moon is visible to this user\n */\nexport function isMoonVisible(moon) {\n return game.user.isGM || moon?.visibility !== MOON_VISIBILITY.HIDDEN;\n}\n\n/**\n * Get the current phase position (0-1) for a moon at a given date.\n * @param {object} moon - Moon definition (must be an entry in `calendar.moonsArray`)\n * @param {object} date - Date to check { year, month, dayOfMonth }\n * @param {object} calendar - Calendar instance (optional, uses active if not provided)\n * @returns {number} Phase position from 0 to 1\n */\nexport function getMoonPhasePosition(moon, date, calendar = null) {\n calendar = calendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moon) return 0;\n const moonIndex = calendar.moonsArray?.indexOf(moon) ?? -1;\n if (moonIndex < 0) return 0;\n const phase = calendar.getMoonPhase(moonIndex, date);\n return Number.isFinite(phase?.position) ? phase.position : 0;\n}\n\n/**\n * Get drawing info for a moon: its phase position plus whether it is in its true Full or New band.\n * @param {object} moon - Moon definition (must be an entry in `calendar.moonsArray`)\n * @param {object} date - Date to check { year, month, dayOfMonth }\n * @param {object} [calendar] - Calendar instance (uses active if not provided)\n * @returns {{position: number, isFull: boolean, isNew: boolean}} Render info\n */\nexport function getMoonRenderInfo(moon, date, calendar = null) {\n calendar = calendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moon) return { position: 0, isFull: false, isNew: false };\n const moonIndex = calendar.moonsArray?.indexOf(moon) ?? -1;\n if (moonIndex < 0) return { position: 0, isFull: false, isNew: false };\n const phase = calendar.getMoonPhase(moonIndex, date);\n const position = Number.isFinite(phase?.position) ? phase.position : 0;\n const phases = moon.phases ? Object.values(moon.phases) : [];\n const current = phases[phase?.phaseIndex ?? -1];\n const inBand = (p, x) => (p.end > p.start ? x >= p.start && x < p.end : x >= p.start || x < p.end);\n return { position, isFull: !!current && inBand(current, 0.5), isNew: !!current && inBand(current, 0) };\n}\n\n/**\n * Check if a moon is in its full phase at a given date.\n * @param {object} moon - Moon definition\n * @param {object} date - Date to check\n * @param {object} calendar - Calendar instance (optional)\n * @returns {boolean} True if moon is full\n */\nexport function isMoonFull(moon, date, calendar = null) {\n const position = getMoonPhasePosition(moon, date, calendar);\n return position >= 0.5 && position < 0.625;\n}\n\n/**\n * Check if a moon is in its new phase at a given date.\n * @param {object} moon - Moon definition\n * @param {object} date - Date to check\n * @param {object} calendar - Calendar instance (optional)\n * @returns {boolean} True if moon is new\n */\nexport function isNewMoon(moon, date, calendar = null) {\n const position = getMoonPhasePosition(moon, date, calendar);\n return position >= 0 && position < 0.125;\n}\n\n/**\n * Find the next date when all moons are simultaneously full (Convergence).\n * @param {Array} moons - Array of moon definitions\n * @param {object} startDate - Date to start searching from { year, month, dayOfMonth }\n * @param {object} options - Search options\n * @param {number} options.maxDays - Maximum days to search (default: 1000)\n * @param {object} options.calendar - Calendar instance (optional)\n * @returns {object|null} Next convergence date { year, month, dayOfMonth }, or null if not found within maxDays\n */\nexport function getNextConvergence(moons, startDate, options = {}) {\n const { maxDays = 1000, calendar: providedCalendar } = options;\n const calendar = providedCalendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moons || moons.length === 0) return null;\n if (moons.length === 1) return getNextFullMoon(moons[0], startDate, { maxDays, calendar });\n let currentDate = { ...startDate };\n for (let i = 0; i < maxDays; i++) {\n const allFull = moons.every((moon) => isMoonFull(moon, currentDate, calendar));\n if (allFull) return currentDate;\n currentDate = addOneDay(currentDate, calendar);\n }\n return null;\n}\n\n/**\n * Find the next full moon date for a single moon.\n * @param {object} moon - Moon definition\n * @param {object} startDate - Date to start searching from\n * @param {object} options - Search options\n * @returns {object|null} Next full moon date { year, month, dayOfMonth }\n */\nexport function getNextFullMoon(moon, startDate, options = {}) {\n const { maxDays = 1000, calendar: providedCalendar } = options;\n const calendar = providedCalendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moon) return null;\n let currentDate = { ...startDate };\n for (let i = 0; i < maxDays; i++) {\n if (isMoonFull(moon, currentDate, calendar)) return currentDate;\n currentDate = addOneDay(currentDate, calendar);\n }\n return null;\n}\n\n/**\n * Get all convergences within a date range.\n * @param {Array} moons - Array of moon definitions\n * @param {object} startDate - Range start date\n * @param {object} endDate - Range end date\n * @param {object} options - Search options\n * @returns {Array} Array of convergence dates { year, month, dayOfMonth }\n */\nexport function getConvergencesInRange(moons, startDate, endDate, options = {}) {\n const calendar = options.calendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moons || moons.length === 0) return [];\n const convergences = [];\n let currentDate = { ...startDate };\n let maxIterations = 10000;\n while (compareDates(currentDate, endDate) <= 0 && maxIterations-- > 0) {\n const allFull = moons.every((moon) => isMoonFull(moon, currentDate, calendar));\n if (allFull) {\n convergences.push({ ...currentDate });\n for (let skip = 0; skip < 5; skip++) currentDate = addOneDay(currentDate, calendar);\n } else {\n currentDate = addOneDay(currentDate, calendar);\n }\n }\n return convergences;\n}\n\n/**\n * Convert a date to an absolute day number for comparison.\n * @param {object} date - Date { year, month, dayOfMonth }\n * @param {object} calendar - Calendar instance\n * @returns {number} Absolute day number\n */\nexport function dateToDayNumber(date, calendar) {\n const daysPerYear = calendar.days?.daysPerYear ?? 365;\n const months = calendar.monthsArray ?? [];\n let dayNumber = date.year * daysPerYear;\n for (let m = 0; m < date.month && m < months.length; m++) dayNumber += months[m]?.days ?? 30;\n dayNumber += date.dayOfMonth ?? 0;\n return dayNumber;\n}\n\n/**\n * Add one day to a date.\n * @param {object} date - Date to increment { year, month, dayOfMonth }\n * @param {object} calendar - Calendar instance\n * @returns {object} New date { year, month, dayOfMonth }\n */\nexport function addOneDay(date, calendar) {\n const months = calendar.monthsArray ?? [];\n let { year, month } = date;\n let dayOfMonth = (date.dayOfMonth ?? 0) + 1;\n const daysInMonth = months[month]?.days ?? 30;\n if (dayOfMonth >= daysInMonth) {\n dayOfMonth = 0;\n month++;\n if (month >= months.length) {\n month = 0;\n year++;\n }\n }\n return { year, month, dayOfMonth };\n}\n\n/**\n * Compare two dates.\n * @param {object} date1 - First date\n * @param {object} date2 - Second date\n * @returns {number} -1 if date1 < date2, 0 if equal, 1 if date1 > date2\n */\nexport function compareDates(date1, date2) {\n if (date1.year !== date2.year) return date1.year < date2.year ? -1 : 1;\n if (date1.month !== date2.month) return date1.month < date2.month ? -1 : 1;\n if (date1.dayOfMonth !== date2.dayOfMonth) return date1.dayOfMonth < date2.dayOfMonth ? -1 : 1;\n return 0;\n}\n","import { resolveRandomizedPhase } from '../../data/_module.mjs';\nimport WeatherManager from '../../weather/weather-manager.mjs';\nimport { normalizeRawDoy } from '../calendar-math.mjs';\nimport { canViewMoons } from '../permissions.mjs';\nimport { isMoonVisible } from './moon-utils.mjs';\n\n/**\n * Resolve a convenience array getter from a calendar object.\n * @param {object} calendar - Calendar model or plain data object\n * @param {string} getter - Getter name (e.g., 'monthsArray')\n * @param {string} path - Dot-separated path to the keyed object (e.g., 'months.values')\n * @returns {Array} Ordered array of collection items\n */\nfunction resolveArray(calendar, getter, path) {\n if (calendar?.[getter]) return calendar[getter];\n const obj = path.split('.').reduce((o, k) => o?.[k], calendar);\n return obj ? Object.values(obj) : [];\n}\n\n/**\n * Get ordinal suffix for a number.\n * @param {number} n - Number\n * @returns {string} - Number with ordinal suffix (1st, 2nd, 3rd, etc.)\n */\nexport function ordinal(n) {\n const s = [_loc('CALENDARIA.Format.Ordinal.th'), _loc('CALENDARIA.Format.Ordinal.st'), _loc('CALENDARIA.Format.Ordinal.nd'), _loc('CALENDARIA.Format.Ordinal.rd')];\n const v = n % 100;\n return n + (s[(v - 20) % 10] || s[v] || s[0]);\n}\n\n/**\n * Convert a number to Roman numerals.\n * @param {number} n - Number (1-3999)\n * @returns {string} - Roman numeral string\n */\nexport function toRomanNumeral(n) {\n if (n < 1 || n > 3999) return String(n);\n const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];\n const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];\n let result = '';\n for (let i = 0; i < values.length; i++) {\n while (n >= values[i]) {\n result += numerals[i];\n n -= values[i];\n }\n }\n return result;\n}\n\n/**\n * Prepared date parts passed to formatters.\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components { year, month, dayOfMonth (0-indexed), hour, minute, second }\n * @returns {object} - Object with all formatting parts\n */\nexport function dateFormattingParts(calendar, components) {\n const { year, month, dayOfMonth, hour = 0, minute = 0, second = 0 } = components;\n const displayYear = year;\n const yearZero = calendar?.years?.yearZero ?? 0;\n const internalYear = displayYear - yearZero;\n const isMonthless = calendar?.isMonthless ?? false;\n const months = resolveArray(calendar, 'monthsArray', 'months.values');\n const monthData = isMonthless ? null : months[month];\n const festivalDay = calendar?.findFestivalDay?.({ ...components, year: internalYear, dayOfMonth });\n const isIntercalaryMonth = monthData?.type === 'intercalary';\n const isIntercalaryFestival = festivalDay?.countsForWeekday === false || isIntercalaryMonth;\n const intercalaryName = isIntercalaryMonth && monthData ? _loc(monthData.name) : festivalDay ? _loc(festivalDay.name) : '';\n const monthName = isIntercalaryFestival ? intercalaryName : isMonthless ? '' : monthData ? _loc(monthData.name) : _loc('CALENDARIA.Common.MonthFallback', { num: month + 1 });\n const monthAbbr = isIntercalaryFestival ? intercalaryName.slice(0, 3) : isMonthless ? '' : monthData?.abbreviation ? _loc(monthData.abbreviation) : monthName.slice(0, 3);\n const weekdays = resolveArray(calendar, 'weekdaysArray', 'days.values');\n let daysInMonthsBefore = 0;\n if (!isMonthless && calendar?.getDaysInMonth) for (let m = 0; m < month; m++) daysInMonthsBefore += calendar.getDaysInMonth(m, internalYear);\n else if (!isMonthless) daysInMonthsBefore = months.slice(0, month).reduce((sum, m) => sum + (m.days || 0), 0);\n const dayOfYear = isMonthless ? dayOfMonth : daysInMonthsBefore + dayOfMonth;\n let totalDaysFromPriorYears = 0;\n if (calendar?.totalDaysBeforeYear) {\n totalDaysFromPriorYears = calendar.totalDaysBeforeYear(internalYear);\n } else if (calendar?.getDaysInYear) {\n if (internalYear > 0) for (let y = 0; y < internalYear; y++) totalDaysFromPriorYears += calendar.getDaysInYear(y);\n else if (internalYear < 0) for (let y = -1; y >= internalYear; y--) totalDaysFromPriorYears -= calendar.getDaysInYear(y);\n }\n const totalDays = totalDaysFromPriorYears + dayOfYear;\n const firstWeekday = calendar?.years?.firstWeekday ?? 0;\n const nonCountingFestivalsInYear = calendar?.countNonWeekdayFestivalsBefore?.({ year: internalYear, month, dayOfMonth }) ?? 0;\n const nonCountingFestivalsFromPriorYears = calendar?.countNonWeekdayFestivalsBeforeYear?.(internalYear) ?? 0;\n const intercalaryInYear = calendar?.countIntercalaryDaysBefore?.({ year: internalYear, month, dayOfMonth }) ?? 0;\n const intercalaryFromPriorYears = calendar?.countIntercalaryDaysBeforeYear?.(internalYear) ?? 0;\n const nonCountingTotal = nonCountingFestivalsFromPriorYears + nonCountingFestivalsInYear + intercalaryFromPriorYears + intercalaryInYear;\n const countingDays = totalDays - nonCountingTotal;\n const cyclePos = calendar?.resolveCyclePosition?.(countingDays);\n const weekday = cyclePos ? cyclePos.weekdayIndex : weekdays.length > 0 ? (((countingDays + firstWeekday) % weekdays.length) + weekdays.length) % weekdays.length : 0;\n const weekdayData = weekdays[weekday];\n const weekdayName = weekdayData?.name ? _loc(weekdayData.name) : '';\n const weekdayAbbr = weekdayData?.abbreviation ? _loc(weekdayData.abbreviation) : weekdayName.slice(0, 3);\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const midday = Math.floor(hoursPerDay / 2);\n const hour12 = hour === 0 ? midday : hour > midday ? hour - midday : hour;\n const amPm = calendar?.amPmNotation ?? {};\n const isPM = hour >= midday;\n const meridiemFull = isPM ? amPm.pm || 'PM' : amPm.am || 'AM';\n const meridiemAbbr = isPM ? amPm.pmAbbr || meridiemFull : amPm.amAbbr || meridiemFull;\n const eras = resolveArray(calendar, 'erasArray', 'eras');\n const matchingEras = [];\n if (eras.length > 0) {\n for (const era of eras) {\n if (displayYear >= era.startYear && (era.endYear == null || displayYear <= era.endYear)) {\n const name = _loc(era.name);\n const abbr = era.abbreviation ? _loc(era.abbreviation) : name.slice(0, 2);\n matchingEras.push({ name, abbr, yearInEra: displayYear - era.startYear + 1 });\n }\n }\n }\n const primaryEra = matchingEras[0] || {};\n const eraName = primaryEra.name || '';\n const eraAbbr = primaryEra.abbr || '';\n const eraYear = primaryEra.yearInEra || '';\n const yearNames = calendar?.years?.names || [];\n const yearNameEntry = yearNames.find((e) => e.year === displayYear);\n const yearName = yearNameEntry ? _loc(yearNameEntry.name) : '';\n let seasonName = '';\n let seasonAbbr = '';\n let seasonIndex = -1;\n const rawSeason = calendar?.getCurrentSeason?.({ year, month, dayOfMonth, hour, minute, second });\n const aliasZone = WeatherManager?.getActiveZone?.(null, globalThis.game?.scenes?.active);\n const currentSeason = WeatherManager?.applySeasonAlias?.(rawSeason, aliasZone) ?? rawSeason;\n if (currentSeason) {\n seasonName = _loc(currentSeason.name);\n seasonAbbr = currentSeason.abbreviation ? _loc(currentSeason.abbreviation) : seasonName.slice(0, 3);\n const allSeasons = resolveArray(calendar, 'seasonsArray', 'seasons.values');\n seasonIndex = allSeasons.indexOf(rawSeason);\n }\n const daysPerWeek = weekdays.length || 7;\n const cycleComponents = { year: internalYear, month, dayOfMonth };\n const weekOfYear = calendar?.weekNumberInYear?.(cycleComponents) ?? Math.ceil((dayOfYear + 1) / daysPerWeek);\n const weekOfMonth = calendar?.weekNumberInMonth?.(cycleComponents) ?? Math.ceil((dayOfMonth + 1) / daysPerWeek);\n let namedWeek = '';\n let namedWeekAbbr = '';\n let currentWeek = null;\n if (typeof calendar?.getCurrentWeek === 'function') {\n currentWeek = calendar.getCurrentWeek({ year: internalYear, month, dayOfMonth });\n } else {\n const weekNames = resolveArray(calendar, 'namedWeeksArray', 'weeks.names');\n if (weekNames.length && daysPerWeek > 0) {\n const weekIndex = (calendar?.weeks?.type || 'year-based') === 'month-based' ? dayOfMonth : dayOfYear;\n let weekNumber = Math.floor(weekIndex / daysPerWeek) + 1;\n let entry = weekNames.find((w) => Number(w.weekNumber) === weekNumber);\n if (!entry && calendar?.weeks?.repeat) {\n const maxWeekNumber = weekNames.reduce((max, w) => Math.max(max, Number(w.weekNumber) || 0), 0);\n if (maxWeekNumber > 0) entry = weekNames.find((w) => Number(w.weekNumber) === ((weekNumber - 1) % maxWeekNumber) + 1);\n }\n if (entry) currentWeek = { weekName: _loc(entry.name), weekAbbr: entry.abbreviation ? _loc(entry.abbreviation) : _loc(entry.name).slice(0, 3) };\n }\n }\n if (currentWeek) {\n namedWeek = currentWeek.weekName || '';\n namedWeekAbbr = currentWeek.weekAbbr || namedWeek.slice(0, 3);\n }\n let climateZoneName = '';\n let climateZoneAbbr = '';\n const activeZone = calendar?.getActiveClimateZone?.();\n if (activeZone) {\n climateZoneName = activeZone.name ? _loc(activeZone.name) : activeZone.id || '';\n climateZoneAbbr = climateZoneName.slice(0, 3);\n }\n return {\n y: displayYear,\n yy: String(displayYear).slice(-2),\n yyyy: String(displayYear).padStart(4, '0'),\n yearName: yearName,\n M: isIntercalaryFestival || isMonthless ? '' : month + 1,\n MM: isIntercalaryFestival || isMonthless ? '' : String(month + 1).padStart(2, '0'),\n MMM: monthAbbr,\n MMMM: monthName,\n Mo: isIntercalaryFestival || isMonthless ? '' : ordinal(month + 1),\n D: isMonthless ? dayOfYear + 1 : dayOfMonth + 1,\n DD: isMonthless ? String(dayOfYear + 1).padStart(2, '0') : String(dayOfMonth + 1).padStart(2, '0'),\n Do: isMonthless ? ordinal(dayOfYear + 1) : ordinal(dayOfMonth + 1),\n DDD: String(dayOfYear + 1).padStart(3, '0'),\n E: weekdayAbbr,\n EE: weekdayAbbr,\n EEE: weekdayAbbr,\n EEEE: weekdayName,\n EEEEE: weekdayName?.charAt(0) || '',\n e: weekday,\n d: weekday,\n dd: weekdayAbbr?.slice(0, 2) || '',\n ddd: weekdayAbbr,\n dddd: weekdayName,\n w: weekOfYear,\n ww: String(weekOfYear).padStart(2, '0'),\n W: weekOfMonth,\n namedWeek: namedWeek,\n namedWeekAbbr: namedWeekAbbr,\n H: hour,\n HH: String(hour).padStart(2, '0'),\n h: hour12,\n hh: String(hour12).padStart(2, '0'),\n m: minute,\n mm: String(minute).padStart(2, '0'),\n s: second,\n ss: String(second).padStart(2, '0'),\n A: meridiemAbbr,\n a: meridiemAbbr.toLowerCase(),\n meridiemFull: meridiemFull,\n G: eraAbbr,\n GG: eraAbbr,\n GGG: eraAbbr,\n GGGG: eraName,\n era: eraName,\n eraAbbr: eraAbbr,\n eraYear: eraYear,\n matchingEras: matchingEras,\n Q: seasonIndex >= 0 ? seasonIndex + 1 : '',\n QQ: seasonIndex >= 0 ? String(seasonIndex + 1).padStart(2, '0') : '',\n QQQ: seasonAbbr,\n QQQQ: seasonName,\n season: seasonName,\n seasonAbbr: seasonAbbr,\n seasonIndex: seasonIndex,\n z: climateZoneAbbr,\n zzzz: climateZoneName,\n dayOfYear: dayOfYear\n };\n}\n\n/**\n * Format date as short (e.g., \"5 Jan\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatShort(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n return `${parts.D} ${parts.MMM}`;\n}\n\n/**\n * Format date as long (e.g., \"5 January, 1492\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatLong(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n return `${parts.D} ${parts.MMMM}, ${parts.y}`;\n}\n\n/**\n * Format date as full (e.g., \"Monday, 5 January 1492\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatFull(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n return `${parts.dddd}, ${parts.D} ${parts.MMMM} ${parts.y}`;\n}\n\n/**\n * Format date with ordinal (e.g., \"5th of January, Second Age\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatOrdinal(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n let result = _loc('CALENDARIA.Format.OrdinalDate', { day: parts.Do, month: parts.MMMM });\n if (parts.era) result += `, ${parts.era}`;\n return result;\n}\n\n/**\n * Format date as fantasy (e.g., \"5th of January, 1492 Second Age\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatFantasy(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n let result = `${parts.Do} of ${parts.MMMM}, ${parts.y}`;\n if (parts.era) result += ` ${parts.era}`;\n return result;\n}\n\n/**\n * Format time as 24h (e.g., \"14:30\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatTime(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n return `${parts.HH}:${parts.mm}`;\n}\n\n/**\n * Format time as 12h (e.g., \"2:30 PM\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatTime12(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n return `${parts.h}:${parts.mm} ${parts.A}`;\n}\n\n/**\n * Format as datetime 24h (e.g., \"5 January 1492, 14:30\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatDateTime(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n return `${parts.D} ${parts.MMMM} ${parts.y}, ${parts.HH}:${parts.mm}`;\n}\n\n/**\n * Format as datetime 12h (e.g., \"5 January 1492, 2:30 PM\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatDateTime12(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n return `${parts.D} ${parts.MMMM} ${parts.y}, ${parts.h}:${parts.mm} ${parts.A}`;\n}\n\n/**\n * Format time to approximate value (e.g., \"Dawn\", \"Noon\", \"Night\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @param {object} [zone] - Optional climate zone for latitude-based daylight\n * @returns {string} - Formatted string\n */\nexport function formatApproximateTime(calendar, components, zone = null) {\n const { hour = 0 } = components;\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const sunriseHour = calendar?.sunrise?.(components, zone) ?? hoursPerDay * 0.25;\n const sunsetHour = calendar?.sunset?.(components, zone) ?? hoursPerDay * 0.75;\n const daylightHours = sunsetHour - sunriseHour;\n const dayProgress = (hour - sunriseHour) / daylightHours;\n const nightProgress = hour >= sunsetHour ? (hour - sunsetHour) / (hoursPerDay - daylightHours) : hour < sunriseHour ? (hour + hoursPerDay - sunsetHour) / (hoursPerDay - daylightHours) : -1;\n let formatter;\n if (nightProgress > 0.96 && dayProgress < 0.04) formatter = 'Sunrise';\n else if (dayProgress > 0.96 && nightProgress < 0.04) formatter = 'Sunset';\n else if (dayProgress > 0.45 && dayProgress < 0.55) formatter = 'Noon';\n else if (nightProgress > 0.45 && nightProgress < 0.55) formatter = 'Midnight';\n else if (nightProgress > 0.84 && dayProgress < 0) formatter = 'Dawn';\n else if (dayProgress > 1 && nightProgress < 0.16) formatter = 'Dusk';\n else if (dayProgress > 0 && dayProgress < 0.5) formatter = 'Morning';\n else if (dayProgress >= 0.5 && dayProgress <= 0.85) formatter = 'Afternoon';\n else if (dayProgress > 0.85 && nightProgress < 0) formatter = 'Evening';\n else formatter = 'Night';\n const COMMON_OVERRIDES = { Midnight: 'CALENDARIA.Common.Midnight', Night: 'CALENDARIA.Common.Night' };\n return _loc(COMMON_OVERRIDES[formatter] ?? `CALENDARIA.Format.ApproxTime.${formatter}`);\n}\n\n/**\n * Format date to approximate value based on season (e.g., \"Early Spring\", \"Mid-Winter\").\n * @param {object} calendar - The calendar data\n * @param {object} components - Time components\n * @returns {string} - Formatted string\n */\nexport function formatApproximateDate(calendar, components) {\n const parts = dateFormattingParts(calendar, components);\n const rawSeason = calendar?.getCurrentSeason?.(components);\n if (!rawSeason) return parts.MMMM;\n const aliasZone = WeatherManager?.getActiveZone?.(null, globalThis.game?.scenes?.active);\n const season = WeatherManager?.applySeasonAlias?.(rawSeason, aliasZone) ?? rawSeason;\n const seasonName = _loc(season.name);\n const monthsValues = resolveArray(calendar, 'monthsArray', 'months.values');\n let dayOfYear;\n if (calendar?._calculateDayOfYear) dayOfYear = calendar._calculateDayOfYear(components);\n else {\n dayOfYear = components.dayOfMonth;\n for (let i = 0; i < components.month; i++) dayOfYear += calendar?.getDaysInMonth?.(i, components.year) ?? monthsValues[i]?.days ?? 0;\n }\n let seasonStart = 0;\n let seasonEnd = 365;\n const seasonsArray = resolveArray(calendar, 'seasonsArray', 'seasons.values');\n const seasonIdx = seasonsArray.indexOf(season);\n if (season.monthStart != null && season.monthEnd != null) {\n seasonStart = season.dayStart ?? 0;\n for (let i = 0; i < season.monthStart; i++) seasonStart += monthsValues[i]?.days ?? 0;\n seasonEnd = season.dayEnd ?? (monthsValues[season.monthEnd]?.days ?? 30) - 1;\n for (let i = 0; i < season.monthEnd; i++) seasonEnd += monthsValues[i]?.days ?? 0;\n } else if (seasonIdx >= 0 && calendar?.seasons?.type === 'periodic' && calendar?._calculatePeriodicSeasonBounds) {\n const bounds = calendar._calculatePeriodicSeasonBounds(seasonIdx, calendar.getDaysInYear?.(components.year));\n seasonStart = bounds.dayStart;\n seasonEnd = bounds.dayEnd;\n } else if (season.dayStart !== undefined) {\n seasonStart = normalizeRawDoy(season.dayStart, calendar, components.year);\n seasonEnd = season.dayEnd != null ? normalizeRawDoy(season.dayEnd, calendar, components.year) : 365;\n }\n let seasonLength, seasonPercent;\n if (seasonStart <= seasonEnd) {\n seasonLength = seasonEnd - seasonStart + 1;\n seasonPercent = (dayOfYear - seasonStart) / seasonLength;\n } else {\n const daysInYear = calendar?.getDaysInYear?.(components.year) ?? 365;\n seasonLength = daysInYear - seasonStart + seasonEnd + 1;\n if (dayOfYear >= seasonStart) seasonPercent = (dayOfYear - seasonStart) / seasonLength;\n else seasonPercent = (dayOfYear + daysInYear - seasonStart) / seasonLength;\n }\n let formatter;\n if (seasonPercent <= 0.33) formatter = 'Early';\n else if (seasonPercent >= 0.66) formatter = 'Late';\n else formatter = 'Mid';\n return _loc(`CALENDARIA.Format.ApproxDate.${formatter}`, { season: seasonName });\n}\n\n/**\n * Format a date using a custom format string with tokens.\n * @param {object} calendar - Calendar data\n * @param {object} components - Date components\n * @param {string} formatStr - Format string with tokens\n * @returns {string} - Formatted date string\n */\nexport function formatCustom(calendar, components, formatStr) {\n const parts = dateFormattingParts(calendar, components);\n const cycleNum = getCycleNumber(calendar, components);\n const customContext = {\n moon: getMoonPhaseName(calendar, components),\n era: parts.era,\n eraAbbr: parts.eraAbbr,\n yearInEra: parts.eraYear,\n yearInEraOrdinal: parts.eraYear != null ? ordinal(parts.eraYear) : '',\n season: parts.season,\n seasonAbbr: parts.seasonAbbr,\n namedWeek: parts.namedWeek,\n namedWeekAbbr: parts.namedWeekAbbr,\n ch: getCanonicalHour(calendar, components),\n chAbbr: getCanonicalHourAbbr(calendar, components),\n cycle: cycleNum,\n cycleName: getCycleName(calendar, components) || cycleNum,\n cycleRoman: cycleNum ? toRomanNumeral(cycleNum) : '',\n cycleYear: cycleNum,\n approxTime: formatApproximateTime(calendar, components),\n approxDate: formatApproximateDate(calendar, components),\n meridiemFull: parts.meridiemFull,\n yearName: parts.yearName\n };\n const tokenMap = {\n YYYY: parts.yyyy,\n YY: parts.yy,\n Y: parts.y,\n MMMM: parts.MMMM,\n MMM: parts.MMM,\n MM: parts.MM,\n Mo: parts.Mo,\n M: parts.M,\n EEEEE: parts.EEEEE,\n EEEE: parts.EEEE,\n EEE: parts.EEE,\n EE: parts.EE,\n E: parts.E,\n e: parts.e,\n dddd: parts.dddd,\n ddd: parts.ddd,\n dd: parts.dd,\n d: parts.d,\n Do: parts.Do,\n DDD: parts.DDD,\n DD: parts.DD,\n D: parts.D,\n GGGG: parts.GGGG,\n GGG: parts.GGG,\n GG: parts.GG,\n G: parts.G,\n QQQQ: parts.QQQQ,\n QQQ: parts.QQQ,\n QQ: parts.QQ,\n Q: parts.Q,\n zzzz: parts.zzzz,\n z: parts.z,\n ww: parts.ww,\n w: parts.w,\n W: parts.W,\n HH: parts.HH,\n H: parts.H,\n hh: parts.hh,\n h: parts.h,\n mm: parts.mm,\n m: parts.m,\n ss: parts.ss,\n s: parts.s,\n A: parts.A,\n a: parts.a\n };\n\n /**\n * Resolve a token string to its value.\n * @param {string} token - Format token to resolve\n */\n const resolveToken = (token) => {\n if (token in tokenMap) return tokenMap[token];\n if (token in customContext) return customContext[token];\n return token;\n };\n const TOKEN_REGEX = /\\[([^\\]]+)]|\\{([^}]+)\\}|YYYY|YY|Y|MMMM|MMM|MM|Mo|M|EEEEE|EEEE|EEE|EE|E|dddd|ddd|dd|Do|DDD|DD|D|d|e|GGGG|GGG|GG|G|QQQQ|QQQ|QQ|Q|zzzz|z|ww|w|W|HH|H|hh|h|mm|m|ss|s|A|a/g;\n return formatStr.replace(TOKEN_REGEX, (match, bracketToken, curlyToken) => {\n const customToken = bracketToken ?? curlyToken;\n if (customToken) {\n if (customToken.includes('|')) {\n const [primary, fallback] = customToken.split('|', 2);\n const value = customContext[primary] ?? '';\n return value || String(resolveToken(fallback));\n }\n const eraIndexMatch = customToken.match(/^(era|eraAbbr|yearInEra|yearInEraOrdinal)=(\\d+)$/);\n if (eraIndexMatch) {\n const [, field, idxStr] = eraIndexMatch;\n const era = parts.matchingEras?.[parseInt(idxStr)] || {};\n if (field === 'era') return era.name || '';\n if (field === 'eraAbbr') return era.abbr || '';\n if (field === 'yearInEra') return era.yearInEra != null ? String(era.yearInEra) : '';\n if (field === 'yearInEraOrdinal') return era.yearInEra != null ? ordinal(era.yearInEra) : '';\n return '';\n }\n const cycleIndexMatch = customToken.match(/^(cycle|cycleName|cycleRoman)=(\\d+)$/);\n if (cycleIndexMatch) {\n const [, field, idxStr] = cycleIndexMatch;\n const idx = parseInt(idxStr);\n if (field === 'cycleName') return getCycleName(calendar, components, idx) || String(getCycleNumber(calendar, components, idx));\n if (field === 'cycleRoman') {\n const n = getCycleNumber(calendar, components, idx);\n return n ? toRomanNumeral(n) : '';\n }\n if (field === 'cycle') return String(getCycleNumber(calendar, components, idx));\n return '';\n }\n if (customToken.startsWith('moon=')) {\n const paramPart = customToken.slice(5);\n let moonSelector;\n if ((paramPart.startsWith(\"'\") && paramPart.endsWith(\"'\")) || (paramPart.startsWith('\"') && paramPart.endsWith('\"'))) moonSelector = paramPart.slice(1, -1);\n else moonSelector = /^\\d+$/.test(paramPart) ? parseInt(paramPart, 10) : paramPart;\n return getMoonPhaseName(calendar, components, moonSelector);\n }\n if (customToken.startsWith('moonIcon')) {\n const paramPart = customToken.slice(9);\n let moonSelector;\n if (paramPart) {\n if ((paramPart.startsWith(\"'\") && paramPart.endsWith(\"'\")) || (paramPart.startsWith('\"') && paramPart.endsWith('\"'))) moonSelector = paramPart.slice(1, -1);\n else moonSelector = /^\\d+$/.test(paramPart) ? parseInt(paramPart, 10) : paramPart;\n }\n return getMoonPhaseIcon(calendar, components, moonSelector);\n }\n return customContext[customToken] ?? customToken;\n }\n return tokenMap[match] ?? match;\n });\n}\n\n/**\n * Validate a custom format string and generate a preview.\n * @param {string} formatStr - The format string to validate\n * @param {object} [calendar] - Optional calendar data for preview\n * @param {object} [components] - Optional date components for preview\n * @returns {{valid: boolean, preview: string, error: string}} Validation result\n */\nexport function validateFormatString(formatStr, calendar, components) {\n if (!formatStr || typeof formatStr !== 'string') return { valid: true };\n const openBrackets = (formatStr.match(/\\[/g) || []).length;\n const closeBrackets = (formatStr.match(/]/g) || []).length;\n if (openBrackets !== closeBrackets) return { valid: false, error: 'CALENDARIA.Format.Error.UnclosedBracket' };\n if (/\\[]/.test(formatStr)) return { valid: false, error: 'CALENDARIA.Format.Error.EmptyBracket' };\n const openCurly = (formatStr.match(/\\{/g) || []).length;\n const closeCurly = (formatStr.match(/}/g) || []).length;\n if (openCurly !== closeCurly) return { valid: false, error: 'CALENDARIA.Format.Error.UnclosedBracket' };\n if (/\\{}/.test(formatStr)) return { valid: false, error: 'CALENDARIA.Format.Error.EmptyBracket' };\n if (calendar && components) {\n try {\n const preview = formatCustom(calendar, components, formatStr);\n const cleanPreview = stripMoonIconMarkers(preview);\n return { valid: true, preview: cleanPreview };\n } catch (e) {\n ATLAS.log(1, e);\n return { valid: false, error: 'CALENDARIA.Format.Error.Invalid' };\n }\n }\n return { valid: true };\n}\n\n/**\n * Resolve a moon selector (index, name string, or undefined) to a numeric index.\n * @param {object[]} moons - Array of moon definitions\n * @param {string|number} [moonSelector] - Moon name or index\n * @returns {number} Resolved moon index\n */\nfunction resolveMoonIndex(moons, moonSelector) {\n if (moonSelector === undefined || moonSelector === null || moonSelector === '') return 0;\n if (typeof moonSelector === 'number') return moonSelector;\n if (/^\\d+$/.test(moonSelector)) return parseInt(moonSelector, 10);\n const selectorLower = String(moonSelector).toLowerCase();\n const foundIndex = moons.findIndex((m) => {\n const moonName = _loc(m.name).toLowerCase();\n const rawName = (m.name || '').toLowerCase();\n return moonName === selectorLower || rawName === selectorLower;\n });\n return foundIndex >= 0 ? foundIndex : 0;\n}\n\n/**\n * Get fallback phase position for a moon when calendar.getMoonPhase is unavailable.\n * @param {object} moon - Moon definition\n * @param {object} components - Date components {year, month, dayOfMonth}\n * @returns {number} Phase position 0-1\n */\nfunction getFallbackPhasePosition(moon, components) {\n const { year, month, dayOfMonth } = components;\n const cycleLength = moon.cycleLength || 29;\n const refDate = moon.referenceDate || { year: 0, month: 0, dayOfMonth: 0 };\n const refDays = refDate.year * 365 + refDate.month * 30 + (refDate.dayOfMonth ?? 0);\n const currentDays = year * 365 + month * 30 + dayOfMonth;\n if (moon.phaseMode === 'randomized') {\n return resolveRandomizedPhase(moon, currentDays, { year, month, dayOfMonth });\n }\n const daysSinceRef = currentDays - refDays;\n return (((daysSinceRef % cycleLength) + cycleLength) % cycleLength) / cycleLength;\n}\n\n/**\n * Get moon phase name for the given date.\n * @param {object} calendar - Calendar data\n * @param {object} components - Date components\n * @param {string|number} [moonSelector] - Moon name or index (default: first moon)\n * @returns {string} Moon phase name\n */\nfunction getMoonPhaseName(calendar, components, moonSelector) {\n const moons = resolveArray(calendar, 'moonsArray', 'moons');\n if (!moons.length) return '';\n let moonIndex = resolveMoonIndex(moons, moonSelector);\n if (!isMoonVisible(moons[moonIndex])) return '';\n if (calendar.getMoonPhase && calendar.componentsToTime) {\n const yearZero = calendar.years?.yearZero ?? 0;\n const internalComponents = { ...components, year: components.year - yearZero };\n const worldTime = calendar.componentsToTime(internalComponents);\n const phaseData = calendar.getMoonPhase(moonIndex, worldTime);\n return phaseData?.subPhaseName || phaseData?.name || '';\n }\n const moon = moons[moonIndex];\n const phasesArr = Object.values(moon.phases ?? {});\n if (!phasesArr.length) return '';\n const cyclePosition = getFallbackPhasePosition(moon, components);\n const phaseIndex = Math.floor(cyclePosition * phasesArr.length);\n const phase = phasesArr[phaseIndex];\n return phase ? _loc(phase.name) : '';\n}\n\n/**\n * Get moon phase icon marker for the given date.\n * @param {object} calendar - Calendar data\n * @param {object} components - Date components\n * @param {string|number} [moonSelector] - Moon name or index (default: first moon)\n * @returns {string} Moon phase icon marker (use renderMoonIcons to convert to HTML)\n */\nfunction getMoonPhaseIcon(calendar, components, moonSelector) {\n const moons = resolveArray(calendar, 'moonsArray', 'moons');\n if (!moons.length) return '';\n const moonIndex = resolveMoonIndex(moons, moonSelector);\n const moon = moons[moonIndex];\n if (!moon || !isMoonVisible(moon)) return '';\n let phase;\n if (calendar.getMoonPhase && calendar.componentsToTime) {\n const yearZero = calendar.years?.yearZero ?? 0;\n const internalComponents = {\n ...components,\n year: components.year - yearZero\n };\n const worldTime = calendar.componentsToTime(internalComponents);\n const phaseData = calendar.getMoonPhase(moonIndex, worldTime);\n if (phaseData) phase = { name: phaseData.name, icon: phaseData.icon };\n }\n if (!phase) {\n const phasesArr = Object.values(moon.phases ?? {});\n if (phasesArr.length) {\n const cyclePosition = getFallbackPhasePosition(moon, components);\n const phaseIndex = Math.floor(cyclePosition * phasesArr.length);\n phase = phasesArr[phaseIndex];\n }\n }\n if (!phase?.icon) return '';\n const phaseName = _loc(phase.name);\n const moonName = _loc(moon.name);\n const moonColor = moon.color || '';\n return `${'__MOONICON:'}${phase.icon}|${phaseName}|${moonName}: ${phaseName}|${moonColor}${'__'}`;\n}\n\n/**\n * Check if a formatted string contains moon icon markers.\n * @param {string} str - Formatted string\n * @returns {boolean} True if string contains moon icon markers\n */\nexport function hasMoonIconMarkers(str) {\n return str?.includes('__MOONICON:') ?? false;\n}\n\n/**\n * Convert moon icon markers in a string to HTML img elements.\n * @param {string} str - String with moon icon markers\n * @returns {string} String with markers replaced by elements\n */\nexport function renderMoonIcons(str) {\n if (!str || !hasMoonIconMarkers(str)) return str;\n if (!canViewMoons()) return stripMoonIconMarkers(str);\n return str.replace(/__MOONICON:([^|]*)\\|([^|]*)\\|([^|]*)\\|(.*?)__/g, (_, src, alt, tooltip, color) => {\n if (color) return `\"${alt}\"`;\n return `\"${alt}\"`;\n });\n}\n\n/**\n * Strip moon icon markers from a string (for text-only contexts).\n * @param {string} str - String with moon icon markers\n * @returns {string} String with markers removed\n */\nexport function stripMoonIconMarkers(str) {\n if (!str || !hasMoonIconMarkers(str)) return str;\n return str.replace(/__MOONICON:([^|]*)\\|([^|]*)\\|([^|]*)\\|(.*?)__/g, '');\n}\n\n/**\n * Get canonical hour name for the given time.\n * @param {object} calendar - Calendar data\n * @param {object} components - Time components\n * @returns {string} Canonical hour name\n */\nfunction getCanonicalHour(calendar, components) {\n const { hour = 0 } = components;\n const hours = resolveArray(calendar, 'canonicalHoursArray', 'canonicalHours');\n if (!hours.length) return '';\n for (const ch of hours) if (ch.startHour <= ch.endHour ? hour >= ch.startHour && hour < ch.endHour : hour >= ch.startHour || hour < ch.endHour) return _loc(ch.name);\n return '';\n}\n\n/**\n * Get canonical hour abbreviation for the given time.\n * @param {object} calendar - Calendar data\n * @param {object} components - Time components\n * @returns {string} Canonical hour abbreviation\n */\nfunction getCanonicalHourAbbr(calendar, components) {\n const { hour = 0 } = components;\n const hours = resolveArray(calendar, 'canonicalHoursArray', 'canonicalHours');\n if (!hours.length) return '';\n for (const ch of hours) {\n let matches = false;\n if (ch.startHour <= ch.endHour) matches = hour >= ch.startHour && hour < ch.endHour;\n else matches = hour >= ch.startHour || hour < ch.endHour;\n if (matches) return ch.abbreviation ? _loc(ch.abbreviation) : _loc(ch.name).slice(0, 3);\n }\n return '';\n}\n\n/**\n * Get cycle entry name for the given date.\n * @param {object} calendar - Calendar data\n * @param {object} components - Date components\n * @param {number} cycleIndex - Index of the cycle to look up\n * @returns {string} Cycle entry name\n */\nfunction getCycleName(calendar, components, cycleIndex = 0) {\n if (calendar?.getCycleEntry) {\n const entry = calendar.getCycleEntry(cycleIndex, components);\n return entry?.name ? _loc(entry.name) : '';\n }\n const cycles = resolveArray(calendar, 'cyclesArray', 'cycles');\n if (!cycles.length || !cycles[cycleIndex]) return '';\n const cycle = cycles[cycleIndex];\n const stages = Object.values(cycle?.stages ?? {});\n if (!stages.length) return '';\n const yearZero = calendar?.years?.yearZero ?? 0;\n const displayYear = components.year + yearZero;\n const adjustedValue = displayYear + (cycle.offset || 0);\n let entryIndex = adjustedValue % stages.length;\n if (entryIndex < 0) entryIndex += stages.length;\n return _loc(stages[entryIndex]?.name ?? '');\n}\n\n/**\n * Get 1-indexed cycle number for the given date.\n * @param {object} calendar - Calendar data\n * @param {object} components - Date components\n * @param {number} cycleIndex - Index of the cycle to look up\n * @returns {number|string} Cycle number (1-indexed)\n */\nfunction getCycleNumber(calendar, components, cycleIndex = 0) {\n if (calendar?.getCurrentCycleNumber) return calendar.getCurrentCycleNumber(cycleIndex, components);\n const cycles = resolveArray(calendar, 'cyclesArray', 'cycles');\n if (!cycles.length || !cycles[cycleIndex]) return '';\n const cycle = cycles[cycleIndex];\n if (!cycle?.length) return '';\n const yearZero = calendar?.years?.yearZero ?? 0;\n const displayYear = components.year + yearZero;\n const adjustedValue = displayYear + (cycle.offset || 0);\n const cycleNum = Math.floor(adjustedValue / cycle.length) + 1;\n return Math.max(1, cycleNum);\n}\n\n/**\n * Format milliseconds into a display string based on format setting.\n * @param {number} ms - Milliseconds to format\n * @param {string} format - Format string (e.g., 'HH:mm:ss.SSS', 'mm:ss')\n * @returns {string} Formatted duration string\n */\nexport function formatDuration(ms, format = 'HH:mm:ss.SSS') {\n const totalSeconds = Math.floor(ms / 1000);\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const seconds = totalSeconds % 60;\n const milliseconds = ms % 1000;\n const pad = (n, len = 2) => String(n).padStart(len, '0');\n switch (format) {\n case 'HH:mm:ss.SSS':\n return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}`;\n case 'HH:mm:ss':\n return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;\n case 'mm:ss.SSS':\n return `${pad(minutes + hours * 60)}:${pad(seconds)}.${pad(milliseconds, 3)}`;\n case 'mm:ss':\n return `${pad(minutes + hours * 60)}:${pad(seconds)}`;\n case 'ss.SSS':\n return `${pad(seconds + minutes * 60 + hours * 3600)}:${pad(milliseconds, 3)}`;\n case 'ss':\n return `${pad(seconds + minutes * 60 + hours * 3600)}`;\n default:\n return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}`;\n }\n}\n\n/**\n * Format game time seconds into display string based on calendar settings.\n * @param {number} totalSecs - Total game time seconds\n * @param {object} calendar - Active calendar with time unit definitions\n * @param {string} format - Format string (e.g., 'HH:mm:ss', 'mm:ss')\n * @returns {string} Formatted duration string\n */\nexport function formatGameDuration(totalSecs, calendar, format = 'HH:mm:ss') {\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const secondsPerMinute = calendar?.days?.secondsPerMinute ?? 60;\n const secsPerMin = secondsPerMinute;\n const secsPerHour = minutesPerHour * secsPerMin;\n const secsPerDay = hoursPerDay * secsPerHour;\n const days = Math.floor(totalSecs / secsPerDay);\n const hours = Math.floor((totalSecs % secsPerDay) / secsPerHour);\n const minutes = Math.floor((totalSecs % secsPerHour) / secsPerMin);\n const secs = Math.floor(totalSecs % secsPerMin);\n const pad = (n, len = 2) => String(n).padStart(len, '0');\n const showMs = format.includes('.SSS');\n const msSuffix = showMs ? '.000' : '';\n switch (format) {\n case 'HH:mm:ss.SSS':\n case 'HH:mm:ss':\n if (days > 0) return `${days}d ${pad(hours)}:${pad(minutes)}:${pad(secs)}${msSuffix}`;\n return `${pad(hours)}:${pad(minutes)}:${pad(secs)}${msSuffix}`;\n case 'mm:ss.SSS':\n case 'mm:ss': {\n const totalMins = minutes + hours * minutesPerHour + days * hoursPerDay * minutesPerHour;\n return `${pad(totalMins)}:${pad(secs)}${msSuffix}`;\n }\n case 'ss.SSS':\n case 'ss': {\n const totalSecsCalc = secs + minutes * secsPerMin + hours * secsPerHour + days * secsPerDay;\n return `${pad(totalSecsCalc)}${msSuffix}`;\n }\n default:\n if (days > 0) return `${days}d ${pad(hours)}:${pad(minutes)}:${pad(secs)}${msSuffix}`;\n return `${pad(hours)}:${pad(minutes)}:${pad(secs)}${msSuffix}`;\n }\n}\n\n/**\n * Map of preset names to formatter functions.\n */\nexport const PRESET_FORMATTERS = {\n off: () => '',\n approxTime: formatApproximateTime,\n approxDate: formatApproximateDate\n};\n\n/**\n * Default format presets for reference.\n * @type {Object}\n */\nexport const DEFAULT_FORMAT_PRESETS = {\n approxDate: '[approxDate]',\n approxDateTime: '[approxDate], [approxTime]',\n approxTime: '[approxTime]',\n dateShort: 'D MMM',\n dateMedium: 'D MMMM',\n dateLong: 'D MMMM, Y',\n dateFull: 'EEEE, D MMMM Y',\n dateUS: 'MMMM D, Y',\n dateUSFull: 'EEEE, MMMM D, Y',\n dateISO: 'YYYY-MM-DD',\n dateNumericUS: 'MM/DD/YYYY',\n dateNumericEU: 'DD/MM/YYYY',\n ordinal: 'Do of MMMM',\n ordinalLong: 'Do of MMMM, Y',\n ordinalEra: 'Do of MMMM, Y GGGG',\n ordinalFull: 'EEEE, Do of MMMM, Y GGGG',\n seasonDate: 'QQQQ, Do of MMMM',\n weekHeader: '[Week] W [of] MMMM, Y',\n yearOnly: 'Y',\n yearEra: 'Y G',\n time12: 'h:mm A',\n time12Sec: 'h:mm:ss A',\n time24: 'HH:mm',\n time24Sec: 'HH:mm:ss',\n datetimeShort12: 'D MMM, h:mm A',\n datetimeShort24: 'D MMM, HH:mm',\n datetime12: 'D MMMM Y, h:mm A',\n datetime24: 'D MMMM Y, HH:mm',\n stopwatchRealtimeFull: 'HH:mm:ss.SSS',\n stopwatchRealtimeNoMs: 'HH:mm:ss',\n stopwatchRealtimeMinSec: 'mm:ss.SSS',\n stopwatchRealtimeSecOnly: 'ss.SSS',\n stopwatchGametimeFull: 'HH:mm:ss',\n stopwatchGametimeMinSec: 'mm:ss',\n stopwatchGametimeSecOnly: 'ss'\n};\n\n/** Map display locations to the calendar.dateFormats slot they read from. */\nexport const LOCATION_FORMAT_KEYS = {\n hudDate: 'long',\n hudTime: 'time',\n timekeeperDate: 'long',\n timekeeperTime: 'time',\n miniCalHeader: 'long',\n miniCalTime: 'time',\n bigCalHeader: 'full',\n bigCalWeekHeader: 'weekHeader',\n bigCalYearHeader: 'yearHeader',\n bigCalYearLabel: 'yearLabel',\n chatTimestamp: 'short',\n noteViewerDate: 'long',\n cinematicDate: 'long',\n sundialTime: 'time'\n};\n\n/**\n * Default format presets for each location (fallback when not in settings).\n * @type {Object}\n */\nexport const LOCATION_DEFAULTS = {\n hudDate: 'ordinal',\n hudTime: 'time24',\n timekeeperDate: 'dateLong',\n timekeeperTime: 'time24',\n microCalHeader: 'approxDateTime',\n miniCalHeader: 'dateLong',\n miniCalTime: 'time24',\n bigCalHeader: 'dateFull',\n bigCalWeekHeader: 'weekHeader',\n bigCalYearHeader: 'yearOnly',\n bigCalYearLabel: 'yearEra',\n chatTimestamp: 'dateShort',\n stopwatchRealtime: 'stopwatchRealtimeFull',\n stopwatchGametime: 'stopwatchGametimeFull',\n noteViewerDate: 'dateLong',\n cinematicDate: 'dateLong',\n cinematicTime: 'time24'\n};\n\n/** Framework-initial DISPLAY_FORMATS object. */\nexport const FRAMEWORK_INITIAL_DISPLAY_FORMATS = {\n hudDate: { gm: 'ordinal', player: 'ordinal' },\n hudTime: { gm: 'time24', player: 'time24' },\n microCalHeader: { gm: 'approxDateTime', player: 'approxDateTime' },\n miniCalHeader: { gm: 'MMMM GGGG', player: 'MMMM GGGG' },\n miniCalTime: { gm: 'time24', player: 'time24' },\n bigCalHeader: { gm: 'MMMM GGGG', player: 'MMMM GGGG' },\n chatTimestamp: { gm: 'dateShort', player: 'dateShort' },\n stopwatchRealtime: { gm: 'stopwatchRealtimeFull', player: 'stopwatchRealtimeFull' },\n stopwatchGametime: { gm: 'stopwatchGametimeFull', player: 'stopwatchGametimeFull' },\n noteViewerDate: { gm: 'dateLong', player: 'dateLong' },\n cinematicDate: { gm: 'dateLong', player: 'dateLong' },\n cinematicTime: { gm: 'time24', player: 'time24' }\n};\n\n/**\n * Build a DISPLAY_FORMATS object that points each calendar-aware location.\n * @param {object} calendar - Active calendar (used to verify slot presence)\n * @returns {object} DISPLAY_FORMATS-shaped object\n */\nexport function buildDisplayFormatsFromCalendar(calendar) {\n const result = foundry.utils.deepClone(FRAMEWORK_INITIAL_DISPLAY_FORMATS);\n const dateFormats = calendar?.dateFormats;\n if (!dateFormats) return result;\n for (const [locationId, slot] of Object.entries(LOCATION_FORMAT_KEYS)) if (dateFormats[slot]) result[locationId] = { gm: 'calendarDefault', player: 'calendarDefault' };\n return result;\n}\n\n/**\n * Get the format string/preset for a specific display location.\n * @param {string} locationId - Location identifier\n * @returns {string} - Format string or preset name\n */\nexport function getDisplayFormat(locationId) {\n const MODULE_ID = 'calendaria';\n const SETTINGS_KEY = 'displayFormats';\n const defaultFormat = LOCATION_DEFAULTS[locationId] || 'dateLong';\n try {\n const formats = game.settings.get(MODULE_ID, SETTINGS_KEY);\n const locationFormats = formats?.[locationId];\n if (!locationFormats) return defaultFormat;\n const isGM = game.user.isGM;\n return isGM ? locationFormats.gm || defaultFormat : locationFormats.player || defaultFormat;\n } catch {\n return defaultFormat;\n }\n}\n\n/**\n * Resolve \"calendarDefault\" preset to the actual format string from calendar data.\n * @param {object} calendar - Calendar data with dateFormats\n * @param {string} locationId - Location identifier to map to format key\n * @returns {string} - Resolved format string or fallback preset name\n */\nfunction resolveCalendarDefault(calendar, locationId) {\n const slot = LOCATION_FORMAT_KEYS[locationId];\n const calendarFormat = slot ? calendar?.dateFormats?.[slot] : null;\n if (calendarFormat) return calendarFormat;\n return LOCATION_DEFAULTS[locationId] || 'dateLong';\n}\n\n/**\n * Resolve a preset name or format string to an actual format string.\n * @param {string} formatSetting - Preset name or custom format string\n * @returns {string} - Format string\n */\nexport function resolveFormatString(formatSetting) {\n return DEFAULT_FORMAT_PRESETS[formatSetting] || formatSetting;\n}\n\n/**\n * Format date/time for a specific display location.\n * @param {object} calendar - Calendar data\n * @param {object} components - Date components\n * @param {string} locationId - Location identifier\n * @returns {string} - Formatted date/time string\n */\nexport function formatForLocation(calendar, components, locationId) {\n let formatSetting = getDisplayFormat(locationId);\n if (formatSetting === 'calendarDefault') formatSetting = resolveCalendarDefault(calendar, locationId);\n if (PRESET_FORMATTERS[formatSetting]) return PRESET_FORMATTERS[formatSetting](calendar, components);\n const formatStr = resolveFormatString(formatSetting);\n return formatCustom(calendar, components, formatStr);\n}\n\n/**\n * Get all display location definitions with labels.\n * @returns {Array<{id: string, label: string, category: string}>} Array of location definitions\n */\nexport function getDisplayLocationDefinitions() {\n return [\n { id: 'hudDate', label: 'CALENDARIA.Common.DateDisplay', category: 'hud' },\n { id: 'hudTime', label: 'CALENDARIA.Common.TimeDisplay', category: 'hud' },\n { id: 'microCalHeader', label: 'CALENDARIA.Format.Location.MicroCalHeader', category: 'miniCal' },\n { id: 'miniCalHeader', label: 'CALENDARIA.Format.Location.MiniCalHeader', category: 'miniCal' },\n { id: 'miniCalTime', label: 'CALENDARIA.Common.TimeDisplay', category: 'miniCal' },\n { id: 'bigCalHeader', label: 'CALENDARIA.Format.Location.BigCalHeader', category: 'bigcal' },\n { id: 'bigCalWeekHeader', label: 'CALENDARIA.Common.WeekViewHeader', category: 'bigcal' },\n { id: 'bigCalYearHeader', label: 'CALENDARIA.Common.YearViewHeader', category: 'bigcal' },\n { id: 'bigCalYearLabel', label: 'CALENDARIA.Format.Location.BigCalYearLabel', category: 'bigcal' },\n { id: 'chatTimestamp', label: 'CALENDARIA.Format.Location.ChatTimestamp', category: 'chat' },\n { id: 'noteViewerDate', label: 'CALENDARIA.Format.Location.NoteViewerDate', category: 'notes' },\n { id: 'cinematicDate', label: 'CALENDARIA.Format.Location.CinematicDate', category: 'cinematics' }\n ];\n}\n\n/**\n * Get relative time description between two dates.\n * @param {object} targetDate - Target date { year, month, dayOfMonth }\n * @param {object} currentDate - Current date { year, month, dayOfMonth }\n * @param {boolean} simple - Whether to return a simplified numeric string\n * @returns {string} Relative time string (e.g., \"3 days ago\", \"in 2 weeks\")\n */\nexport function timeSince(targetDate, currentDate, simple = false) {\n const daysPerMonth = 30;\n const daysPerYear = 365;\n const targetDays = targetDate.year * daysPerYear + targetDate.month * daysPerMonth + targetDate.dayOfMonth;\n const currentDays = currentDate.year * daysPerYear + currentDate.month * daysPerMonth + currentDate.dayOfMonth;\n const diff = targetDays - currentDays;\n if (diff === 0) return simple ? '0' : _loc('CALENDARIA.Format.Today');\n if (diff === 1) return simple ? '1' : _loc('CALENDARIA.Format.Tomorrow');\n if (diff === -1) return simple ? '1' : _loc('CALENDARIA.Format.Yesterday');\n const absDiff = Math.abs(diff);\n const isFuture = diff > 0;\n const years = Math.floor(absDiff / daysPerYear);\n const months = Math.floor((absDiff % daysPerYear) / daysPerMonth);\n const weeks = Math.floor(absDiff / 7);\n const days = absDiff;\n let unit, count;\n if (years >= 1) {\n unit = years === 1 ? _loc('CALENDARIA.Common.UnitYear') : _loc('CALENDARIA.Common.UnitYears');\n count = years;\n } else if (months >= 1) {\n unit = months === 1 ? _loc('CALENDARIA.Common.UnitMonth') : _loc('CALENDARIA.Common.UnitMonths');\n count = months;\n } else if (weeks >= 1) {\n unit = weeks === 1 ? _loc('CALENDARIA.Common.UnitWeek') : _loc('CALENDARIA.Common.UnitWeeks');\n count = weeks;\n } else {\n unit = days === 1 ? _loc('CALENDARIA.Common.UnitDay') : _loc('CALENDARIA.Common.UnitDays');\n count = days;\n }\n if (simple) return String(count);\n if (isFuture) return _loc('CALENDARIA.Format.InFuture', { count, unit });\n else return _loc('CALENDARIA.Format.InPast', { count, unit });\n}\n\n/**\n * Get all available tokens with descriptions.\n * @returns {Array<{token: string, descriptionKey: string, type: string}>} Array of token definitions\n */\nexport function getAvailableTokens() {\n return [\n { token: 'Y', descriptionKey: 'CALENDARIA.Format.Token.Y', type: 'standard' },\n { token: 'YY', descriptionKey: 'CALENDARIA.Format.Token.YY', type: 'standard' },\n { token: 'YYYY', descriptionKey: 'CALENDARIA.Format.Token.YYYY', type: 'standard' },\n { token: '[yearName]', descriptionKey: 'CALENDARIA.Format.Token.yearName', type: 'custom' },\n { token: 'M', descriptionKey: 'CALENDARIA.Format.Token.M', type: 'standard' },\n { token: 'MM', descriptionKey: 'CALENDARIA.Format.Token.MM', type: 'standard' },\n { token: 'MMM', descriptionKey: 'CALENDARIA.Format.Token.MMM', type: 'standard' },\n { token: 'MMMM', descriptionKey: 'CALENDARIA.Format.Token.MMMM', type: 'standard' },\n { token: 'Mo', descriptionKey: 'CALENDARIA.Format.Token.Mo', type: 'standard' },\n { token: 'D', descriptionKey: 'CALENDARIA.Format.Token.D', type: 'standard' },\n { token: 'DD', descriptionKey: 'CALENDARIA.Format.Token.DD', type: 'standard' },\n { token: 'Do', descriptionKey: 'CALENDARIA.Format.Token.Do', type: 'standard' },\n { token: 'DDD', descriptionKey: 'CALENDARIA.Format.Token.DDD', type: 'standard' },\n { token: 'EEEE', descriptionKey: 'CALENDARIA.Format.Token.EEEE', type: 'standard' },\n { token: 'EEE', descriptionKey: 'CALENDARIA.Common.FormatWeekdayShort', type: 'standard' },\n { token: 'EE', descriptionKey: 'CALENDARIA.Common.FormatWeekdayShort', type: 'standard' },\n { token: 'E', descriptionKey: 'CALENDARIA.Common.FormatWeekdayShort', type: 'standard' },\n { token: 'EEEEE', descriptionKey: 'CALENDARIA.Format.Token.EEEEE', type: 'standard' },\n { token: 'e', descriptionKey: 'CALENDARIA.Format.Token.e', type: 'standard' },\n { token: 'w', descriptionKey: 'CALENDARIA.Format.Token.w', type: 'standard' },\n { token: 'ww', descriptionKey: 'CALENDARIA.Format.Token.ww', type: 'standard' },\n { token: 'W', descriptionKey: 'CALENDARIA.Format.Token.W', type: 'standard' },\n { token: '[namedWeek]', descriptionKey: 'CALENDARIA.Format.Token.namedWeek', type: 'custom' },\n { token: '[namedWeekAbbr]', descriptionKey: 'CALENDARIA.Format.Token.namedWeekAbbr', type: 'custom' },\n { token: 'GGGG', descriptionKey: 'CALENDARIA.Format.Token.GGGG', type: 'standard' },\n { token: 'GGG', descriptionKey: 'CALENDARIA.Common.FormatEraShort', type: 'standard' },\n { token: 'GG', descriptionKey: 'CALENDARIA.Common.FormatEraShort', type: 'standard' },\n { token: 'G', descriptionKey: 'CALENDARIA.Common.FormatEraShort', type: 'standard' },\n { token: '[yearInEra]', descriptionKey: 'CALENDARIA.Format.Token.yearInEra', type: 'custom' },\n { token: '[yearInEraOrdinal]', descriptionKey: 'CALENDARIA.Format.Token.yearInEraOrdinal', type: 'custom' },\n { token: '[era=N]', descriptionKey: 'CALENDARIA.Format.Token.eraIndex', type: 'custom' },\n { token: '[eraAbbr=N]', descriptionKey: 'CALENDARIA.Format.Token.eraAbbrIndex', type: 'custom' },\n { token: '[yearInEra=N]', descriptionKey: 'CALENDARIA.Format.Token.yearInEraIndex', type: 'custom' },\n { token: '[yearInEraOrdinal=N]', descriptionKey: 'CALENDARIA.Format.Token.yearInEraOrdinalIndex', type: 'custom' },\n { token: 'QQQQ', descriptionKey: 'CALENDARIA.Format.Token.QQQQ', type: 'standard' },\n { token: 'QQQ', descriptionKey: 'CALENDARIA.Format.Token.QQQ', type: 'standard' },\n { token: 'QQ', descriptionKey: 'CALENDARIA.Format.Token.QQ', type: 'standard' },\n { token: 'Q', descriptionKey: 'CALENDARIA.Format.Token.Q', type: 'standard' },\n { token: 'zzzz', descriptionKey: 'CALENDARIA.Format.Token.zzzz', type: 'standard' },\n { token: 'z', descriptionKey: 'CALENDARIA.Format.Token.z', type: 'standard' },\n { token: 'H', descriptionKey: 'CALENDARIA.Format.Token.H', type: 'standard' },\n { token: 'HH', descriptionKey: 'CALENDARIA.Format.Token.HH', type: 'standard' },\n { token: 'h', descriptionKey: 'CALENDARIA.Format.Token.h', type: 'standard' },\n { token: 'hh', descriptionKey: 'CALENDARIA.Format.Token.hh', type: 'standard' },\n { token: 'm', descriptionKey: 'CALENDARIA.Format.Token.m', type: 'standard' },\n { token: 'mm', descriptionKey: 'CALENDARIA.Format.Token.mm', type: 'standard' },\n { token: 's', descriptionKey: 'CALENDARIA.Format.Token.s', type: 'standard' },\n { token: 'ss', descriptionKey: 'CALENDARIA.Format.Token.ss', type: 'standard' },\n { token: 'A', descriptionKey: 'CALENDARIA.Format.Token.A', type: 'standard' },\n { token: 'a', descriptionKey: 'CALENDARIA.Format.Token.a', type: 'standard' },\n { token: '[meridiemFull]', descriptionKey: 'CALENDARIA.Format.Token.meridiemFull', type: 'custom' },\n { token: '[moon]', descriptionKey: 'CALENDARIA.Format.Token.moon', type: 'custom' },\n { token: '[moonIcon]', descriptionKey: 'CALENDARIA.Format.Token.moonIcon', type: 'custom' },\n { token: '[ch]', descriptionKey: 'CALENDARIA.Format.Token.ch', type: 'custom' },\n { token: '[chAbbr]', descriptionKey: 'CALENDARIA.Format.Token.chAbbr', type: 'custom' },\n { token: '[cycle]', descriptionKey: 'CALENDARIA.Format.Token.cycle', type: 'custom' },\n { token: '[cycle=N]', descriptionKey: 'CALENDARIA.Format.Token.cycleN', type: 'custom' },\n { token: '[cycleName]', descriptionKey: 'CALENDARIA.Format.Token.cycleName', type: 'custom' },\n { token: '[cycleName=N]', descriptionKey: 'CALENDARIA.Format.Token.cycleNameN', type: 'custom' },\n { token: '[cycleRoman]', descriptionKey: 'CALENDARIA.Format.Token.cycleRoman', type: 'custom' },\n { token: '[cycleRoman=N]', descriptionKey: 'CALENDARIA.Format.Token.cycleRomanN', type: 'custom' },\n { token: '[approxTime]', descriptionKey: 'CALENDARIA.Format.Token.approxTime', type: 'custom' },\n { token: '[approxDate]', descriptionKey: 'CALENDARIA.Format.Token.approxDate', type: 'custom' }\n ];\n}\n","import { CalendarManager } from '../../calendar/_module.mjs';\nimport { MODULE, SETTINGS, SOCKET_TYPES } from '../../constants.mjs';\nimport { NoteManager } from '../../notes/_module.mjs';\nimport { WeatherManager } from '../../weather/_module.mjs';\nimport { PRESET_FORMATTERS, formatCustom, resolveFormatString } from '../formatting/format-utils.mjs';\nimport { isMoonVisible } from '../formatting/moon-utils.mjs';\nimport { canViewWeatherForecast } from '../permissions.mjs';\nimport { CalendariaSocket } from '../socket.mjs';\n\n/** Time unit aliases mapping to component fields. */\nexport const TIME_UNIT_MAP = {\n second: 'second',\n seconds: 'second',\n secs: 'second',\n sec: 'second',\n s: 'second',\n round: 'round',\n rounds: 'round',\n rd: 'round',\n minute: 'minute',\n minutes: 'minute',\n mins: 'minute',\n min: 'minute',\n m: 'minute',\n hour: 'hour',\n hours: 'hour',\n hrs: 'hour',\n hr: 'hour',\n h: 'hour',\n day: 'day',\n days: 'day',\n d: 'day',\n week: 'week',\n weeks: 'week',\n w: 'week',\n month: 'month',\n months: 'month',\n mo: 'month',\n year: 'year',\n years: 'year',\n yrs: 'year',\n yr: 'year',\n y: 'year'\n};\n\n/**\n * Format hours as HH:MM string.\n * @param {number} hours - Decimal hours\n * @returns {string} Formatted time string\n */\nexport function formatHours(hours) {\n const h = Math.floor(hours);\n const m = Math.round((hours - h) * 60);\n return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;\n}\n\n/**\n * Format date/time using preset or custom format string.\n * @param {object|null} components - Time components (0-indexed month/dayOfMonth, display year) or null for current\n * @param {string} formatOrPreset - Preset name or custom format string\n * @returns {string} Formatted string\n */\nfunction formatDate(components, formatOrPreset) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return '';\n const formatted = components || { ...game.time.components, year: game.time.components.year + (calendar.years?.yearZero ?? 0) };\n if (PRESET_FORMATTERS[formatOrPreset]) return PRESET_FORMATTERS[formatOrPreset](calendar, formatted);\n const formatStr = resolveFormatString(formatOrPreset);\n return formatCustom(calendar, formatted, formatStr);\n}\n\n/**\n * Get current date/time components (0-indexed month/dayOfMonth, display year with yearZero).\n * @returns {object} Current time components\n */\nfunction getCurrentDateTime() {\n const components = game.time.components;\n const calendar = CalendarManager.getActiveCalendar();\n const yearZero = calendar?.years?.yearZero ?? 0;\n return { ...components, year: components.year + yearZero };\n}\n\n/**\n * Get current season from the active calendar.\n * @returns {object|null} Season data\n */\nfunction getCurrentSeason() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar?.seasons) return null;\n const raw = calendar.getCurrentSeason() ?? null;\n return WeatherManager.applySeasonAlias(raw, WeatherManager.getActiveZone(null, game.scenes?.active));\n}\n\n/**\n * Get sunrise time in decimal hours.\n * @returns {number|null} Sunrise hours\n */\nfunction getSunrise() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n return calendar.sunrise(undefined, zone);\n}\n\n/**\n * Get sunset time in decimal hours.\n * @returns {number|null} Sunset hours\n */\nfunction getSunset() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n return calendar.sunset(undefined, zone);\n}\n\n/**\n * Get current weekday information.\n * @returns {object|null} Weekday data\n */\nfunction getCurrentWeekday() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const weekdayInfo = calendar.getWeekdayForDate?.();\n if (!weekdayInfo) return null;\n return { index: weekdayInfo.index, name: weekdayInfo.name || '', abbreviation: weekdayInfo.abbreviation || '', isRestDay: weekdayInfo.isRestDay || false };\n}\n\n// ─── Command Handlers ──────────────────────────────────────────────────────────\n\n/**\n * /date — format and return current date.\n * @param {string} formatStr - Format preset or custom string\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdDate(formatStr) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const formatted = formatDate(null, formatStr || 'dateLong');\n return { content: formatted };\n}\n\n/**\n * /time — format and return current time.\n * @param {string} formatStr - Format preset or custom string\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdTime(formatStr) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const formatted = formatDate(null, formatStr || 'time24');\n return { content: formatted };\n}\n\n/**\n * /datetime — format and return date+time.\n * @param {string} formatStr - Format preset or custom string\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdDateTime(formatStr) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const formatted = formatDate(null, formatStr || 'dateTimeLong');\n return { content: formatted };\n}\n\n/**\n * /note — create a quick note.\n * @param {string} args - Note title and optional description\n * @returns {Promise<{ content: string }|null>} Null if no calendar or no title\n */\nexport async function cmdNote(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n if (!args) return null;\n const quotedMatch = args.match(/^\"([^\"]+)\"(?:\\s+\"([^\"]*)\")?/);\n const title = quotedMatch ? quotedMatch[1] : args.trim();\n const description = quotedMatch?.[2] || '';\n if (!title) return null;\n const dt = getCurrentDateTime();\n const noteData = {\n startDate: { year: dt.year, month: dt.month, dayOfMonth: dt.dayOfMonth, hour: dt.hour, minute: dt.minute },\n allDay: false,\n categories: ['event'],\n icon: 'fas fa-calendar-day',\n color: '#4a90e2',\n visibility: 'visible'\n };\n const page = await NoteManager.createNote({ name: title, content: description, noteData, openSheet: 'edit' });\n if (page) ATLAS.log(3, `Created note via chat: ${title}`);\n return { content: '' };\n}\n\n/**\n * /weather — get current or historical weather info.\n * @param {string} args - Optional date as \"year month day\"\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdWeather(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n if (args) {\n const match = args.match(/^(\\d+)\\s+(\\d+)\\s+(\\d+)$/);\n if (!match) return { content: _loc('CALENDARIA.ChatCommand.InvalidDateFormat'), error: true };\n const year = parseInt(match[1], 10);\n const month = parseInt(match[2], 10) - 1;\n const day = parseInt(match[3], 10) - 1;\n const yearZero = calendar.years?.yearZero ?? 0;\n const weather = WeatherManager.getWeatherForDate(year - yearZero, month, day);\n if (!weather) return { content: _loc('CALENDARIA.ChatCommand.NoWeatherForDate', { date: `${match[1]}/${match[2]}/${match[3]}` }) };\n const tempStr = weather.temperature != null ? ` (${WeatherManager.formatTemperature(weather.temperature)})` : '';\n const windStr = weather.wind?.speed > 0 ? ` · ${WeatherManager.getWindSpeedLabel(weather.wind.speed)}` : '';\n return { content: ` ${match[1]}/${match[2]}/${match[3]} — ${_loc(weather.label)}${tempStr}${windStr}` };\n }\n const weather = WeatherManager.getCurrentWeather();\n if (!weather) return { content: _loc('CALENDARIA.ChatCommand.NoWeather') };\n const temp = WeatherManager.getTemperature();\n const tempStr = temp != null ? ` (${WeatherManager.formatTemperature(temp)})` : '';\n return { content: ` ${_loc(weather.label)}${tempStr}` };\n}\n\n/**\n * /moon — get moon phase(s).\n * @param {string} args - Optional moon index\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdMoon(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const moons = calendar.moonsArray;\n if (!moons?.length) return { content: _loc('CALENDARIA.Common.NoMoonsConfigured') };\n const moonIndex = args ? parseInt(args, 10) : null;\n if (moonIndex !== null && !isNaN(moonIndex)) {\n const moon = moons[moonIndex];\n const phase = calendar.getMoonPhase(moonIndex);\n if (!moon || !isMoonVisible(moon) || !phase) return { content: _loc('CALENDARIA.Common.NoMoonsConfigured') };\n const icon = phase.icon ? `` : '';\n return { content: `${icon}${_loc(moon.name)}: ${phase.subPhaseName || _loc(phase.name)}` };\n }\n const lines = moons\n .map((moon, index) => {\n if (!isMoonVisible(moon)) return null;\n const phase = calendar.getMoonPhase(index);\n if (!phase) return null;\n const icon = phase.icon ? `` : '';\n return `${icon}${_loc(moon.name)}: ${phase.subPhaseName || _loc(phase.name)}`;\n })\n .filter(Boolean);\n if (!lines.length) return { content: _loc('CALENDARIA.Common.NoMoonsConfigured') };\n return { content: lines.join('
') };\n}\n\n/**\n * /season — get current season.\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdSeason() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const season = getCurrentSeason();\n if (!season) return { content: _loc('CALENDARIA.ChatCommand.NoSeason') };\n const icon = season.icon ? ` ` : '';\n return { content: `${icon}${_loc(season.name)}` };\n}\n\n/**\n * /today — get today's notes.\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdToday() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const dt = getCurrentDateTime();\n const notes = NoteManager.getNotesForDate(dt.year, dt.month, dt.dayOfMonth);\n if (!notes?.length) return { content: _loc('CALENDARIA.ChatCommand.NoNotesToday') };\n const lines = notes.map((n) => {\n const time = n.flagData.allDay ? '' : ` (${String(n.flagData.startDate.hour).padStart(2, '0')}:${String(n.flagData.startDate.minute).padStart(2, '0')})`;\n return `• ${n.name}${time}`;\n });\n return { content: `${_loc('CALENDARIA.ChatCommand.TodayHeader')}
${lines.join('
')}` };\n}\n\n/**\n * /sunrise — get sunrise time.\n * @param {string} formatStr - Format preset or custom string\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdSunrise(formatStr) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const sunrise = getSunrise();\n if (sunrise == null) return { content: _loc('CALENDARIA.ChatCommand.NoSunData') };\n const dt = getCurrentDateTime();\n const h = Math.floor(sunrise);\n const m = Math.round((sunrise - h) * 60);\n const components = { ...dt, hour: h, minute: m, second: 0 };\n const formatted = formatDate(components, formatStr || 'time24');\n return { content: ` ${_loc('CALENDARIA.Common.Sunrise')}: ${formatted}` };\n}\n\n/**\n * /sunset — get sunset time.\n * @param {string} formatStr - Format preset or custom string\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdSunset(formatStr) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const sunset = getSunset();\n if (sunset == null) return { content: _loc('CALENDARIA.ChatCommand.NoSunData') };\n const dt = getCurrentDateTime();\n const h = Math.floor(sunset);\n const m = Math.round((sunset - h) * 60);\n const components = { ...dt, hour: h, minute: m, second: 0 };\n const formatted = formatDate(components, formatStr || 'time24');\n return { content: ` ${_loc('CALENDARIA.Common.Sunset')}: ${formatted}` };\n}\n\n/**\n * /advance — advance time by specified amount.\n * @param {string} args - Time amount and unit (e.g., \"2 hours\", \"1 week cinematic=true\")\n * @returns {Promise<{ content: string }|null>} Null if no calendar or invalid input\n */\nexport async function cmdAdvance(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const match = args?.trim().match(/^(-?\\d+)\\s*(\\w+)(?:\\s+cinematic=(true|false))?$/i);\n if (!match) return { content: _loc('CALENDARIA.ChatCommand.InvalidTimeFormat'), error: true };\n const value = parseInt(match[1], 10);\n const unitInput = match[2].toLowerCase();\n const baseUnit = TIME_UNIT_MAP[unitInput];\n if (!baseUnit) return { content: _loc('CALENDARIA.ChatCommand.InvalidTimeFormat'), error: true };\n const cinematicOverride = match[3] ? match[3].toLowerCase() === 'true' : null;\n const dt = getCurrentDateTime();\n const secondsPerRound = calendar.secondsPerRound ?? 6;\n const yearZero = calendar.years?.yearZero ?? 0;\n const weekDelta = calendar.weekStepDays({ year: dt.year - yearZero, month: dt.month, dayOfMonth: dt.dayOfMonth }, value);\n const updates = {\n second: { second: (dt.second ?? 0) + value },\n round: { second: (dt.second ?? 0) + value * secondsPerRound },\n minute: { minute: dt.minute + value },\n hour: { hour: dt.hour + value },\n day: { dayOfMonth: dt.dayOfMonth + value },\n week: { dayOfMonth: dt.dayOfMonth + weekDelta },\n month: { month: dt.month + value },\n year: { year: dt.year + value }\n };\n const components = { ...dt, ...updates[baseUnit] };\n components.year -= yearZero;\n if (!game.user.isGM) {\n CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'set', components, cinematicOverride });\n } else {\n const { CinematicOverlay } = await import('../../applications/_module.mjs');\n const targetSeconds = calendar.componentsToTime(components);\n const timeDelta = targetSeconds - game.time.worldTime;\n if (cinematicOverride === true) await CinematicOverlay.triggerFromAdvance(timeDelta);\n else if (cinematicOverride === false) await game.time.advance(timeDelta);\n else await CinematicOverlay.gatedAdvance(timeDelta);\n }\n ATLAS.log(3, `Advanced time by ${value} ${unitInput}`);\n return { content: _loc('CALENDARIA.ChatCommand.TimeAdvanced', { value, unit: unitInput }) };\n}\n\n/**\n * /setdate — set date to specific values.\n * @param {string} args - Year month day\n * @returns {Promise<{ content: string }|null>} Null if no calendar or invalid input\n */\nexport async function cmdSetDate(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const match = args?.trim().match(/^(\\d+)\\s+(\\d+)\\s+(\\d+)$/);\n if (!match) return { content: _loc('CALENDARIA.ChatCommand.InvalidDateFormat'), error: true };\n const year = parseInt(match[1], 10);\n const month = parseInt(match[2], 10) - 1;\n const dayOfMonth = parseInt(match[3], 10) - 1;\n if (!game.user.isGM) CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'jump', date: { year, month, dayOfMonth } });\n else await calendar.jumpToDate({ year, month, dayOfMonth });\n ATLAS.log(3, `Set date to ${year}-${month + 1}-${dayOfMonth + 1}`);\n return { content: _loc('CALENDARIA.ChatCommand.DateSet') };\n}\n\n/**\n * /settime — set time to specific values.\n * @param {string} args - Hour minute [second]\n * @returns {Promise<{ content: string }|null>} Null if no calendar or invalid input\n */\nexport async function cmdSetTime(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const match = args?.trim().match(/^(\\d+)\\s+(\\d+)(?:\\s+(\\d+))?$/);\n if (!match) return { content: _loc('CALENDARIA.ChatCommand.InvalidTimeFormat'), error: true };\n const hour = parseInt(match[1], 10);\n const minute = parseInt(match[2], 10);\n const second = match[3] ? parseInt(match[3], 10) : 0;\n const dt = getCurrentDateTime();\n const yearZero = calendar.years?.yearZero ?? 0;\n const components = { ...dt, hour, minute, second, year: dt.year - yearZero };\n if (!game.user.isGM) CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'set', components });\n else await game.time.set(components);\n ATLAS.log(3, `Set time to ${hour}:${minute}:${second}`);\n return { content: _loc('CALENDARIA.ChatCommand.TimeSet') };\n}\n\n/**\n * /calendar — show active calendar summary.\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdCalendar() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const lines = [];\n lines.push(`${_loc('CALENDARIA.Common.Date')}: ${formatDate(null, 'dateLong')}`);\n lines.push(`${_loc('CALENDARIA.Common.Time')}: ${formatDate(null, 'time24')}`);\n const season = getCurrentSeason();\n if (season) lines.push(`${_loc('CALENDARIA.Common.Season')}: ${_loc(season.name)}`);\n const weather = WeatherManager.getCurrentWeather();\n if (weather) {\n const temp = WeatherManager.getTemperature();\n const unit = game.settings.get('calendaria', 'temperatureUnit');\n const tempStr = temp != null ? ` (${Math.round(temp)}°${unit === 'fahrenheit' ? 'F' : 'C'})` : '';\n lines.push(`${_loc('CALENDARIA.Common.Weather')}: ${_loc(weather.label)}${tempStr}`);\n }\n const calMoons = calendar.moonsArray;\n if (calMoons?.length) {\n const moonStrs = calMoons\n .map((moon, index) => {\n if (!isMoonVisible(moon)) return null;\n const phase = calendar.getMoonPhase(index);\n return phase ? `${_loc(moon.name)}: ${phase.subPhaseName || _loc(phase.name)}` : null;\n })\n .filter(Boolean);\n if (moonStrs.length) lines.push(`${_loc('CALENDARIA.Common.Moons')}: ${moonStrs.join(', ')}`);\n }\n const sunrise = getSunrise();\n const sunset = getSunset();\n if (sunrise != null && sunset != null) lines.push(`${_loc('CALENDARIA.ChatCommand.Daylight')}: ${formatHours(sunrise)} - ${formatHours(sunset)}`);\n return { content: lines.join('
') };\n}\n\n/**\n * /calendars — list all calendars.\n * @returns {{ content: string }} Calendar list\n */\nexport function cmdCalendars() {\n const calendars = CalendarManager.getAllCalendarMetadata();\n if (!calendars?.length) return { content: _loc('CALENDARIA.ChatCommand.NoCalendars') };\n const active = CalendarManager.getActiveCalendar();\n const lines = calendars.map((cal) => {\n const isActive = cal.id === active?.id;\n const marker = isActive ? ' ' : '';\n return `• ${cal.name}${marker}`;\n });\n return { content: `${_loc('CALENDARIA.ChatCommand.AvailableCalendars')}:
${lines.join('
')}` };\n}\n\n/**\n * /switchcal — switch active calendar.\n * @param {string} args - Calendar ID\n * @returns {Promise<{ content: string }|null>} Null if missing ID or calendar not found\n */\nexport async function cmdSwitchCal(args) {\n const calendarId = args?.trim();\n if (!calendarId) return null;\n const calendar = CalendarManager.getCalendar(calendarId);\n if (!calendar) return null;\n if (!game.user.isGM) CalendariaSocket.emit(SOCKET_TYPES.CALENDAR_REQUEST, { calendarId });\n else await CalendarManager.switchCalendar(calendarId);\n ATLAS.log(3, `Switched calendar to ${calendarId}`);\n return { content: _loc('CALENDARIA.ChatCommand.CalendarSwitched', { name: calendar.name }) };\n}\n\n/**\n * /festival — get current festival.\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdFestival() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const festival = CalendarManager.getCurrentFestival();\n if (!festival) return { content: _loc('CALENDARIA.ChatCommand.NoFestival') };\n const icon = festival.icon ? ` ` : ' ';\n return { content: `${icon}${_loc(festival.name)}` };\n}\n\n/**\n * /weekday — get current weekday.\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdWeekday() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const weekday = getCurrentWeekday();\n if (!weekday) return { content: _loc('CALENDARIA.ChatCommand.NoWeekday') };\n const restDay = weekday.isRestDay ? ` (${_loc('CALENDARIA.Common.RestDay')})` : '';\n return { content: ` ${_loc(weekday.name)}${restDay}` };\n}\n\n/**\n * /cycle — get zodiac/cycle info.\n * @returns {{ content: string }|null} Null if no calendar\n */\nexport function cmdCycle() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n const cycleData = calendar.getCycleValues();\n if (!cycleData?.values?.length) return { content: _loc('CALENDARIA.ChatCommand.NoCycles') };\n const lines = cycleData.values.map((cycle) => `• ${cycle.cycleName}: ${cycle.entryName}`);\n return { content: lines.join('
') };\n}\n\n/**\n * /forecast — get weather forecast.\n * @param {string} args - Optional day count\n * @returns {{ content: string, whisper: boolean }|null} Null if no calendar, whisper flag for GM-only delivery\n */\nexport function cmdForecast(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n if (!game.settings.get(MODULE.ID, SETTINGS.AUTO_GENERATE_WEATHER)) return { content: _loc('CALENDARIA.ChatCommand.NoForecast') };\n if (!canViewWeatherForecast()) return { content: _loc('CALENDARIA.ChatCommand.NoPermission'), error: true };\n const days = args ? parseInt(args, 10) : undefined;\n if (args && isNaN(days)) return { content: _loc('CALENDARIA.ChatCommand.InvalidForecastDays'), error: true };\n const forecast = WeatherManager.getForecast({ days: days || undefined });\n if (!forecast.length) return { content: _loc('CALENDARIA.ChatCommand.NoForecast') };\n const zone = WeatherManager.getActiveZone(null, game.scenes?.active);\n const zoneName = zone ? _loc(zone.name) : '';\n const subtitle = zoneName ? `
${zoneName}
` : '';\n const lines = forecast.map((f) => {\n const tempStr = f.temperature != null ? ` ${f.isVaried ? '~' : ''}${WeatherManager.formatTemperature(f.temperature)}` : '';\n const label = _loc(f.preset.label);\n return ` ${f.dayOfMonth + 1} — ${label}${tempStr}`;\n });\n return { content: `

${_loc('CALENDARIA.ChatCommand.ForecastHeader')}

${subtitle}${lines.join('
')}`, whisper: true };\n}\n\n/**\n * /weatherprob — get weather probability breakdown.\n * @param {string} args - Optional season name\n * @returns {{ content: string, whisper: boolean }|null} Null if no calendar\n */\nexport function cmdWeatherProb(args) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return null;\n if (!game.settings.get(MODULE.ID, SETTINGS.AUTO_GENERATE_WEATHER)) return { content: _loc('CALENDARIA.ChatCommand.NoForecast') };\n if (!canViewWeatherForecast()) return { content: _loc('CALENDARIA.ChatCommand.NoPermission'), error: true };\n const data = WeatherManager.getWeatherProbabilities({ season: args || undefined });\n if (!data.entries.length) return { content: _loc('CALENDARIA.WeatherProbability.NoPresets') };\n const header = `

${_loc('CALENDARIA.WeatherProbability.Title')}

`;\n const subtitle = data.zone ? `
${data.zone.name}${data.season ? ` — ${data.season}` : ''}
` : '';\n const rows = data.entries.map((e) => ` ${e.label} — ${e.percent}%`);\n const tempStr = `${WeatherManager.formatTemperature(data.tempRange.min)} – ${WeatherManager.formatTemperature(data.tempRange.max)}`;\n const footer = `
${_loc('CALENDARIA.Common.TemperatureRange')}: ${tempStr}
`;\n return { content: `${header}${subtitle}${rows.join('
')}${footer}`, whisper: true };\n}\n\n/** @type {string} Flag key for identifying the enricher reference journal. */\nconst ENRICHER_JOURNAL_FLAG = 'isEnricherReference';\n\n/** @type {Array<{label: string, keys: string[]}>} Enricher categories for the reference journal. */\nconst ENRICHER_CATEGORIES = [\n { label: 'CALENDARIA.Enricher.Category.DateTime', keys: ['date', 'time', 'weekday', 'season', 'era', 'cycle', 'festival', 'restday'] },\n {\n label: 'CALENDARIA.Enricher.Category.TimeMath',\n keys: ['countdown', 'countup', 'between', 'timeuntil', 'datemath'],\n examples: { countdown: '1 1 2030 cal=gregorian', countup: '1 1 2020 cal=gregorian', between: '1 1 2020 to 1 6 2025 cal=gregorian', timeuntil: 'sunset', datemath: '+30d' }\n },\n { label: 'CALENDARIA.Enricher.Category.Calendar', keys: ['calname', 'month', 'year', 'dayofyear', 'yearprogress', 'leapyear', 'intercalary', 'daysinyear'] },\n { label: 'CALENDARIA.Enricher.Category.Sun', keys: ['sunrise', 'sunset', 'daylight', 'isdaytime', 'dayprogress', 'nightprogress', 'untilsunrise', 'untilsunset'] },\n { label: 'CALENDARIA.Enricher.Category.Moon', keys: ['moon', 'moons', 'nextfullmoon', 'convergence', 'eclipse', 'nexteclipse'] },\n { label: 'CALENDARIA.Enricher.Category.Weather', keys: ['weather', 'temperature', 'wind', 'precipitation', 'weathericon', 'zone', 'forecast'] },\n {\n label: 'CALENDARIA.Enricher.Category.Notes',\n keys: ['event', 'notes', 'next', 'category', 'chronicle'],\n examples: { event: 'Winter Solstice', category: 'quest', chronicle: '1 1 1500 to 14 1 1500' }\n },\n { label: 'CALENDARIA.Enricher.Category.Composite', keys: ['summary', 'almanac', 'format', 'compare', 'peek'], examples: { format: 'MMMM YYYY', compare: '1 1 2025 cal=gregorian', peek: '+7d' } },\n {\n label: 'CALENDARIA.Enricher.Category.Actions',\n keys: ['advancetotime', 'advanceinterval', 'advancetopreset', 'settime', 'jumptodate', 'toggleclock', 'setweather'],\n examples: { advancetotime: '18:00', advanceinterval: 'hour=1', advancetopreset: 'sunset', settime: '12:00', jumptodate: '1 1 2030', setweather: 'rain' }\n }\n];\n\n/**\n * /enrichers — create or open the enricher reference journal.\n * @returns {{ content: string }} Chat result\n */\nexport async function cmdEnrichers() {\n const existing = game.journal.find((j) => j.getFlag(MODULE.ID, ENRICHER_JOURNAL_FLAG));\n if (existing) {\n existing.sheet.render(true);\n return { content: ` ${_loc('CALENDARIA.Enricher.Reference.Opened')}` };\n }\n const { handlers: enricherHandlers } = await import('../enrichers.mjs');\n const syntaxHeader = _loc('CALENDARIA.Enricher.Reference.Syntax');\n const outputHeader = _loc('CALENDARIA.Enricher.Reference.Output');\n const sections = ENRICHER_CATEGORIES.map((cat) => {\n const examples = cat.examples ?? {};\n const rows = cat.keys\n .filter((k) => enricherHandlers[k])\n .map((k) => {\n const args = examples[k] ? ` ${examples[k]}` : '';\n return `[\\u200B[cal.${k}${args}]\\u200B][[cal.${k}${args}]]`;\n });\n return `

${_loc(cat.label)}

${rows.join('')}
${syntaxHeader}${outputHeader}
`;\n });\n const wikiLink = `

${_loc('CALENDARIA.Enricher.Reference.WikiLink')}

`;\n const content = `${wikiLink}${sections.join('')}`;\n const journalName = _loc('CALENDARIA.Enricher.Reference.Title');\n const journal = await JournalEntry.create({ name: journalName, pages: [{ name: journalName, type: 'text', text: { content } }], flags: { [MODULE.ID]: { [ENRICHER_JOURNAL_FLAG]: true } } });\n journal.sheet.render(true);\n return { content: ` ${_loc('CALENDARIA.Enricher.Reference.Created')}` };\n}\n","import { canChangeActiveCalendar, canChangeDateTime } from '../permissions.mjs';\nimport {\n cmdAdvance,\n cmdCalendar,\n cmdCalendars,\n cmdCycle,\n cmdDate,\n cmdDateTime,\n cmdEnrichers,\n cmdFestival,\n cmdForecast,\n cmdMoon,\n cmdNote,\n cmdSeason,\n cmdSetDate,\n cmdSetTime,\n cmdSunrise,\n cmdSunset,\n cmdSwitchCal,\n cmdTime,\n cmdToday,\n cmdWeather,\n cmdWeatherProb,\n cmdWeekday\n} from './chat-command-handler.mjs';\n\n/** Command patterns for parsing chat input. */\nconst COMMAND_PATTERNS = {\n date: /^\\/(?:date|d)(?:\\s+(.*))?$/i,\n time: /^\\/(?:time|t)(?:\\s+(.*))?$/i,\n datetime: /^\\/(?:datetime|dt)(?:\\s+(.*))?$/i,\n note: /^\\/(?:note|n)(?:\\s+(.*))?$/i,\n weather: /^\\/weather(?:\\s+(.*))?$/i,\n moon: /^\\/moon(?:\\s+(.*))?$/i,\n season: /^\\/season$/i,\n today: /^\\/today$/i,\n sunrise: /^\\/sunrise(?:\\s+(.*))?$/i,\n sunset: /^\\/sunset(?:\\s+(.*))?$/i,\n advance: /^\\/(?:advance|adv)\\s+(.+)$/i,\n setdate: /^\\/setdate\\s+(.+)$/i,\n settime: /^\\/settime\\s+(.+)$/i,\n calendar: /^\\/(?:calendar|cal)$/i,\n calendars: /^\\/(?:calendars|cals)$/i,\n switchcal: /^\\/switchcal\\s+(.+)$/i,\n festival: /^\\/festival$/i,\n weekday: /^\\/weekday$/i,\n cycle: /^\\/(?:cycle|zodiac)$/i,\n forecast: /^\\/(?:forecast|fc)(?:\\s+(\\d+))?$/i,\n weatherprob: /^\\/(?:weatherprob|wp)(?:\\s+(.*))?$/i,\n enrichers: /^\\/enrichers$/i\n};\n\n/**\n * Handle chatMessage hook to intercept custom commands.\n * @param {object} _chatLog - The ChatLog instance\n * @param {string} message - The raw message content\n * @param {object} _chatData - Chat message data\n * @returns {boolean|void} False to cancel default processing, undefined otherwise\n */\nexport function onChatMessage(_chatLog, message, _chatData) {\n const trimmed = message\n .replace(/^\\s*]*)?>/i, '')\n .replace(/<\\/p>\\s*$/i, '')\n .trim();\n if (!trimmed.startsWith('/')) return;\n for (const [cmd, pattern] of Object.entries(COMMAND_PATTERNS)) {\n const match = trimmed.match(pattern);\n if (match) {\n handleCommand(cmd, match);\n return false;\n }\n }\n}\n\n/**\n * Route command to handler.\n * @param {string} cmd - Command name\n * @param {Array} match - Regex match array\n */\nasync function handleCommand(cmd, match) {\n const arg = match[1]?.trim() || '';\n const noCalMsg = () => ui.notifications.warn(_loc('CALENDARIA.ChatCommand.NoCalendar'));\n const handlers = {\n date: () => sendResult(cmdDate(arg), noCalMsg),\n time: () => sendResult(cmdTime(arg), noCalMsg),\n datetime: () => sendResult(cmdDateTime(arg), noCalMsg),\n note: async () => {\n if (!arg) return ui.notifications.warn(_loc('CALENDARIA.ChatCommand.NoteTitleRequired'));\n try {\n const result = await cmdNote(arg);\n if (!result) return noCalMsg();\n ui.notifications.info(_loc('CALENDARIA.ChatCommand.NoteCreated'));\n } catch {\n ui.notifications.error(_loc('CALENDARIA.ChatCommand.NoteError'));\n }\n },\n weather: () => {\n const result = cmdWeather(arg);\n if (!result) return noCalMsg();\n if (result.error) return ui.notifications.warn(result.content);\n sendChat(result.content);\n },\n moon: () => sendResult(cmdMoon(arg), noCalMsg),\n season: () => sendResult(cmdSeason(), noCalMsg),\n today: () => sendResult(cmdToday(), noCalMsg),\n sunrise: () => sendResult(cmdSunrise(arg), noCalMsg),\n sunset: () => sendResult(cmdSunset(arg), noCalMsg),\n advance: async () => {\n if (!canChangeDateTime()) return ui.notifications.warn(_loc('CALENDARIA.ChatCommand.NoPermission'));\n try {\n const result = await cmdAdvance(match[1]);\n if (!result) return noCalMsg();\n if (result.error) return ui.notifications.warn(result.content);\n if (result.content) ui.notifications.info(result.content);\n } catch {\n ui.notifications.error(_loc('CALENDARIA.ChatCommand.AdvanceError'));\n }\n },\n setdate: async () => {\n if (!canChangeDateTime()) return ui.notifications.warn(_loc('CALENDARIA.ChatCommand.NoPermission'));\n try {\n const result = await cmdSetDate(match[1]);\n if (!result) return noCalMsg();\n if (result.error) return ui.notifications.warn(result.content);\n if (result.content) ui.notifications.info(result.content);\n } catch {\n ui.notifications.error(_loc('CALENDARIA.ChatCommand.SetDateError'));\n }\n },\n settime: async () => {\n if (!canChangeDateTime()) return ui.notifications.warn(_loc('CALENDARIA.ChatCommand.NoPermission'));\n try {\n const result = await cmdSetTime(match[1]);\n if (!result) return noCalMsg();\n if (result.error) return ui.notifications.warn(result.content);\n if (result.content) ui.notifications.info(result.content);\n } catch {\n ui.notifications.error(_loc('CALENDARIA.ChatCommand.SetTimeError'));\n }\n },\n calendar: () => sendResult(cmdCalendar(), noCalMsg),\n calendars: () => sendResult(cmdCalendars()),\n switchcal: async () => {\n if (!canChangeActiveCalendar()) return ui.notifications.warn(_loc('CALENDARIA.ChatCommand.NoPermission'));\n const calendarId = match[1]?.trim();\n if (!calendarId) return ui.notifications.warn(_loc('CALENDARIA.ChatCommand.CalendarIdRequired'));\n try {\n const result = await cmdSwitchCal(calendarId);\n if (!result) return ui.notifications.warn(_loc('CALENDARIA.ChatCommand.CalendarNotFound'));\n if (result.content) ui.notifications.info(result.content);\n } catch {\n ui.notifications.error(_loc('CALENDARIA.ChatCommand.SwitchCalError'));\n }\n },\n festival: () => sendResult(cmdFestival(), noCalMsg),\n weekday: () => sendResult(cmdWeekday(), noCalMsg),\n cycle: () => sendResult(cmdCycle(), noCalMsg),\n forecast: () => {\n const result = cmdForecast(arg);\n if (!result) return noCalMsg();\n if (result.error) return ui.notifications.warn(result.content);\n if (result.whisper) return sendWhisperChat(result.content);\n sendChat(result.content);\n },\n weatherprob: () => {\n const result = cmdWeatherProb(arg);\n if (!result) return noCalMsg();\n if (result.error) return ui.notifications.warn(result.content);\n if (result.whisper) return sendWhisperChat(result.content);\n sendChat(result.content);\n },\n enrichers: async () => {\n const result = await cmdEnrichers();\n if (result?.content) sendChat(result.content);\n }\n };\n await handlers[cmd]?.();\n}\n\n/**\n * Send handler result as a chat message, or call fallback if null.\n * @param {object|null} result - Handler result with content string\n * @param {Function} [fallback] - Called when result is null (no calendar)\n * @returns {Promise}\n */\nasync function sendResult(result, fallback) {\n if (!result) return fallback?.();\n if (result.content) await sendChat(result.content);\n}\n\n/**\n * Send a chat message with calendaria styling.\n * @param {string} content - HTML content\n */\nasync function sendChat(content) {\n await ChatMessage.create({ content: `
${content}
`, speaker: ChatMessage.getSpeaker() });\n}\n\n/**\n * Send a whisper chat message with calendaria styling (GM-only forecast/prob).\n * @param {string} content - HTML content\n */\nasync function sendWhisperChat(content) {\n await ChatMessage.create({ content: `
${content}
`, speaker: ChatMessage.getSpeaker(), whisper: game.user.isGM ? [game.user.id] : [] });\n}\n","import { CalendarManager } from '../../calendar/_module.mjs';\nimport { MODULE, SETTINGS } from '../../constants.mjs';\nimport { NoteManager } from '../../notes/_module.mjs';\nimport { formatForLocation, hasMoonIconMarkers, renderMoonIcons } from '../formatting/format-utils.mjs';\n\nconst ChatLog = foundry.applications.sidebar.tabs.ChatLog;\n\n/**\n * Get the calendar timestamp banner for a chat message, creating it if missing.\n * @param {HTMLElement} li - The chat message
  • element\n * @returns {HTMLElement} The banner element\n */\nfunction getOrCreateBanner(li) {\n let banner = li.querySelector('.cal-timestamp-banner');\n if (!banner) {\n banner = document.createElement('div');\n banner.className = 'cal-timestamp-banner';\n li.append(banner);\n }\n return banner;\n}\n\n/**\n * Write a formatted date into a banner element, rendering moon icons when present.\n * @param {HTMLElement} banner - The banner element\n * @param {string} formattedDate - Pre-formatted date string, possibly containing moon icon markers\n */\nfunction setBannerContent(banner, formattedDate) {\n if (hasMoonIconMarkers(formattedDate)) banner.innerHTML = renderMoonIcons(formattedDate);\n else banner.textContent = formattedDate;\n}\n\n/**\n * Hook handler for preCreateChatMessage.\n * @param {ChatMessage} message - The chat message being created\n * @param {object} _data - The creation data\n * @param {object} _options - Creation options\n * @param {string} _userId - The creating user's ID\n */\nexport function onPreCreateChatMessage(message, _data, _options, _userId) {\n const mode = game.settings.get(MODULE.ID, SETTINGS.CHAT_TIMESTAMP_MODE);\n if (mode === 'disabled') return;\n const worldTime = game.time.worldTime;\n message.updateSource({ [`flags.${MODULE.ID}.worldTime`]: worldTime });\n}\n\n/**\n * Hook handler for renderChatMessageHTML.\n * @param {ChatMessage} message - The chat message document\n * @param {HTMLElement} html - The rendered HTML element\n * @param {object} _context - Render context\n */\nexport function onRenderChatMessageHTML(message, html, _context) {\n const mode = game.settings.get(MODULE.ID, SETTINGS.CHAT_TIMESTAMP_MODE);\n if (mode === 'disabled') return;\n const flags = message.flags?.[MODULE.ID];\n if (!flags?.worldTime) return;\n const timestampEl = html.querySelector('.message-timestamp');\n if (!timestampEl) return;\n const formattedDate = formatWorldTime(flags.worldTime);\n if (!formattedDate) return;\n if (mode === 'replace') {\n if (hasMoonIconMarkers(formattedDate)) timestampEl.innerHTML = renderMoonIcons(formattedDate);\n else timestampEl.textContent = formattedDate;\n } else if (mode === 'augment') {\n setBannerContent(getOrCreateBanner(html), formattedDate);\n }\n}\n\n/**\n * Override ChatLog.prototype.updateTimestamps\n */\nexport function overrideChatLogTimestamps() {\n const originalUpdateTimestamps = ChatLog.prototype.updateTimestamps;\n ChatLog.prototype.updateTimestamps = function () {\n const mode = game.settings.get(MODULE.ID, SETTINGS.CHAT_TIMESTAMP_MODE);\n if (mode === 'disabled') return originalUpdateTimestamps.call(this);\n for (const li of document.querySelectorAll('.chat-message[data-message-id]')) {\n const message = game.messages.get(li.dataset.messageId);\n if (!message?.timestamp) continue;\n const stamp = li.querySelector('.message-timestamp');\n if (!stamp) continue;\n const flags = message.flags?.[MODULE.ID];\n if (flags?.worldTime !== undefined) {\n const formattedDate = formatWorldTime(flags.worldTime);\n if (formattedDate) {\n if (mode === 'replace') {\n if (hasMoonIconMarkers(formattedDate)) stamp.innerHTML = renderMoonIcons(formattedDate);\n else stamp.textContent = formattedDate;\n stamp.dataset.tooltip = foundry.utils.timeSince(message.timestamp);\n } else if (mode === 'augment') {\n stamp.textContent = foundry.utils.timeSince(message.timestamp);\n setBannerContent(getOrCreateBanner(li), formattedDate);\n }\n }\n } else {\n stamp.textContent = foundry.utils.timeSince(message.timestamp);\n }\n }\n };\n}\n\n/**\n * Format world time to a readable date string using display format settings.\n * @param {number} worldTime - The world time in seconds\n * @returns {string} Formatted date string\n */\nexport function formatWorldTime(worldTime) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return '';\n const components = calendar.timeToComponents(worldTime);\n const yearZero = calendar.years?.yearZero ?? 0;\n return formatForLocation(calendar, { ...components, year: components.year + yearZero, dayOfMonth: components.dayOfMonth ?? 0 }, 'chatTimestamp');\n}\n\n/**\n * Handle renderChatMessage hook for calendar announcements and reminders.\n * @param {object} message - The chat message document\n * @param {HTMLElement} html - The rendered HTML element\n * @param {object} _context - Render context\n */\nexport function onRenderAnnouncementMessage(message, html, _context) {\n const flags = message.flags?.[MODULE.ID];\n if (!flags?.isAnnouncement && !flags?.isReminder) return;\n const openLink = html.querySelector('.open-note');\n if (!openLink) return;\n openLink.addEventListener('click', async (event) => {\n event.preventDefault();\n const noteId = openLink.dataset.noteId || flags?.noteId;\n const journalId = openLink.dataset.journalId || flags?.journalId;\n if (!noteId) return;\n const page = NoteManager.getFullNote(noteId);\n if (page) {\n page.sheet.render(true, { mode: 'view' });\n } else if (journalId) {\n const journal = game.journal.get(journalId);\n if (journal) journal.sheet.render(true, { pageId: noteId });\n }\n });\n}\n","import { CalendarManager, CalendarRegistry } from '../calendar/_module.mjs';\nimport { HOOKS, MODULE, SETTINGS } from '../constants.mjs';\nimport { addDays, compareDays } from '../notes/_module.mjs';\nimport { CalendariaSocket } from './socket.mjs';\n\n/** @type {Map>|null} Cached revealed ranges per calendar */\nlet rangeCache = null;\n\n/**\n * Check if Fog of War is enabled.\n * @returns {boolean} Whether fog of war is enabled\n */\nexport function isFogEnabled() {\n return game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_ENABLED);\n}\n\n/**\n * Check if a specific date is revealed for the current user.\n * @param {number} year - Display year (with yearZero applied)\n * @param {number} month - Month (0-indexed)\n * @param {number} dayOfMonth - Day (0-indexed)\n * @param {string} [calendarId] - Calendar ID (defaults to active)\n * @returns {boolean} Whether the date is revealed\n */\nexport function isRevealed(year, month, dayOfMonth, calendarId = null) {\n if (game.user.isGM) return true;\n if (!isFogEnabled()) return true;\n calendarId = calendarId || CalendarRegistry.getActiveId();\n if (!calendarId) return true;\n const date = { year, month, dayOfMonth };\n const startDate = getCompleteStartDate();\n if (startDate) {\n const config = game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_CONFIG);\n const radius = config.revealRadius || 0;\n const floor = radius > 0 ? addDays(startDate, -radius) : startDate;\n if (compareDays(date, floor) < 0) return false;\n }\n const ranges = getRevealedRanges(calendarId);\n for (const range of ranges) if (compareDays(date, range.start) >= 0 && compareDays(date, range.end) <= 0) return true;\n return false;\n}\n\n/**\n * Get revealed ranges for a calendar.\n * @param {string} calendarId - Calendar ID to look up\n * @returns {Array<{start: object, end: object}>} Revealed date ranges\n */\nexport function getRevealedRanges(calendarId) {\n if (!rangeCache) refreshCache();\n return rangeCache.get(calendarId) || [];\n}\n\n/**\n * Reveal a date range. GM only. Merges adjacent/overlapping ranges.\n * @param {object} start - {year, month, dayOfMonth}\n * @param {object} end - {year, month, dayOfMonth}\n * @param {string} [calendarId] - Calendar ID (defaults to active)\n */\nexport async function revealRange(start, end, calendarId = null) {\n if (!game.user.isGM) return;\n calendarId = calendarId || CalendarRegistry.getActiveId();\n if (!calendarId) return;\n const allRanges = foundry.utils.deepClone(game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_RANGES));\n const calRanges = allRanges[calendarId] || [];\n calRanges.push({ start: { year: start.year, month: start.month, dayOfMonth: start.dayOfMonth }, end: { year: end.year, month: end.month, dayOfMonth: end.dayOfMonth } });\n allRanges[calendarId] = mergeRanges(calRanges);\n await game.settings.set(MODULE.ID, SETTINGS.FOG_OF_WAR_RANGES, allRanges);\n Hooks.callAll(HOOKS.FOG_RANGE_CHANGED, { calendarId, ranges: allRanges[calendarId] });\n}\n\n/**\n * Clear all revealed ranges for a calendar. GM only.\n * @param {string} [calendarId] - Calendar ID (defaults to active)\n */\nexport async function clearRanges(calendarId = null) {\n if (!game.user.isGM) return;\n calendarId = calendarId || CalendarRegistry.getActiveId();\n if (!calendarId) return;\n const allRanges = foundry.utils.deepClone(game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_RANGES));\n allRanges[calendarId] = [];\n await game.settings.set(MODULE.ID, SETTINGS.FOG_OF_WAR_RANGES, allRanges);\n Hooks.callAll(HOOKS.FOG_RANGE_CHANGED, { calendarId, ranges: [] });\n}\n\n/**\n * Auto-reveal the current date on DAY_CHANGE. Only runs for primary GM.\n * @param {object} hookData - DAY_CHANGE hook data with {current, previous, calendar}\n */\nexport async function autoRevealCurrentDay(hookData) {\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (!isFogEnabled()) return;\n const config = game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_CONFIG);\n if (!config.autoReveal) return;\n const { current, previous, calendar } = hookData;\n const calendarId = calendar?.metadata?.id;\n if (!calendarId) return;\n const radius = config.revealRadius || 0;\n const revealIntermediate = game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_REVEAL_INTERMEDIATE);\n let start = revealIntermediate ? { year: previous.year, month: previous.month, dayOfMonth: previous.dayOfMonth } : { year: current.year, month: current.month, dayOfMonth: current.dayOfMonth };\n let end = { year: current.year, month: current.month, dayOfMonth: current.dayOfMonth };\n if (radius > 0) {\n start = addDays(start, -radius);\n end = addDays(end, radius);\n }\n await revealRange(start, end, calendarId);\n}\n\n/**\n * Check if an entire month is fully fogged for the current user.\n * @param {number} year - Display year\n * @param {number} month - Month (0-indexed)\n * @param {string} [calendarId] - Calendar ID (defaults to active)\n * @returns {boolean} Whether the entire month is fogged\n */\nexport function isMonthFullyFogged(year, month, calendarId = null) {\n if (game.user.isGM) return false;\n if (!isFogEnabled()) return false;\n calendarId = calendarId || CalendarRegistry.getActiveId();\n if (!calendarId) return true;\n const calendar = CalendarManager.getActiveCalendar();\n const yearZero = calendar?.years?.yearZero ?? 0;\n const daysInMonth = calendar?.getDaysInMonth(month, year - yearZero) ?? 30;\n const monthStart = { year, month, dayOfMonth: 0 };\n const monthEnd = { year, month, dayOfMonth: daysInMonth - 1 };\n const startDate = getCompleteStartDate();\n if (startDate && compareDays(monthEnd, startDate) < 0) return true;\n const ranges = getRevealedRanges(calendarId);\n for (const range of ranges) if (compareDays(range.end, monthStart) >= 0 && compareDays(range.start, monthEnd) <= 0) return false;\n return true;\n}\n\n/**\n * Check if there's a revealed month in the given direction from the current month.\n * @param {number} year - Current viewed year\n * @param {number} month - Current viewed month (0-indexed)\n * @param {number} direction - -1 for previous, 1 for next\n * @param {object} calendar - The calendar object\n * @returns {boolean} True if a revealed month exists in that direction\n */\nexport function hasFogRevealedMonthInDirection(year, month, direction, calendar) {\n if (!calendar) return true;\n const monthsInYear = calendar.monthsArray?.length ?? 12;\n let checkMonth = month + direction;\n let checkYear = year;\n if (checkMonth >= monthsInYear) {\n checkMonth = 0;\n checkYear++;\n } else if (checkMonth < 0) {\n checkMonth = monthsInYear - 1;\n checkYear--;\n }\n let attempts = 0;\n const maxAttempts = monthsInYear * 10;\n while (attempts < maxAttempts) {\n if (!isMonthFullyFogged(checkYear, checkMonth)) return true;\n checkMonth += direction;\n if (checkMonth >= monthsInYear) {\n checkMonth = 0;\n checkYear++;\n } else if (checkMonth < 0) {\n checkMonth = monthsInYear - 1;\n checkYear--;\n }\n attempts++;\n }\n return false;\n}\n\n/**\n * Get the complete start date if all fields are filled.\n * @returns {object|null} {year, month, dayOfMonth} or null if incomplete\n * @private\n */\nfunction getCompleteStartDate() {\n const sd = game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_START_DATE);\n if (!sd || sd.year == null || sd.month == null || sd.dayOfMonth == null) return null;\n return sd;\n}\n\n/**\n * Invalidate the internal range cache. Called when FOG_OF_WAR_RANGES setting changes.\n */\nexport function invalidateCache() {\n rangeCache = null;\n}\n\n/**\n * Refresh the internal cache from settings.\n * @private\n */\nfunction refreshCache() {\n rangeCache = new Map();\n const allRanges = game.settings.get(MODULE.ID, SETTINGS.FOG_OF_WAR_RANGES);\n for (const [calendarId, ranges] of Object.entries(allRanges)) rangeCache.set(calendarId, ranges);\n}\n\n/**\n * Merge overlapping or adjacent date ranges into minimal set.\n * @param {Array<{start: object, end: object}>} ranges - Unsorted ranges\n * @returns {Array<{start: object, end: object}>} Merged ranges\n * @private\n */\nfunction mergeRanges(ranges) {\n if (ranges.length <= 1) return ranges;\n const sorted = [...ranges].sort((a, b) => compareDays(a.start, b.start));\n const merged = [sorted[0]];\n for (let i = 1; i < sorted.length; i++) {\n const current = sorted[i];\n const last = merged[merged.length - 1];\n const lastEndPlusOne = addDays(last.end, 1);\n if (compareDays(current.start, lastEndPlusOne) <= 0) {\n if (compareDays(current.end, last.end) > 0) last.end = current.end;\n } else {\n merged.push(current);\n }\n }\n return merged;\n}\n","import { CalendarManager } from '../calendar/_module.mjs';\nimport { COMPASS_DIRECTIONS, MODULE, SETTINGS, WIND_SPEEDS } from '../constants.mjs';\nimport { NoteManager, addDays, compareDates, dayOfWeek } from '../notes/_module.mjs';\nimport { WeatherManager } from '../weather/_module.mjs';\nimport { isFogEnabled, isRevealed } from './fog-of-war.mjs';\nimport { dateFormattingParts } from './formatting/format-utils.mjs';\nimport { isMoonVisible } from './formatting/moon-utils.mjs';\nimport { canViewMoons } from './permissions.mjs';\n\n/**\n * Build a journal-style weather sentence from weather data.\n * @param {object} w - Weather data object\n * @returns {string|null} Formatted weather sentence or null\n */\nfunction buildWeatherSentence(w) {\n if (!w) return null;\n const label = w.label ? _loc(w.label) : null;\n const temp = w.temperature != null ? WeatherManager.formatTemperature(w.temperature) : null;\n const windDef = w.wind?.speed ? Object.values(WIND_SPEEDS).find((ws) => ws.value === w.wind.speed) : null;\n const windLabel = windDef ? _loc(windDef.label) : null;\n const dir = w.wind?.direction != null ? compassLabel(w.wind.direction) : null;\n const precip = w.precipitation?.type || null;\n const fragments = [];\n if (label) fragments.push(label);\n if (temp) fragments.push(temp);\n if (windLabel && w.wind.speed > 0) fragments.push(dir ? `${windLabel} winds from the ${dir}` : `${windLabel} winds`);\n if (precip) fragments.push(precip);\n if (!fragments.length) return null;\n return fragments.join(', ');\n}\n\n/**\n * Get display color, icon, and festival flag for a note stub.\n * @param {object} stub - Note stub object\n * @returns {object} Display properties { color, icon, isFestival }\n */\nfunction resolveStubDisplayProps(stub) {\n const sys = stub.flagData;\n return { color: sys.color || '#4a9eff', icon: sys.icon || 'fas fa-calendar', isFestival: !!sys.linkedFestival };\n}\n\n/**\n * Build a plain text excerpt from HTML content.\n * @param {string} html - HTML content string\n * @param {number} [maxLength] - Maximum excerpt length\n * @returns {string} Plain text excerpt\n */\nexport function buildExcerpt(html, maxLength = 150) {\n if (!html) return '';\n const div = document.createElement('div');\n div.innerHTML = html;\n const text = div.textContent || '';\n return text.length > maxLength ? `${text.slice(0, maxLength).trimEnd()}...` : text;\n}\n\n/**\n * Resolve compass direction label from degrees.\n * @param {number} degrees - Direction in degrees\n * @returns {string} Compass direction label\n */\nfunction compassLabel(degrees) {\n let closest = 'N';\n let minDiff = 360;\n for (const [label, deg] of Object.entries(COMPASS_DIRECTIONS)) {\n const diff = Math.abs(((degrees - deg + 540) % 360) - 180);\n if (diff < minDiff) {\n minDiff = diff;\n closest = label;\n }\n }\n return closest;\n}\n\n/**\n * Build scroll entries for a date range.\n * @param {object} startDate - Start date { year, month, dayOfMonth }\n * @param {object} endDate - End date { year, month, dayOfMonth }\n * @param {object} [options] - Build options\n * @param {string} [options.calendarId] - Calendar ID (defaults to active)\n * @param {boolean} [options.showEmpty] - Include days with no events\n * @param {string} [options.entryDepth] - Content depth: title, excerpt, full, collapsible\n * @param {string[]} [options.categoryFilter] - Preset IDs to include; empty/missing = no filter\n * @returns {object[]} Array of day entry objects\n */\nexport function buildScrollEntries(startDate, endDate, options = {}) {\n const { calendarId = null, showEmpty = false, entryDepth = 'excerpt', categoryFilter = [] } = options;\n const categoryFilterSet = Array.isArray(categoryFilter) && categoryFilter.length > 0 ? new Set(categoryFilter) : null;\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return [];\n const fogEnabled = isFogEnabled();\n const isGM = game.user.isGM;\n const moons = calendar.moonsArray || [];\n const weekdays = calendar.weekdaysArray || [];\n const yearZero = calendar.years?.yearZero ?? 0;\n const showWeather = game.settings.get(MODULE.ID, SETTINGS.CHRONICLE_SHOW_WEATHER);\n const showMoons = canViewMoons();\n const showSeasons = game.settings.get(MODULE.ID, SETTINGS.CHRONICLE_SHOW_SEASON_CHANGES);\n const emptyContentTypes = game.settings.get(MODULE.ID, SETTINGS.CHRONICLE_EMPTY_CONTENT_TYPES);\n const currentComponents = calendar.timeToComponents(game.time.worldTime);\n const today = { year: currentComponents.year + yearZero, month: currentComponents.month, dayOfMonth: currentComponents.dayOfMonth };\n let prevSeasonName = null;\n let prevSeasonIcon = null;\n let prevSeasonColor = null;\n const dayBefore = addDays(startDate, -1);\n const dayBeforeInternal = { year: dayBefore.year - yearZero, month: dayBefore.month, dayOfMonth: dayBefore.dayOfMonth };\n const aliasZone = WeatherManager.getActiveZone(null, game.scenes?.active);\n if (showSeasons) {\n const prevSeason = WeatherManager.applySeasonAlias(calendar.getCurrentSeason?.(dayBeforeInternal), aliasZone);\n prevSeasonName = prevSeason?.name ? _loc(prevSeason.name) : null;\n prevSeasonIcon = prevSeason?.icon || 'fas fa-leaf';\n prevSeasonColor = prevSeason?.color || '#84cc16';\n }\n const entries = [];\n let current = { ...startDate };\n while (compareDates(current, endDate) <= 0) {\n const { year, month, dayOfMonth } = current;\n const fogged = !isGM && fogEnabled && !isRevealed(year, month, dayOfMonth, calendarId);\n if (fogged) {\n entries.push(buildFoggedEntry(current, calendar, weekdays));\n current = addDays(current, 1);\n continue;\n }\n const parts = dateFormattingParts(calendar, { year, month, dayOfMonth });\n const weekdayIndex = dayOfWeek(current, calendar);\n const weekdayName = weekdays[weekdayIndex]?.name ? _loc(weekdays[weekdayIndex].name) : '';\n const isToday = year === today.year && month === today.month && dayOfMonth === today.dayOfMonth;\n const isPastOrToday = compareDates(current, today) <= 0;\n const internalComponents = { year: year - yearZero, month, dayOfMonth };\n const banners = [];\n const filterActiveSuppressBanners = !!categoryFilterSet;\n if (showSeasons && !filterActiveSuppressBanners) {\n const season = WeatherManager.applySeasonAlias(calendar.getCurrentSeason?.(internalComponents), aliasZone);\n const seasonName = season?.name ? _loc(season.name) : null;\n if (seasonName && prevSeasonName && seasonName !== prevSeasonName) {\n const prevEntry = entries.length > 0 ? entries[entries.length - 1] : null;\n if (prevEntry && !prevEntry.fogged) {\n prevEntry.banners ??= [];\n prevEntry.banners.push({ type: 'season', icon: prevSeasonIcon, label: `${prevSeasonName} is ending`, color: prevSeasonColor });\n if (prevEntry.isEmpty) prevEntry.isEmpty = false;\n }\n banners.push({ type: 'season', icon: season.icon || 'fas fa-leaf', label: `${seasonName} Begins`, color: season.color || '#84cc16' });\n }\n prevSeasonName = seasonName;\n prevSeasonIcon = season?.icon || 'fas fa-leaf';\n prevSeasonColor = season?.color || '#84cc16';\n }\n if (showMoons && !filterActiveSuppressBanners) {\n for (let i = 0; i < moons.length; i++) {\n if (!isMoonVisible(moons[i])) continue;\n const phase = calendar.getMoonPhase(i, internalComponents);\n if (!phase || !phase.name) continue;\n const mid = Math.floor(phase.phaseDuration / 2);\n if (phase.dayWithinPhase === mid) {\n const icon = phase.icon || '';\n const isImage = icon && !icon.startsWith('fas ') && !icon.startsWith('far ');\n const phaseName = _loc(phase.name);\n banners.push({ type: 'moon', icon, label: `${moons[i].name}: ${phaseName}`, color: moons[i].color || '#c0c0c0', isImage });\n }\n }\n }\n const stubsAll = NoteManager.getNotesForDate(year, month, dayOfMonth, calendarId);\n const stubs = categoryFilterSet ? stubsAll.filter((stub) => Array.isArray(stub.flagData?.categories) && stub.flagData.categories.some((c) => categoryFilterSet.has(c))) : stubsAll;\n const notes = stubs.map((stub) => {\n const props = resolveStubDisplayProps(stub);\n const iconStr = props.icon ?? '';\n const iconIsImage = typeof iconStr === 'string' && !iconStr.startsWith('fa') && (iconStr.includes('/') || iconStr.includes('.'));\n const iconIsSvg = iconIsImage && /\\.svg(\\?.*)?$/i.test(iconStr);\n const note = {\n id: stub.id,\n journalId: stub.journalId,\n name: stub.name,\n color: props.color,\n icon: props.icon,\n iconIsImage,\n iconIsSvg,\n isFestival: props.isFestival,\n isOwner: stub.isOwner,\n author: stub.flagData.author?.name || null,\n allDay: stub.flagData.allDay ?? true\n };\n if (entryDepth !== 'title') {\n const content = stub.content || '';\n const excerpt = buildExcerpt(content);\n note.content = content;\n note.excerpt = excerpt;\n note.truncated = excerpt.endsWith('...');\n }\n return note;\n });\n if (showWeather && isPastOrToday && !filterActiveSuppressBanners) {\n const weatherData = WeatherManager.getWeatherForDate(year - yearZero, month, dayOfMonth);\n const sentence = buildWeatherSentence(weatherData);\n if (sentence) {\n const icon = weatherData.icon ? `fas ${weatherData.icon}` : 'fas fa-cloud';\n banners.push({ type: 'weather', icon, label: sentence, color: weatherData.color || '#b0c4de' });\n }\n }\n const hasFestival = notes.some((n) => n.isFestival);\n const contentBanners = banners.filter((b) => emptyContentTypes.has(b.type));\n const isEmpty = filterActiveSuppressBanners ? notes.length === 0 : notes.length === 0 && contentBanners.length === 0;\n if (!isEmpty || (showEmpty && !filterActiveSuppressBanners)) {\n entries.push({ date: { year, month, dayOfMonth }, formattedDate: `${parts.D} ${parts.MMMM} ${parts.y}`, weekday: weekdayName, fogged: false, isToday, notes, banners, hasFestival, isEmpty });\n }\n current = addDays(current, 1);\n }\n return entries;\n}\n\n/**\n * Build a fogged (hidden) entry placeholder.\n * @param {object} date - Date object { year, month, dayOfMonth }\n * @param {object} calendar - Calendar instance\n * @param {object[]} weekdays - Array of weekday definitions\n * @returns {object} Fogged entry object\n */\nfunction buildFoggedEntry(date, calendar, weekdays) {\n const parts = dateFormattingParts(calendar, date);\n const weekdayIndex = dayOfWeek(date, calendar);\n const weekdayName = weekdays[weekdayIndex]?.name ? _loc(weekdays[weekdayIndex].name) : '';\n return { date: { ...date }, formattedDate: `${parts.D} ${parts.MMMM} ${parts.y}`, weekday: weekdayName, fogged: true, isToday: false, notes: [], banners: [], hasFestival: false, isEmpty: true };\n}\n\n/**\n * Compute the default date range starting from today.\n * @param {string} [_calendarId] - Calendar ID\n * @returns {{ startDate: object, endDate: object }} Start and end date range\n */\nexport function getDefaultDateRange(_calendarId = null) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return { startDate: { year: 0, month: 0, dayOfMonth: 0 }, endDate: { year: 0, month: 0, dayOfMonth: 29 } };\n const yearZero = calendar.years?.yearZero ?? 0;\n const components = calendar.timeToComponents(game.time.worldTime);\n const year = components.year + yearZero;\n const month = components.month;\n const dayOfMonth = components.dayOfMonth;\n const today = { year, month, dayOfMonth };\n const startDate = addDays(today, -30);\n const endDate = addDays(today, 30);\n return { startDate, endDate };\n}\n","import { MODULE } from '../constants.mjs';\n\n/**\n * Register combat behavior hooks for a widget.\n * @param {object} options - Configuration for combat behavior hooks\n * @param {string} options.settingKey - SETTINGS key for this widget's combat mode\n * @param {string} options.showSettingKey - SETTINGS key for the widget's show/visibility setting\n * @param {Function} options.getInstance - Returns the rendered instance or undefined\n * @param {Function} options.showWidget - Renders/shows the widget\n */\nexport function registerWidgetCombatHooks({ settingKey, showSettingKey, getInstance, showWidget }) {\n let closedForCombat = false;\n\n /** Restore the widget when combat ends if it was hidden. */\n function onCombatEnd() {\n if (!closedForCombat) return;\n closedForCombat = false;\n if (!game.settings.get(MODULE.ID, showSettingKey)) return;\n showWidget();\n }\n\n Hooks.on('combatStart', () => {\n const mode = game.settings.get(MODULE.ID, settingKey);\n if (mode !== 'hideCombat' && mode !== 'hideEncounter') return;\n const instance = getInstance();\n if (instance?.rendered) {\n closedForCombat = true;\n instance.close({ combat: true });\n }\n });\n\n Hooks.on('createCombat', () => {\n const mode = game.settings.get(MODULE.ID, settingKey);\n if (mode !== 'hideEncounter') return;\n const instance = getInstance();\n if (instance?.rendered) {\n closedForCombat = true;\n instance.close({ combat: true });\n }\n });\n\n Hooks.on('deleteCombat', () => {\n onCombatEnd();\n });\n\n Hooks.on('updateCombat', () => {\n if (!game.combat?.started) onCombatEnd();\n });\n}\n\n/**\n * Check if a widget should be blocked from showing due to active combat.\n * @param {string} settingKey - SETTINGS key for the widget's combat mode\n * @returns {boolean} True if the widget should be blocked\n */\nexport function isCombatBlocked(settingKey) {\n const mode = game.settings.get(MODULE.ID, settingKey);\n if (game.combat?.started && (mode === 'hideCombat' || mode === 'hideEncounter')) return true;\n if (game.combat && !game.combat.started && mode === 'hideEncounter') return true;\n return false;\n}\n","import { CalendarManager } from '../calendar/_module.mjs';\nimport { addOneDay, compareDates, dateToDayNumber, isMoonFull, isNewMoon } from './formatting/moon-utils.mjs';\n\n/** @enum {string} Eclipse type identifiers. */\nexport const ECLIPSE_TYPES = {\n TOTAL_SOLAR: 'totalSolar',\n PARTIAL_SOLAR: 'partialSolar',\n ANNULAR_SOLAR: 'annularSolar',\n TOTAL_LUNAR: 'totalLunar',\n PARTIAL_LUNAR: 'partialLunar',\n PENUMBRAL_LUNAR: 'penumbralLunar'\n};\n\n/** @type {Object} Nodal period multipliers for frequency presets. */\nconst FREQUENCY_MULTIPLIERS = { rare: 12, occasional: 6, frequent: 3 };\n\n/** @type {number} Sin-value threshold for eclipse window detection. */\nconst ECLIPSE_WINDOW_THRESHOLD = 0.15;\n\n/**\n * Resolve the effective nodal period for a moon.\n * @param {object} moon - Moon definition\n * @returns {number|null} Nodal period in days, or null if eclipses disabled\n */\nfunction resolveNodalPeriod(moon) {\n const mode = moon.eclipseMode ?? 'never';\n if (mode === 'never') return null;\n if (mode === 'custom') return moon.nodalPeriod ?? null;\n const multiplier = FREQUENCY_MULTIPLIERS[mode];\n if (!multiplier) return null;\n return moon.cycleLength * multiplier;\n}\n\n/**\n * Calculate how close a date is to an eclipse window center.\n * @param {number} absoluteDay - Absolute day number\n * @param {number} nodalPeriod - Nodal period in days\n * @returns {number} Sin-value (0 = at node crossing, 1 = farthest from node)\n */\nfunction getNodalSinValue(absoluteDay, nodalPeriod) {\n const nodalPosition = (((absoluteDay % nodalPeriod) + nodalPeriod) % nodalPeriod) / nodalPeriod;\n return Math.abs(Math.sin(nodalPosition * 2 * Math.PI));\n}\n\n/**\n * Determine solar eclipse subtype from proximity and apparent size.\n * @param {number} proximity - 0 (center) to 1 (edge) within window\n * @param {number} apparentSize - Moon apparent size relative to sun\n * @returns {string} Eclipse type\n */\nfunction getSolarEclipseType(proximity, apparentSize) {\n if (proximity < 0.3) return apparentSize >= 1.0 ? ECLIPSE_TYPES.TOTAL_SOLAR : ECLIPSE_TYPES.ANNULAR_SOLAR;\n return ECLIPSE_TYPES.PARTIAL_SOLAR;\n}\n\n/**\n * Determine lunar eclipse subtype from proximity.\n * @param {number} proximity - 0 (center) to 1 (edge) within window\n * @returns {string} Eclipse type\n */\nfunction getLunarEclipseType(proximity) {\n if (proximity < 0.3) return ECLIPSE_TYPES.TOTAL_LUNAR;\n if (proximity < 0.7) return ECLIPSE_TYPES.PARTIAL_LUNAR;\n return ECLIPSE_TYPES.PENUMBRAL_LUNAR;\n}\n\n/**\n * Check if an eclipse type is solar.\n * @param {string} type - Eclipse type\n * @returns {boolean} True if the eclipse type is solar\n */\nexport function isSolarEclipse(type) {\n return type === ECLIPSE_TYPES.TOTAL_SOLAR || type === ECLIPSE_TYPES.PARTIAL_SOLAR || type === ECLIPSE_TYPES.ANNULAR_SOLAR;\n}\n\n/**\n * Check if an eclipse type is lunar.\n * @param {string} type - Eclipse type\n * @returns {boolean} True if the eclipse type is lunar\n */\nexport function isLunarEclipse(type) {\n return type === ECLIPSE_TYPES.TOTAL_LUNAR || type === ECLIPSE_TYPES.PARTIAL_LUNAR || type === ECLIPSE_TYPES.PENUMBRAL_LUNAR;\n}\n\n/**\n * Get eclipse data for a single moon on a specific date.\n * @param {object} moon - Moon definition with eclipseMode, cycleLength, etc.\n * @param {object} date - Date to check { year, month, dayOfMonth }\n * @param {object} [calendar] - Calendar instance (uses active if not provided)\n * @returns {object} Eclipse result { type, proximity } or { type: null }\n */\nexport function getEclipseAtDate(moon, date, calendar = null) {\n calendar = calendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moon) return { type: null };\n const nodalPeriod = resolveNodalPeriod(moon);\n if (!nodalPeriod) return { type: null };\n const absoluteDay = dateToDayNumber(date, calendar);\n const sinValue = getNodalSinValue(absoluteDay, nodalPeriod);\n if (sinValue >= ECLIPSE_WINDOW_THRESHOLD) return { type: null };\n const proximity = sinValue / ECLIPSE_WINDOW_THRESHOLD;\n const apparentSize = moon.apparentSize ?? 1.0;\n if (isNewMoon(moon, date, calendar)) return { type: getSolarEclipseType(proximity, apparentSize), proximity };\n if (isMoonFull(moon, date, calendar)) return { type: getLunarEclipseType(proximity), proximity };\n return { type: null };\n}\n\n/**\n * Estimate days until the next nodal window entry from a given day.\n * @param {number} absoluteDay - Current absolute day number\n * @param {number} nodalPeriod - Nodal period in days\n * @returns {number} Estimated days to skip (0 if already in or near a window)\n */\nfunction estimateSkipDays(absoluteDay, nodalPeriod) {\n const sinValue = getNodalSinValue(absoluteDay, nodalPeriod);\n if (sinValue < ECLIPSE_WINDOW_THRESHOLD * 2) return 0;\n const nodalPosition = (((absoluteDay % nodalPeriod) + nodalPeriod) % nodalPeriod) / nodalPeriod;\n const distTo0 = nodalPosition;\n const distTo05 = Math.abs(nodalPosition - 0.5);\n const distTo1 = 1.0 - nodalPosition;\n const minDist = Math.min(distTo0, distTo05, distTo1);\n const windowHalfWidth = Math.asin(ECLIPSE_WINDOW_THRESHOLD) / (2 * Math.PI);\n const skipFraction = Math.max(0, minDist - windowHalfWidth);\n return Math.floor(skipFraction * nodalPeriod);\n}\n\n/**\n * Find the next eclipse for a single moon.\n * @param {object} moon - Moon definition\n * @param {object} startDate - Date to start searching from { year, month, dayOfMonth }\n * @param {object} [options] - Search options\n * @param {number} [options.maxDays] - Maximum days to search\n * @param {object} [options.calendar] - Calendar instance\n * @returns {object|null} Result { date, type, proximity } or null if not found\n */\nexport function getNextEclipse(moon, startDate, options = {}) {\n const { maxDays = 2000, calendar: providedCalendar } = options;\n const calendar = providedCalendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moon) return null;\n const nodalPeriod = resolveNodalPeriod(moon);\n if (!nodalPeriod) return null;\n let currentDate = { ...startDate };\n let daysSearched = 0;\n while (daysSearched < maxDays) {\n const absoluteDay = dateToDayNumber(currentDate, calendar);\n const skip = estimateSkipDays(absoluteDay, nodalPeriod);\n if (skip > 1) {\n const toSkip = Math.min(skip - 1, maxDays - daysSearched);\n for (let s = 0; s < toSkip; s++) currentDate = addOneDay(currentDate, calendar);\n daysSearched += toSkip;\n continue;\n }\n const result = getEclipseAtDate(moon, currentDate, calendar);\n if (result.type) return { date: { ...currentDate }, type: result.type, proximity: result.proximity };\n currentDate = addOneDay(currentDate, calendar);\n daysSearched++;\n }\n return null;\n}\n\n/**\n * Get all eclipses for a single moon within a date range.\n * @param {object} moon - Moon definition\n * @param {object} startDate - Range start { year, month, dayOfMonth }\n * @param {object} endDate - Range end { year, month, dayOfMonth }\n * @param {object} [options] - Search options\n * @param {object} [options.calendar] - Calendar instance\n * @returns {Array} Array of { date, type, proximity }\n */\nexport function getEclipsesInRange(moon, startDate, endDate, options = {}) {\n const calendar = options.calendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moon) return [];\n const nodalPeriod = resolveNodalPeriod(moon);\n if (!nodalPeriod) return [];\n const eclipses = [];\n let currentDate = { ...startDate };\n let maxIterations = 10000;\n while (compareDates(currentDate, endDate) <= 0 && maxIterations-- > 0) {\n const absoluteDay = dateToDayNumber(currentDate, calendar);\n const skip = estimateSkipDays(absoluteDay, nodalPeriod);\n if (skip > 1) {\n for (let s = 0; s < skip - 1 && compareDates(currentDate, endDate) <= 0; s++) currentDate = addOneDay(currentDate, calendar);\n continue;\n }\n const result = getEclipseAtDate(moon, currentDate, calendar);\n if (result.type) {\n eclipses.push({ date: { ...currentDate }, type: result.type, proximity: result.proximity });\n for (let s = 0; s < 2; s++) currentDate = addOneDay(currentDate, calendar);\n } else {\n currentDate = addOneDay(currentDate, calendar);\n }\n }\n return eclipses;\n}\n\n/**\n * Check all moons for an eclipse on a specific date. Returns the first found.\n * @param {Array} moons - Array of moon definitions\n * @param {object} date - Date to check { year, month, dayOfMonth }\n * @param {object} [calendar] - Calendar instance\n * @returns {object|null} Result { type, proximity, moonIndex } or null\n */\nexport function getEclipseOnDate(moons, date, calendar = null) {\n calendar = calendar || CalendarManager.getActiveCalendar();\n if (!calendar || !moons?.length) return null;\n for (let i = 0; i < moons.length; i++) {\n const result = getEclipseAtDate(moons[i], date, calendar);\n if (result.type) return { type: result.type, proximity: result.proximity, moonIndex: i };\n }\n return null;\n}\n\n/**\n * Check if any eclipse occurs on a specific date across all moons.\n * @param {Array} moons - Array of moon definitions\n * @param {object} date - Date to check { year, month, dayOfMonth }\n * @param {object} [calendar] - Calendar instance\n * @returns {boolean} True if any eclipse occurs on the given date\n */\nexport function isEclipseOnDate(moons, date, calendar = null) {\n return getEclipseOnDate(moons, date, calendar) !== null;\n}\n","import { MODULE, SCENE_FLAGS, SETTINGS, SOCKET_TYPES } from '../constants.mjs';\nimport { CalendariaSocket, getMoonPhasePosition } from '../utils/_module.mjs';\nimport { WeatherManager } from '../weather/_module.mjs';\n\n/** @type {number|null} Last hour we calculated darkness for */\nlet lastHour = null;\n\n/** @type {number|null} Last minute we calculated darkness for */\nlet lastMinute = null;\n\n/**\n * Calculate darkness level based on time of day, shaped by sunrise and sunset.\n * @param {number} hours - Hours (0 to hoursPerDay-1)\n * @param {number} minutes - Minutes (0 to minutesPerHour-1)\n * @param {number} [hoursPerDay] - Hours per day for this calendar\n * @param {number} [minutesPerHour] - Minutes per hour for this calendar\n * @param {number} [sunrise] - Sunrise hour (decimal). If null, uses symmetric cosine.\n * @param {number} [sunset] - Sunset hour (decimal). If null, uses symmetric cosine.\n * @returns {number} Darkness level between 0.0 (brightest) and 1.0 (darkest)\n */\nexport function calculateDarknessFromTime(hours, minutes, hoursPerDay = 24, minutesPerHour = 60, sunrise = null, sunset = null) {\n const currentHour = hours + minutes / minutesPerHour;\n if (sunrise == null || sunset == null) {\n const dayProgress = currentHour / hoursPerDay;\n const darkness = (Math.cos(dayProgress * 2 * Math.PI) + 1) / 2;\n return Math.max(0, Math.min(1, darkness));\n }\n const daylightHours = sunset - sunrise;\n const nightHours = hoursPerDay - daylightHours;\n if (currentHour >= sunrise && currentHour < sunset) {\n const dayProgress = (currentHour - sunrise) / daylightHours;\n return Math.max(0, Math.min(1, (Math.cos(dayProgress * Math.PI * 2) + 1) / 4));\n }\n let nightProgress;\n if (currentHour >= sunset) nightProgress = (currentHour - sunset) / nightHours;\n else nightProgress = (currentHour + hoursPerDay - sunset) / nightHours;\n return Math.max(0, Math.min(1, ((1 - Math.cos(nightProgress * Math.PI * 2)) / 2) * 0.5 + 0.5));\n}\n\n/**\n * Get the current darkness level based on game world time and active zone.\n * @param {object} [scene] - Scene for zone resolution\n * @returns {number} Darkness level between 0.0 (brightest) and 1.0 (darkest)\n */\nexport function getCurrentDarkness(scene = null) {\n const calendar = game.time.calendar;\n const components = game.time.components;\n const hours = components.hour ?? 0;\n const minutes = components.minute ?? 0;\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const zone = WeatherManager.getActiveZone?.(null, scene);\n const sunrise = calendar?.sunrise?.(components, zone) ?? null;\n const sunset = calendar?.sunset?.(components, zone) ?? null;\n return calculateDarknessFromTime(hours, minutes, hoursPerDay, minutesPerHour, sunrise, sunset);\n}\n\n/**\n * Calculate adjusted darkness with scene, climate, and weather modifiers.\n * @param {number} baseDarkness - Base darkness from time of day (0-1)\n * @param {object} scene - The scene to get modifiers from\n * @returns {number} Adjusted darkness level (0-1)\n */\nexport function calculateAdjustedDarkness(baseDarkness, scene) {\n const defaultMult = game.settings.get(MODULE.ID, SETTINGS.DEFAULT_BRIGHTNESS_MULTIPLIER) ?? 1.0;\n const sceneFlag = scene?.getFlag(MODULE.ID, SCENE_FLAGS.BRIGHTNESS_MULTIPLIER);\n const sceneBrightnessMult = sceneFlag ?? defaultMult;\n const activeZone = WeatherManager.getActiveZone?.(null, scene);\n const climateBrightnessMult = activeZone?.brightnessMultiplier ?? 1.0;\n const brightness = 1 - baseDarkness;\n const adjustedBrightness = brightness * sceneBrightnessMult * climateBrightnessMult;\n let adjustedDarkness = 1 - adjustedBrightness;\n const moonSync = resolveSceneSync(scene, SCENE_FLAGS.DARKNESS_MOON_SYNC, SETTINGS.DARKNESS_MOON_SYNC);\n if (moonSync) {\n const moonResult = calculateMoonIllumination(adjustedDarkness);\n adjustedDarkness -= moonResult.reduction;\n }\n const weatherSync = resolveSceneSync(scene, SCENE_FLAGS.DARKNESS_WEATHER_SYNC, SETTINGS.DARKNESS_WEATHER_SYNC);\n if (weatherSync) {\n const currentWeather = WeatherManager.getCurrentWeather?.(null, scene);\n const weatherDarknessPenalty = currentWeather?.darknessPenalty ?? 0;\n adjustedDarkness += weatherDarknessPenalty;\n }\n return Math.max(0, Math.min(1, adjustedDarkness));\n}\n\n/**\n * Calculate moon illumination factor for the current night.\n * @param {number} baseDarkness - Current darkness level (0-1)\n * @returns {{ reduction: number, hue: number|null, intensity: number|null, luminosity: number|null }} Moon illumination data\n */\nexport function calculateMoonIllumination(baseDarkness) {\n if (baseDarkness < 0.5) return { reduction: 0, hue: null, intensity: null, luminosity: null };\n const calendar = game.time.calendar;\n if (!calendar?.moonsArray?.length) return { reduction: 0, hue: null, intensity: null, luminosity: null };\n let totalReduction = 0;\n let totalIllumination = 0;\n const coloredMoons = [];\n for (const moon of calendar.moonsArray) {\n const position = getMoonPhasePosition(moon, game.time.components, calendar);\n const illumination = (1 - Math.cos(position * 2 * Math.PI)) / 2;\n const moonMax = moon.moonBrightnessMax ?? 0.15;\n const nightFactor = (baseDarkness - 0.5) / 0.5;\n totalReduction += illumination * moonMax * nightFactor;\n totalIllumination += illumination * moonMax;\n if (illumination > 0.3 && moon.color) {\n const rgb = foundry.utils.Color.from(moon.color);\n const hsl = rgb.hsl;\n if (hsl[1] > 0.1) coloredMoons.push({ hsl, illumination });\n }\n }\n let hue = null;\n let intensity = null;\n if (coloredMoons.length) {\n let totalWeight = 0;\n let weightedHueX = 0;\n let weightedHueY = 0;\n let weightedSat = 0;\n for (const { hsl, illumination } of coloredMoons) {\n const moonHue = hsl[0] * 360;\n const rad = (moonHue * Math.PI) / 180;\n weightedHueX += Math.cos(rad) * illumination;\n weightedHueY += Math.sin(rad) * illumination;\n weightedSat += hsl[1] * illumination;\n totalWeight += illumination;\n }\n hue = Math.round(((Math.atan2(weightedHueY, weightedHueX) * 180) / Math.PI + 360) % 360);\n intensity = Math.min(0.5, (weightedSat / totalWeight) * 0.55);\n }\n const luminosity = Math.min(0.25, totalIllumination * 0.5);\n return { reduction: Math.min(0.3, totalReduction), hue, intensity, luminosity };\n}\n\n/**\n * Calculate time-of-day environment color based on solar position.\n * @param {number} currentHour - Current hour (decimal)\n * @param {number} hoursPerDay - Hours per day\n * @param {number|null} [sunrise] - Sunrise hour (decimal)\n * @param {number|null} [sunset] - Sunset hour (decimal)\n * @param {object|null} colorShift - Per-zone color shift overrides\n * @param {number} [minutesPerHour] - Minutes per hour for transition calculation\n * @returns {{ hue: number, intensity: number, luminosity: number }} Time-based color values\n */\nexport function calculateTimeOfDayColor(currentHour, hoursPerDay, sunrise = null, sunset = null, colorShift = null, minutesPerHour = 60) {\n const dawn = { hue: colorShift?.dawnHue ?? 30, intensity: 0.15, luminosity: 0 };\n const midday = { hue: 0, intensity: 0, luminosity: 0 };\n const dusk = { hue: colorShift?.duskHue ?? 15, intensity: 0.15, luminosity: 0 };\n const night = { hue: colorShift?.nightHue ?? 220, intensity: 0.12, luminosity: 0 };\n const transitionMinutes = colorShift?.transitionMinutes ?? 60;\n const transitionHours = transitionMinutes / minutesPerHour;\n const blend = (a, b, t) => ({ hue: lerpHue(a.hue, b.hue, t), intensity: lerp(a.intensity, b.intensity, t), luminosity: lerp(a.luminosity, b.luminosity, t) });\n if (sunrise == null || sunset == null) {\n const mid = hoursPerDay / 2;\n const quarter = hoursPerDay / 4;\n if (currentHour >= quarter && currentHour < mid) return blend(dawn, midday, (currentHour - quarter) / (mid - quarter));\n if (currentHour >= mid && currentHour < mid + quarter) return blend(midday, dusk, (currentHour - mid) / quarter);\n return { ...night };\n }\n const preDawn = sunrise - transitionHours;\n const mid = sunrise + (sunset - sunrise) / 2;\n const postDusk = sunset + transitionHours;\n if (currentHour >= preDawn && currentHour < sunrise) return blend(night, dawn, (currentHour - preDawn) / transitionHours);\n if (currentHour >= sunrise && currentHour < mid) return blend(dawn, midday, (currentHour - sunrise) / (mid - sunrise));\n if (currentHour >= mid && currentHour < sunset) return blend(midday, dusk, (currentHour - mid) / (sunset - mid));\n if (currentHour >= sunset && currentHour < postDusk) return blend(dusk, night, (currentHour - sunset) / transitionHours);\n return { ...night };\n}\n\n/**\n * Linear interpolation between two values.\n * @param {number} a - Start value\n * @param {number} b - End value\n * @param {number} t - Interpolation factor (0-1)\n * @returns {number} Interpolated value\n */\nfunction lerp(a, b, t) {\n return a + (b - a) * Math.max(0, Math.min(1, t));\n}\n\n/**\n * Circular hue interpolation — takes shortest path around 360° wheel.\n * @param {number} a - Start hue (0-360)\n * @param {number} b - End hue (0-360)\n * @param {number} t - Interpolation factor (0-1)\n * @returns {number} Interpolated hue (0-360)\n */\nfunction lerpHue(a, b, t) {\n t = Math.max(0, Math.min(1, t));\n let diff = b - a;\n if (diff > 180) diff -= 360;\n else if (diff < -180) diff += 360;\n return (((a + diff * t) % 360) + 360) % 360;\n}\n\n/**\n * Calculate environment lighting overrides from time-of-day, climate zone, and weather.\n * @param {object} [scene] - The scene to check for climate zone override\n * @returns {{base: {hue: number|null, intensity: number|null, saturation: number|null, luminosity: number|null, shadows: number|null}, dark: {hue: number|null, intensity: number|null, saturation: number|null, luminosity: number|null, shadows: number|null}}|null} - environment config\n */\nexport function calculateEnvironmentLighting(scene) {\n const colorShiftSync = resolveSceneSync(scene, SCENE_FLAGS.COLOR_SHIFT_SYNC, SETTINGS.COLOR_SHIFT_SYNC);\n const activeZone = WeatherManager.getActiveZone?.(null, scene);\n const currentWeather = WeatherManager.getCurrentWeather?.(null, scene);\n let base = { hue: null, intensity: null, saturation: null, luminosity: null, shadows: null };\n let dark = { hue: null, intensity: null, saturation: null, luminosity: null, shadows: null };\n if (colorShiftSync) {\n const calendar = game.time.calendar;\n const components = game.time.components;\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const currentHour = (components?.hour ?? 0) + (components?.minute ?? 0) / minutesPerHour;\n const sunrise = calendar?.sunrise?.(components, activeZone) ?? null;\n const sunset = calendar?.sunset?.(components, activeZone) ?? null;\n const colorShift = activeZone?.colorShift ?? null;\n const timeColor = calculateTimeOfDayColor(currentHour, hoursPerDay, sunrise, sunset, colorShift, minutesPerHour);\n const ambienceIntensity = game.settings.get(MODULE.ID, SETTINGS.AMBIENCE_INTENSITY) ?? 1;\n const scaledIntensity = timeColor.intensity * ambienceIntensity;\n base = { hue: timeColor.hue, intensity: scaledIntensity, saturation: null, luminosity: timeColor.luminosity, shadows: null };\n dark = { hue: timeColor.hue, intensity: scaledIntensity, saturation: null, luminosity: timeColor.luminosity, shadows: null };\n }\n const moonSync = resolveSceneSync(scene, SCENE_FLAGS.DARKNESS_MOON_SYNC, SETTINGS.DARKNESS_MOON_SYNC);\n if (moonSync) {\n const baseDarkness = getCurrentDarkness();\n if (baseDarkness > 0.5) {\n const moonResult = calculateMoonIllumination(baseDarkness);\n if (moonResult.hue != null) {\n dark.hue = moonResult.hue;\n dark.intensity = moonResult.intensity;\n }\n if (moonResult.luminosity > 0) dark.luminosity = Math.max(dark.luminosity ?? -0.1, (dark.luminosity ?? -0.1) + moonResult.luminosity);\n }\n }\n const applyOverrides = (target, source, hueBlend = 1) => {\n if (!source) return;\n if (source.hue != null) {\n if (hueBlend < 1 && target.hue != null) target.hue = lerpHue(target.hue, source.hue, hueBlend);\n else target.hue = source.hue;\n }\n if (source.saturation != null) target.intensity = source.saturation;\n if (source.colorSaturation != null) target.saturation = source.colorSaturation;\n if (source.luminosity != null) target.luminosity = source.luminosity;\n if (source.shadows != null) target.shadows = source.shadows;\n };\n const weatherHueBlend = game.settings.get(MODULE.ID, SETTINGS.WEATHER_AMBIENCE_INTENSITY) ?? 1;\n applyOverrides(base, activeZone?.environmentBase);\n applyOverrides(dark, activeZone?.environmentDark);\n applyOverrides(base, currentWeather?.environmentBase, weatherHueBlend);\n applyOverrides(dark, currentWeather?.environmentDark, weatherHueBlend);\n let cycle = null;\n if (activeZone?.environmentCycle != null) cycle = activeZone.environmentCycle;\n if (currentWeather?.environmentCycle != null) cycle = currentWeather.environmentCycle;\n const hasValues =\n cycle !== null ||\n base.hue !== null ||\n base.intensity !== null ||\n base.saturation !== null ||\n base.luminosity !== null ||\n base.shadows !== null ||\n dark.hue !== null ||\n dark.intensity !== null ||\n dark.saturation !== null ||\n dark.luminosity !== null ||\n dark.shadows !== null;\n if (!hasValues) return null;\n return { cycle, base, dark };\n}\n\n/**\n * Build environment lighting update data for a scene.\n * @param {object} scene - The scene whose per-scene ambience override is resolved\n * @param {{cycle: boolean|null, base: {hue: number|null, intensity: number|null, saturation: number|null, luminosity: number|null, shadows: number|null}, dark: {hue: number|null, intensity: number|null, saturation: number|null, luminosity: number|null, shadows: number|null}}|null} lighting - Lighting overrides\n * @returns {object|null} Update data object, or null if ambience sync is off or not primary GM\n */\nfunction buildEnvironmentUpdateData(scene, lighting) {\n if (!CalendariaSocket.isPrimaryGM()) return null;\n const ambienceSync = resolveSceneSync(scene, SCENE_FLAGS.AMBIENCE_SYNC, SETTINGS.AMBIENCE_SYNC);\n if (!ambienceSync) return null;\n const base = lighting?.base ?? {};\n const dark = lighting?.dark ?? {};\n return {\n 'environment.cycle': lighting?.cycle ?? true,\n 'environment.base.hue': (base.hue ?? 0) / 360,\n 'environment.base.intensity': base.intensity ?? 0,\n 'environment.base.saturation': base.saturation ?? 0,\n 'environment.base.luminosity': base.luminosity ?? 0,\n 'environment.base.shadows': base.shadows ?? 0,\n 'environment.dark.hue': (dark.hue ?? 0) / 360,\n 'environment.dark.intensity': dark.intensity ?? 0,\n 'environment.dark.saturation': dark.saturation ?? 0,\n 'environment.dark.luminosity': dark.luminosity ?? 0,\n 'environment.dark.shadows': dark.shadows ?? 0\n };\n}\n\n/**\n * Build scene darkness/environment updates, skipping scenes already at the computed values.\n * @param {object} components - Time components with hour/minute\n * @returns {object[]} Scene update payloads whose values differ from current state\n */\nfunction buildDarknessUpdates(components) {\n const calendar = game.time.calendar;\n const currentHour = components?.hour ?? 0;\n const currentMinute = components?.minute ?? 0;\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n return getDarknessScenes()\n .map((scene) => {\n const zone = WeatherManager.getActiveZone?.(null, scene);\n const sunrise = calendar?.sunrise?.(components, zone) ?? null;\n const sunset = calendar?.sunset?.(components, zone) ?? null;\n const baseDarkness = calculateDarknessFromTime(currentHour, currentMinute, hoursPerDay, minutesPerHour, sunrise, sunset);\n const darkness = calculateAdjustedDarkness(baseDarkness, scene);\n const lighting = calculateEnvironmentLighting(scene);\n const envData = buildEnvironmentUpdateData(scene, lighting);\n return { scene, update: { _id: scene.id, 'environment.darknessLevel': darkness, ...envData } };\n })\n .filter(({ scene, update }) => Object.entries(update).some(([key, value]) => key !== '_id' && foundry.utils.getProperty(scene, key) !== value))\n .map(({ update }) => update);\n}\n\n/**\n * Update scene darkness when world time changes.\n * @param {number} worldTime - The new world time\n * @param {number} dt - The time delta in seconds\n */\nexport async function updateDarknessFromWorldTime(worldTime, dt) {\n if (!CalendariaSocket.isPrimaryGM()) return;\n const calendar = game.time.calendar;\n const components = game.time.components ?? calendar?.timeToComponents(worldTime);\n const currentHour = components?.hour ?? 0;\n const currentMinute = components?.minute ?? 0;\n if (lastHour !== null && lastHour === currentHour && lastMinute === currentMinute) return;\n lastHour = currentHour;\n lastMinute = currentMinute;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const secondsPerHour = (calendar?.days?.secondsPerMinute ?? 60) * minutesPerHour;\n const animateDarkness = Math.abs(dt) < secondsPerHour;\n const updates = buildDarknessUpdates(components);\n if (updates.length) await Scene.updateDocuments(updates, { animateDarkness }).catch((error) => ATLAS.log(1, 'Darkness batch update failed:', error));\n}\n\n/**\n * Determine if a scene should have its darkness synced with time.\n * @param {object} scene - The scene to check\n * @returns {boolean} True if darkness should be synced\n */\nfunction shouldSyncSceneDarkness(scene) {\n return resolveSceneSync(scene, SCENE_FLAGS.DARKNESS_SYNC, SETTINGS.DARKNESS_SYNC);\n}\n\n/**\n * Resolve a per-scene sync override against its global setting.\n * @param {object} scene - The scene to check (may be null)\n * @param {string} flagKey - SCENE_FLAGS key holding 'default' | 'enabled' | 'disabled'\n * @param {string} globalSetting - SETTINGS key used as the fallback default\n * @returns {boolean} Whether the sync is enabled for this scene\n */\nfunction resolveSceneSync(scene, flagKey, globalSetting) {\n const flag = scene?.getFlag?.(MODULE.ID, flagKey);\n if (flag === true || flag === 'enabled') return true;\n if (flag === false || flag === 'disabled') return false;\n return game.settings.get(MODULE.ID, globalSetting);\n}\n\n/**\n * Get all scenes that should receive darkness updates.\n * @returns {object[]} Array of scene documents with darkness sync enabled\n */\nfunction getDarknessScenes() {\n if (game.settings.get(MODULE.ID, SETTINGS.DARKNESS_SYNC_ALL_SCENES)) return game.scenes.filter((scene) => shouldSyncSceneDarkness(scene));\n const activeScene = game.scenes.active;\n if (!activeScene || !shouldSyncSceneDarkness(activeScene)) return [];\n return [activeScene];\n}\n\n/**\n * Handle moon phase change to recalculate darkness across synced scenes.\n */\nexport async function onMoonPhaseChange() {\n if (!CalendariaSocket.isPrimaryGM()) return;\n lastHour = null;\n lastMinute = null;\n await updateDarknessFromWorldTime(game.time.worldTime, 0);\n}\n\n/**\n * Force a recompute of environment lighting for synced scenes without waiting for a time tick.\n * Resets the hour/minute cache so settings changes take effect immediately.\n */\nexport async function refreshEnvironmentLighting() {\n if (!CalendariaSocket.isPrimaryGM()) return;\n lastHour = null;\n lastMinute = null;\n await updateDarknessFromWorldTime(game.time.worldTime, 0);\n}\n\n/**\n * Handle weather change to update scene darkness and environment lighting.\n */\nexport async function onWeatherChange() {\n if (!CalendariaSocket.isPrimaryGM()) return;\n const components = game.time.components;\n const updates = buildDarknessUpdates(components);\n if (updates.length) await Scene.updateDocuments(updates).catch((error) => ATLAS.log(1, 'Weather darkness batch update failed:', error));\n}\n\n/**\n * Handle scene update to sync darkness when a scene becomes active.\n * @param {object} scene - The scene that was updated\n * @param {object} change - The change data\n */\nexport async function onUpdateScene(scene, change) {\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (!change.active) return;\n if (scene.getFlag(MODULE.ID, SCENE_FLAGS.HUD_HIDE_FOR_PLAYERS)) CalendariaSocket.emit(SOCKET_TYPES.HUD_VISIBILITY, { visible: false });\n else CalendariaSocket.emit(SOCKET_TYPES.HUD_VISIBILITY, { visible: true });\n if (!shouldSyncSceneDarkness(scene)) return;\n lastHour = null;\n lastMinute = null;\n const calendar = game.time.calendar;\n const components = game.time.components;\n const currentHour = components?.hour ?? 0;\n const currentMinute = components?.minute ?? 0;\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const zone = WeatherManager.getActiveZone?.(null, scene);\n const sunrise = calendar?.sunrise?.(components, zone) ?? null;\n const sunset = calendar?.sunset?.(components, zone) ?? null;\n const baseDarkness = calculateDarknessFromTime(currentHour, currentMinute, hoursPerDay, minutesPerHour, sunrise, sunset);\n const darkness = calculateAdjustedDarkness(baseDarkness, scene);\n const lighting = calculateEnvironmentLighting(scene);\n const envData = buildEnvironmentUpdateData(scene, lighting);\n const updateData = { 'environment.darknessLevel': darkness, ...envData };\n await scene.update(updateData, { animateDarkness: true });\n ATLAS.log(3, `Scene activated, transitioning darkness to ${darkness.toFixed(3)}`);\n}\n","import { CalendarManager } from '../calendar/_module.mjs';\nimport { HOOKS, MODULE, TEMPLATES } from '../constants.mjs';\nimport { NoteManager, compareDates, generateRandomOccurrences, getCurrentDate, isPresetDefaultMacro, needsRandomRegeneration } from '../notes/_module.mjs';\nimport { CalendariaSocket } from '../utils/_module.mjs';\nimport { WeatherManager } from '../weather/_module.mjs';\n\n/**\n * Event Scheduler class that monitors time changes and triggers event notifications.\n */\nexport default class EventScheduler {\n /** @type {object | null} Last processed date components */\n static #lastDate = null;\n\n /** @type {Set} Set of event IDs that have been triggered today (prevents duplicate notifications) */\n static #triggeredToday = new Set();\n\n /** @type {boolean} Flag to skip event triggers on next update (for timepoint jumps) */\n static #skipNext = false;\n\n /** Skip event triggers on the next time update. */\n static skipNext() {\n this.#skipNext = true;\n }\n\n /**\n * Initialize the event scheduler.\n * @returns {void}\n */\n static initialize() {\n this.#lastDate = getCurrentDate();\n ATLAS.log(3, 'Event Scheduler initialized');\n }\n\n /**\n * Handle world time updates.\n * @param {number} _worldTime - The new world time in seconds\n * @param {number} _delta - The time delta in seconds\n * @returns {void}\n */\n static async onUpdateWorldTime(_worldTime, _delta) {\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (this.#skipNext) {\n this.#skipNext = false;\n this.#lastDate = getCurrentDate();\n ATLAS.log(3, 'Skipping event triggers (timepoint jump)');\n return;\n }\n const currentDate = getCurrentDate();\n if (!currentDate) return;\n if (!NoteManager.isInitialized()) return;\n const dateChanged = this.#lastDate && this.#hasDateChanged(this.#lastDate, currentDate);\n if (dateChanged) {\n this.#triggeredToday.clear();\n this.#updateMultiDayEventProgress(currentDate);\n this.#checkRandomEventRegeneration(currentDate);\n await WeatherManager.whenDayChangeSettled();\n }\n if (this.#lastDate) await this.#checkEventTriggers(this.#lastDate, currentDate);\n this.#lastDate = { ...currentDate };\n }\n\n /**\n * Check if any events should trigger based on time change.\n * @param {object} previousDate - Previous date components\n * @param {object} currentDate - Current date components\n * @private\n */\n static async #checkEventTriggers(previousDate, currentDate) {\n const allNotes = NoteManager.getAllNotes();\n for (const note of allNotes) {\n if (this.#triggeredToday.has(note.id)) continue;\n if (this.#shouldTrigger(note, previousDate, currentDate)) {\n this.#triggeredToday.add(note.id);\n await this.#triggerEvent(note, currentDate);\n }\n }\n }\n\n /**\n * Determine if a note should trigger based on time crossing its start time.\n * @param {object} note - The note stub\n * @param {object} previousDate - Previous date components\n * @param {object} currentDate - Current date components\n * @returns {boolean} True if the note should trigger\n * @private\n */\n static #shouldTrigger(note, previousDate, currentDate) {\n if (!previousDate || !currentDate) return false;\n const startDate = note.flagData.startDate;\n if (!startDate) return false;\n const eventStart = {\n year: startDate.year,\n month: startDate.month,\n dayOfMonth: startDate.dayOfMonth,\n hour: note.flagData.allDay ? 0 : (startDate.hour ?? 0),\n minute: note.flagData.allDay ? 0 : (startDate.minute ?? 0)\n };\n const prevComparison = this.#compareDateTimes(previousDate, eventStart);\n const currComparison = this.#compareDateTimes(currentDate, eventStart);\n return prevComparison < 0 && currComparison >= 0;\n }\n\n /**\n * Compare two date-time objects.\n * @param {object} a - First date-time\n * @param {object} b - Second date-time\n * @returns {number} -1 if a < b, 0 if equal, 1 if a > b\n * @private\n */\n static #compareDateTimes(a, b) {\n if (a.year !== b.year) return a.year < b.year ? -1 : 1;\n if (a.month !== b.month) return a.month < b.month ? -1 : 1;\n if (a.dayOfMonth !== b.dayOfMonth) return a.dayOfMonth < b.dayOfMonth ? -1 : 1;\n const aHour = a.hour ?? 0;\n const bHour = b.hour ?? 0;\n if (aHour !== bHour) return aHour < bHour ? -1 : 1;\n const aMinute = a.minute ?? 0;\n const bMinute = b.minute ?? 0;\n if (aMinute !== bMinute) return aMinute < bMinute ? -1 : 1;\n return 0;\n }\n\n /**\n * Trigger an event — chat announcement, Hook, and macro.\n * @param {object} note - The note stub\n * @param {object} currentDate - Current date components\n * @private\n */\n static async #triggerEvent(note, currentDate) {\n ATLAS.log(3, `Triggering event: ${note.name}`);\n if (!note.flagData.silent) this.#sendChatAnnouncement(note);\n Hooks.callAll(HOOKS.EVENT_TRIGGERED, { id: note.id, name: note.name, flagData: note.flagData, currentDate });\n await this.#executeMacro(note);\n }\n\n /**\n * Execute the macro attached to a note.\n * @param {object} note - The note stub\n * @param {object} [context] - Additional context to pass to the macro\n * @private\n */\n static async #executeMacro(note, context = {}) {\n const macroId = note.flagData.macro;\n if (!macroId) return;\n const macro = game.macros.get(macroId);\n if (!macro) return;\n const author = note.flagData.author?._id ? game.users.get(note.flagData.author._id) : null;\n if (author && !author.isGM && !macro.canUserExecute(author) && !isPresetDefaultMacro(macroId)) {\n ATLAS.log(2, `Skipping macro for event ${note.name}: author ${author.name} lacks permission to execute ${macro.name}`);\n return;\n }\n ATLAS.log(3, `Executing macro for event ${note.name}: ${macro.name}`);\n const scope = { event: { id: note.id, name: note.name, flagData: note.flagData }, ...context };\n await macro.execute(scope);\n }\n\n /**\n * Send a chat announcement for an event.\n * @param {object} note - The note stub\n * @private\n */\n static async #sendChatAnnouncement(note) {\n const calendar = CalendarManager.getActiveCalendar();\n const flagData = note.flagData;\n const fullNote = NoteManager.getFullNote(note.id);\n const noteContent = fullNote?.text?.content || '';\n const tempDiv = document.createElement('div');\n tempDiv.innerHTML = noteContent;\n let plainContent = tempDiv.textContent || tempDiv.innerText || '';\n plainContent = plainContent.trim();\n if (plainContent.length > 140) plainContent = `${plainContent.substring(0, 140).trim()}…`;\n const dateRange = this.#formatDateRange(calendar, flagData);\n const color = flagData.color || '#4a9eff';\n let iconHtml;\n if (flagData.icon) {\n if (flagData.icon.startsWith('fa') || flagData.iconType === 'fontawesome') iconHtml = ``;\n else iconHtml = `\"\"`;\n } else {\n iconHtml = '';\n }\n const content = await foundry.applications.handlebars.renderTemplate(TEMPLATES.PARTIALS.CHAT_ANNOUNCEMENT, {\n dateRange,\n content: plainContent,\n noteId: note.id,\n journalId: note.journalId,\n iconHtml\n });\n let whisper = [];\n if (flagData.visibility && flagData.visibility !== 'visible') whisper = game.users.filter((u) => u.isGM).map((u) => u.id);\n await ChatMessage.create({\n content,\n whisper,\n speaker: { alias: note.name },\n flavor: `${iconHtml} ${_loc('CALENDARIA.Event.CalendarEvent')}`,\n flags: { [MODULE.ID]: { isAnnouncement: true, noteId: note.id, journalId: note.journalId } }\n });\n ATLAS.log(3, `Chat announcement sent for event: ${note.name}`, { visibility: flagData.visibility });\n }\n\n /**\n * Format date range for display.\n * @param {object} calendar - The active calendar\n * @param {object} flagData - Note flag data\n * @returns {string} Formatted date range\n * @private\n */\n static #formatDateRange(calendar, flagData) {\n if (!calendar || !flagData.startDate) return '';\n const formatDate = (date) => {\n const monthData = calendar.monthsArray?.[date.month];\n const monthName = monthData?.name ? _loc(monthData.name) : `Month ${date.month + 1}`;\n return `${(date.dayOfMonth ?? 0) + 1} ${monthName}, ${date.year}`;\n };\n const formatTime = (date) => {\n if (flagData.allDay) return '';\n const hour = String(date.hour ?? 0).padStart(2, '0');\n const minute = String(date.minute ?? 0).padStart(2, '0');\n return ` ${_loc('CALENDARIA.Event.AtTime', { time: `${hour}:${minute}` })}`;\n };\n let result = formatDate(flagData.startDate) + formatTime(flagData.startDate);\n if (flagData.endDate && flagData.endDate.year) {\n const startKey = `${flagData.startDate.year}-${flagData.startDate.month}-${flagData.startDate.dayOfMonth}`;\n const endKey = `${flagData.endDate.year}-${flagData.endDate.month}-${flagData.endDate.dayOfMonth}`;\n if (startKey !== endKey) {\n result += ` — ${formatDate(flagData.endDate)}`;\n if (!flagData.allDay && flagData.endDate.hour !== undefined) result += formatTime(flagData.endDate);\n }\n }\n if (flagData.allDay) result += ` ${_loc('CALENDARIA.Event.AllDay')}`;\n return result;\n }\n\n /**\n * Check and regenerate random event occurrences when approaching year end.\n * @param {object} currentDate - Current date components\n * @private\n */\n static async #checkRandomEventRegeneration(currentDate) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar?.monthsArray) return;\n const allNotes = NoteManager.getAllNotes();\n for (const note of allNotes) {\n if (note.flagData.repeat !== 'random') continue;\n const fullNote = NoteManager.getFullNote(note.id);\n if (!fullNote) continue;\n const cachedData = fullNote.getFlag(MODULE.ID, 'randomOccurrences');\n if (!needsRandomRegeneration(cachedData)) continue;\n const currentYear = currentDate.year;\n const targetYear = cachedData?.year >= currentYear ? currentYear + 1 : currentYear;\n const noteData = { startDate: fullNote.system.startDate, randomConfig: fullNote.system.randomConfig, repeatEndDate: fullNote.system.repeatEndDate };\n const occurrences = generateRandomOccurrences(noteData, targetYear);\n await fullNote.setFlag(MODULE.ID, 'randomOccurrences', { year: targetYear, generatedAt: Date.now(), occurrences });\n ATLAS.log(3, `Auto-regenerated ${occurrences.length} random occurrences for ${fullNote.name} until year ${targetYear}`);\n }\n }\n\n /**\n * Check if the date has changed (day/month/year).\n * @param {object} previous - Previous date\n * @param {object} current - Current date\n * @returns {boolean} True if date changed\n * @private\n */\n static #hasDateChanged(previous, current) {\n return previous.year !== current.year || previous.month !== current.month || previous.dayOfMonth !== current.dayOfMonth;\n }\n\n /**\n * Update progress for multi-day events.\n * @param {object} currentDate - Current date components\n * @private\n */\n static #updateMultiDayEventProgress(currentDate) {\n const notes = NoteManager.getAllNotes();\n for (const note of notes) {\n if (note.flagData.silent) continue;\n const progress = this.#getMultiDayProgress(note, currentDate);\n if (!progress) continue;\n this.#showProgressNotification(note, progress);\n }\n }\n\n /**\n * Calculate progress for a multi-day event.\n * @param {object} note - The note stub\n * @param {object} currentDate - Current date\n * @returns {object | null} Progress info or null if not a multi-day event in progress\n * @private\n */\n static #getMultiDayProgress(note, currentDate) {\n const startDate = note.flagData.startDate;\n const endDate = note.flagData.endDate;\n if (!startDate || !endDate) return null;\n const start = { year: startDate.year, month: startDate.month, dayOfMonth: startDate.dayOfMonth };\n const end = { year: endDate.year, month: endDate.month, dayOfMonth: endDate.dayOfMonth };\n const current = { year: currentDate.year, month: currentDate.month, dayOfMonth: currentDate.dayOfMonth };\n if (compareDates(current, start) < 0 || compareDates(current, end) > 0) return null;\n const totalDays = this.#daysBetween(start, end) + 1;\n const currentDay = this.#daysBetween(start, current) + 1;\n if (totalDays <= 1) return null;\n return { currentDay, totalDays, percentage: Math.round((currentDay / totalDays) * 100), isFirstDay: currentDay === 1, isLastDay: currentDay === totalDays };\n }\n\n /**\n * Calculate days between two dates.\n * @param {object} start - Start date\n * @param {object} end - End date\n * @returns {number} Number of days between\n * @private\n */\n static #daysBetween(start, end) {\n const calendar = CalendarManager.getActiveCalendar();\n const startSeconds = calendar.componentsToTime({ year: start.year, month: start.month, dayOfMonth: start.dayOfMonth, hour: 0, minute: 0, second: 0 });\n const endSeconds = calendar.componentsToTime({ year: end.year, month: end.month, dayOfMonth: end.dayOfMonth, hour: 0, minute: 0, second: 0 });\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const secondsPerMinute = calendar?.days?.secondsPerMinute ?? 60;\n const secondsPerDay = hoursPerDay * minutesPerHour * secondsPerMinute;\n return Math.floor((endSeconds - startSeconds) / secondsPerDay);\n }\n\n /**\n * Show or update a progress notification for a multi-day event.\n * @param {object} note - The note stub\n * @param {object} progress - Progress info\n * @private\n */\n static #showProgressNotification(note, progress) {\n ATLAS.log(3, `Multi-day progress: ${note.name} — day ${progress.currentDay}/${progress.totalDays}`);\n Hooks.callAll(HOOKS.EVENT_DAY_CHANGED, { id: note.id, name: note.name, progress });\n this.#executeMacro(note, { trigger: 'multiDayProgress', progress });\n }\n}\n","import { CalendarManager, CalendarRegistry } from '../calendar/_module.mjs';\nimport { HOOKS, MODULE, SOCKET_TYPES } from '../constants.mjs';\nimport { NoteManager, getCurrentDate, isRecurringMatch } from '../notes/_module.mjs';\nimport { CalendariaSocket } from '../utils/_module.mjs';\n\n/**\n * Reminder Scheduler class that monitors time and triggers pre-event reminders.\n */\nexport default class ReminderScheduler {\n /** @type {Set} Set of reminder keys that have fired today (noteId:offset) */\n static #firedToday = new Set();\n\n /** @type {object | null} Last processed date */\n static #lastDate = null;\n\n /** @type {number} Minimum interval between checks in game seconds (1 minute) */\n static CHECK_INTERVAL = 60;\n\n /** @type {number} Last world time when reminders were checked */\n static #lastCheckTime = 0;\n\n /** @type {boolean} Flag to skip reminders on next update (for timepoint jumps) */\n static #skipNext = false;\n\n /** Skip reminders on the next time update. */\n static skipNext() {\n this.#skipNext = true;\n }\n\n /**\n * Initialize the reminder scheduler.\n * @returns {void}\n */\n static initialize() {\n this.#lastDate = getCurrentDate();\n Hooks.on(HOOKS.REMINDER_RECEIVED, (data) => this.handleReminderNotify(data));\n }\n\n /**\n * Handle world time updates.\n * @param {number} worldTime - The new world time in seconds\n * @param {number} _delta - The time delta in seconds\n * @returns {void}\n */\n static onUpdateWorldTime(worldTime, _delta) {\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (this.#skipNext) {\n this.#skipNext = false;\n this.#lastDate = getCurrentDate();\n ATLAS.log(3, 'Skipping reminders (timepoint jump)');\n return;\n }\n const currentDate = getCurrentDate();\n if (!currentDate) return;\n if (!NoteManager.isInitialized()) return;\n if (worldTime < this.#lastCheckTime) {\n this.#firedToday.clear();\n this.#lastCheckTime = worldTime;\n this.#checkReminders(currentDate);\n this.#lastDate = { ...currentDate };\n return;\n }\n if (this.#lastDate && this.#hasDateChanged(this.#lastDate, currentDate)) this.#firedToday.clear();\n if (worldTime - this.#lastCheckTime >= this.CHECK_INTERVAL) {\n this.#checkReminders(currentDate);\n this.#lastCheckTime = worldTime;\n }\n this.#lastDate = { ...currentDate };\n }\n\n /**\n * Check all notes for pending reminders.\n * @param {object} currentDate - Current date components\n * @private\n */\n static #checkReminders(currentDate) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return;\n const activeCalendarId = calendar.metadata?.id || CalendarRegistry.getActiveId() || 'unknown';\n const allNotes = NoteManager.getAllNotes();\n let fired = 0;\n for (const note of allNotes) {\n if (note.calendarId && note.calendarId !== activeCalendarId) continue;\n if (note.flagData.reminderOffset == null || note.flagData.reminderOffset < 0) continue;\n if (note.flagData.silent) continue;\n const reminderKey = `${note.id}:${currentDate.year}-${currentDate.month}-${currentDate.dayOfMonth}`;\n if (this.#firedToday.has(reminderKey)) continue;\n if (this.#shouldFireReminder(note, calendar, currentDate)) {\n this.#fireReminder(note);\n this.#firedToday.add(reminderKey);\n fired++;\n }\n }\n if (fired > 0) ATLAS.log(3, `Fired ${fired} reminder(s) at ${currentDate.year}-${currentDate.month}-${currentDate.dayOfMonth} ${currentDate.hour}:${currentDate.minute}`);\n }\n\n /**\n * Determine if a reminder should fire based on current time and offset.\n * @param {object} note - The note stub\n * @param {object} calendar - Active calendar\n * @param {object} currentDate - Current date components\n * @returns {boolean} - Should reminder fire?\n * @private\n */\n static #shouldFireReminder(note, calendar, currentDate) {\n const startDate = note.flagData.startDate;\n if (!startDate) return false;\n if (!currentDate) return false;\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n const minutesInDay = hoursPerDay * minutesPerHour;\n const offsetMinutes = this.#offsetToMinutes(note, calendar);\n const allDay = !!note.flagData.allDay;\n const eventMinuteOfDay = allDay ? 0 : (startDate.hour ?? 0) * minutesPerHour + (startDate.minute ?? 0);\n const shift = offsetMinutes - eventMinuteOfDay;\n const daysBefore = shift > 0 ? Math.floor((shift - 1) / minutesInDay) + 1 : 0;\n const reminderMinuteOfDay = eventMinuteOfDay + daysBefore * minutesInDay - offsetMinutes;\n const currentMinutes = currentDate.hour * minutesPerHour + currentDate.minute;\n const hasRecurrence = note.flagData.repeat && note.flagData.repeat !== 'never';\n const tree = note.flagData.conditionTree;\n const hasConditions = note.flagData.conditions?.length > 0 || (tree && (tree.children?.length > 0 || (tree.type === 'condition' && tree.field)));\n if (hasRecurrence || hasConditions) {\n const occursToday = isRecurringMatch(note.flagData, currentDate);\n let occursOnReminderDay = false;\n if (daysBefore > 0) {\n const reminderDay = this.#getDateNDaysAhead(currentDate, calendar, daysBefore);\n occursOnReminderDay = isRecurringMatch(note.flagData, reminderDay);\n }\n if (!occursToday && !occursOnReminderDay) return false;\n if (occursOnReminderDay && currentMinutes >= reminderMinuteOfDay) return true;\n if (occursToday && allDay) {\n if (offsetMinutes === 0) return true;\n if (currentMinutes <= offsetMinutes) return true;\n }\n if (occursToday && !allDay && daysBefore === 0) {\n if (offsetMinutes === 0) return currentMinutes >= eventMinuteOfDay;\n return currentMinutes >= reminderMinuteOfDay && currentMinutes < eventMinuteOfDay;\n }\n return false;\n }\n if (daysBefore > 0) {\n const reminderDay = this.#getDateNDaysAhead(currentDate, calendar, daysBefore);\n if (reminderDay.year === startDate.year && reminderDay.month === startDate.month && reminderDay.dayOfMonth === startDate.dayOfMonth) return currentMinutes >= reminderMinuteOfDay;\n return false;\n }\n const endDate = note.flagData.endDate;\n if (this.#isDateInRange(currentDate, startDate, endDate)) {\n const isFirstDay = currentDate.year === startDate.year && currentDate.month === startDate.month && currentDate.dayOfMonth === startDate.dayOfMonth;\n const eventHour = allDay ? 0 : isFirstDay ? (startDate.hour ?? 0) : 0;\n const eventMinute = allDay ? 0 : isFirstDay ? (startDate.minute ?? 0) : 0;\n const eventMinutes = eventHour * minutesPerHour + eventMinute;\n if (offsetMinutes === 0) return currentMinutes >= eventMinutes;\n const reminderMinutes = eventMinutes - offsetMinutes;\n return currentMinutes >= reminderMinutes && currentMinutes < eventMinutes;\n }\n return false;\n }\n\n /**\n * Convert a note's reminder offset and unit into minutes, using calendar-derived lengths.\n * @param {object} note - The note stub\n * @param {object} calendar - Active calendar\n * @returns {number} Offset in minutes\n * @private\n */\n static #offsetToMinutes(note, calendar) {\n const value = note.flagData.reminderOffset ?? 0;\n const unit = note.flagData.reminderUnit || 'hour';\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const minutesInDay = (calendar?.days?.hoursPerDay ?? 24) * minutesPerHour;\n const daysPerYear = calendar?.getDaysInYear?.() ?? (calendar?.monthsArray?.reduce((sum, m) => sum + (m.days || 0), 0) || 365);\n switch (unit) {\n case 'day':\n return value * minutesInDay;\n case 'week':\n return value * (Object.keys(calendar?.days?.values ?? {}).length || 7) * minutesInDay;\n case 'month':\n return value * Math.round(daysPerYear / (calendar?.monthsArray?.length || 12)) * minutesInDay;\n case 'year':\n return value * daysPerYear * minutesInDay;\n default:\n return value * minutesPerHour;\n }\n }\n\n /**\n * Check if a date falls within a date range (inclusive).\n * @param {object} date - Date to check\n * @param {object} startDate - Range start\n * @param {object} endDate - Range end (optional)\n * @returns {boolean} True if date is within range\n * @private\n */\n static #isDateInRange(date, startDate, endDate) {\n const dateVal = date.year * 10000 + date.month * 100 + date.dayOfMonth;\n const startVal = startDate.year * 10000 + startDate.month * 100 + startDate.dayOfMonth;\n if (dateVal < startVal) return false;\n if (!endDate || !endDate.year) return dateVal === startVal;\n const endVal = endDate.year * 10000 + endDate.month * 100 + endDate.dayOfMonth;\n return dateVal <= endVal;\n }\n\n /**\n * Get the next day's date components.\n * @param {object} currentDate - Current date components\n * @param {object} calendar - Active calendar\n * @returns {object} Next day's date components\n * @private\n */\n static #getNextDay(currentDate, calendar) {\n const yearZero = calendar.years?.yearZero ?? 0;\n const daysInMonth = calendar.getDaysInMonth(currentDate.month, currentDate.year - yearZero);\n let nextDayOfMonth = currentDate.dayOfMonth + 1;\n let nextMonth = currentDate.month;\n let nextYear = currentDate.year;\n if (nextDayOfMonth >= daysInMonth) {\n nextDayOfMonth = 0;\n nextMonth++;\n if (nextMonth >= calendar.monthsArray.length) {\n nextMonth = 0;\n nextYear++;\n }\n }\n return { year: nextYear, month: nextMonth, dayOfMonth: nextDayOfMonth };\n }\n\n /**\n * Walk forward N days from the given date.\n * @param {object} currentDate - Starting date components\n * @param {object} calendar - Active calendar\n * @param {number} n - Number of days to walk forward\n * @returns {object} Date components N days ahead\n * @private\n */\n static #getDateNDaysAhead(currentDate, calendar, n) {\n let date = currentDate;\n for (let i = 0; i < n; i++) date = this.#getNextDay(date, calendar);\n return date;\n }\n\n /**\n * Fire a reminder notification.\n * @param {object} note - The note stub\n * @private\n */\n static #fireReminder(note) {\n const reminderType = note.flagData.reminderType || 'toast';\n const targets = this.#getTargetUsers(note);\n const message = this.#formatReminderMessage(note);\n switch (reminderType) {\n case 'toast':\n case 'dialog':\n CalendariaSocket.emit(SOCKET_TYPES.REMINDER_NOTIFY, {\n type: reminderType,\n noteId: note.id,\n noteName: note.name,\n journalId: note.journalId,\n message,\n icon: note.flagData.icon,\n iconType: note.flagData.iconType,\n color: note.flagData.color,\n targets\n });\n if (targets.includes(game.user.id)) {\n if (reminderType === 'toast') this.#showToast(note, message);\n else this.#showDialog(note, message);\n }\n break;\n case 'chat':\n this.#sendChatReminder(note, message, targets);\n break;\n }\n Hooks.callAll(HOOKS.EVENT_TRIGGERED, { id: note.id, name: note.name, flagData: note.flagData, reminderType, isReminder: true });\n }\n\n /**\n * Get list of user IDs who should receive the reminder.\n * @param {object} note - The note stub\n * @returns {string[]} Array of user IDs\n * @private\n */\n static #getTargetUsers(note) {\n const targets = note.flagData.reminderTargets || 'all';\n switch (targets) {\n case 'all':\n return game.users.map((u) => u.id);\n case 'gm':\n return game.users.filter((u) => u.isGM).map((u) => u.id);\n case 'author':\n return note.flagData.author ? [note.flagData.author] : [game.user.id];\n case 'specific':\n return note.flagData.reminderUsers || [];\n case 'viewers': {\n const visibility = note.flagData.visibility || 'visible';\n if (visibility === 'hidden' || visibility === 'secret') return game.users.filter((u) => u.isGM).map((u) => u.id);\n return game.users.filter((u) => u.isGM || note.page?.testUserPermission(u, 'OBSERVER')).map((u) => u.id);\n }\n default:\n return game.users.map((u) => u.id);\n }\n }\n\n /**\n * Format the reminder message.\n * @param {object} note - The note stub\n * @returns {string} - Formatted message\n * @private\n */\n static #formatReminderMessage(note) {\n const value = note.flagData.reminderOffset;\n if (value === 0) return _loc('CALENDARIA.Reminder.StartsNow', { name: note.name });\n const unit = note.flagData.reminderUnit || 'hour';\n const base = { hour: 'Hour', day: 'Day', week: 'Week', month: 'Month', year: 'Year' }[unit] ?? 'Hour';\n const timeStr = _loc(`CALENDARIA.Reminder.Unit.${base}${value === 1 ? '' : 'Plural'}`, { count: value });\n return _loc('CALENDARIA.Reminder.StartsIn', { name: note.name, time: timeStr });\n }\n\n /**\n * Show toast notification.\n * @param {object} note - The note stub\n * @param {string} message - Formatted message\n * @private\n */\n static #showToast(note, message) {\n const icon = this.#getIconHtml(note);\n ui.notifications.info(`${icon} ${message}`, { permanent: false });\n }\n\n /**\n * Send chat message reminder.\n * @param {object} note - The note stub\n * @param {string} message - Formatted message\n * @param {string[]} targets - Target user IDs\n * @private\n */\n static async #sendChatReminder(note, message, targets) {\n const icon = this.#getIconHtml(note);\n const color = note.flagData.color || '#4a9eff';\n let whisper = [];\n if (note.flagData.reminderTargets !== 'all') whisper = targets;\n if (note.flagData.visibility && note.flagData.visibility !== 'visible') {\n const allowed = new Set(game.users.filter((u) => u.isGM).map((u) => u.id));\n if (note.flagData.author) allowed.add(note.flagData.author);\n whisper = (whisper.length ? whisper : targets).filter((id) => allowed.has(id));\n if (!whisper.length) whisper = [...allowed];\n }\n const content = `\n \n `.trim();\n await ChatMessage.create({\n content,\n whisper,\n speaker: { alias: 'Calendaria' },\n flavor: `${icon} ${_loc('CALENDARIA.Reminder.Label')}`,\n flags: { [MODULE.ID]: { isReminder: true, noteId: note.id } }\n });\n }\n\n /**\n * Show dialog popup.\n * @param {object} note - The note stub\n * @param {string} message - Formatted message\n * @private\n */\n static async #showDialog(note, message) {\n const icon = this.#getIconHtml(note);\n const result = await foundry.applications.api.DialogV2.wait({\n window: { title: _loc('CALENDARIA.Reminder.Title'), icon: 'fas fa-bell' },\n content: `

    ${icon} ${message}

    `,\n buttons: [\n { action: 'open', label: _loc('CALENDARIA.Common.OpenNote'), icon: 'fas fa-book-open', callback: () => 'open' },\n { action: 'dismiss', label: _loc('CALENDARIA.Reminder.Dismiss'), icon: 'fas fa-times', default: true, callback: () => 'dismiss' }\n ],\n rejectClose: false\n });\n if (result === 'open') {\n const page = NoteManager.getFullNote(note.id);\n if (page) page.sheet.render(true, { mode: 'view' });\n }\n }\n\n /**\n * Check if date has changed.\n * @param {object} previous - Previous date\n * @param {object} current - Current date\n * @returns {boolean} - Has the date changed?\n * @private\n */\n static #hasDateChanged(previous, current) {\n return previous.year !== current.year || previous.month !== current.month || previous.dayOfMonth !== current.dayOfMonth;\n }\n\n /**\n * Get icon HTML for a note.\n * @param {object} note - The note stub\n * @returns {string} - Icon HTML string\n * @private\n */\n static #getIconHtml(note) {\n const icon = note.flagData.icon;\n const color = note.flagData.color || '#4a9eff';\n if (!icon) return ``;\n if (icon.startsWith('fa') || note.flagData.iconType === 'fontawesome') return ``;\n return `\"\"`;\n }\n\n /**\n * Get icon HTML from raw data (for socket messages).\n * @param {object} data - Socket data with icon, iconType, color\n * @returns {string} - Icon HTML string\n * @private\n */\n static #getIconHtmlFromData(data) {\n const icon = data.icon;\n const color = data.color || '#4a9eff';\n if (!icon) return ``;\n if (icon.startsWith('fa') || data.iconType === 'fontawesome') return ``;\n return `\"\"`;\n }\n\n /**\n * Handle incoming reminder notification from socket.\n * @param {object} data - The reminder data\n * @param {string} data.type - 'toast' or 'dialog'\n * @param {string} data.noteId - Note page ID\n * @param {string} data.noteName - Note name\n * @param {string} data.journalId - Parent journal ID\n * @param {string} data.message - Formatted message\n * @param {string[]} data.targets - Array of target user IDs\n * @returns {void}\n */\n static handleReminderNotify(data) {\n if (!data.targets.includes(game.user.id)) return;\n const iconHtml = this.#getIconHtmlFromData(data);\n if (data.type === 'toast') {\n ui.notifications.info(`${iconHtml} ${data.message}`, { permanent: false });\n } else if (data.type === 'dialog') {\n foundry.applications.api.DialogV2.wait({\n window: { title: _loc('CALENDARIA.Reminder.Title'), icon: 'fas fa-bell' },\n content: `

    ${iconHtml} ${data.message}

    `,\n buttons: [\n {\n action: 'open',\n label: _loc('CALENDARIA.Common.OpenNote'),\n icon: 'fas fa-book-open',\n callback: () => {\n const page = NoteManager.getFullNote(data.noteId);\n if (page) page.sheet.render(true, { mode: 'view' });\n }\n },\n { action: 'dismiss', label: _loc('CALENDARIA.Reminder.Dismiss'), icon: 'fas fa-times', default: true }\n ],\n rejectClose: false\n });\n }\n }\n}\n","import { CalendarManager } from '../calendar/_module.mjs';\nimport { HOOKS, MODULE, SETTINGS } from '../constants.mjs';\nimport { executeMacroById, isMoonVisible } from '../utils/_module.mjs';\nimport { WeatherManager } from '../weather/_module.mjs';\n\n/**\n * Static class that tracks world time changes and fires threshold hooks.\n */\nexport default class TimeTracker {\n /** @type {number|null} Last known world time in seconds */\n static #lastWorldTime = null;\n\n /** @type {object | null} Last checked time components */\n static #lastComponents = null;\n\n /** @type {number|null} Last known season index */\n static #lastSeason = null;\n\n /** @type {Map|null} Last known moon phases (moonIndex -> phaseIndex) */\n static #lastMoonPhases = null;\n\n /** @type {boolean|null} Last known rest day status */\n static #lastRestDay = null;\n\n /** @type {boolean} Flag to skip threshold/period hooks on next update (for timepoint jumps) */\n static #skipNextHooks = false;\n\n /** @type {number} Maximum days to fire threshold hooks for (prevents hook spam on large advances) */\n static #MAX_THRESHOLD_DAYS = 30;\n\n /**\n * Skip threshold and period hooks on the next time update.\n */\n static skipNextHooks() {\n this.#skipNextHooks = true;\n }\n\n /**\n * Initialize the time tracker.\n */\n static initialize() {\n this.#lastWorldTime = game.time.worldTime;\n this.#lastComponents = foundry.utils.deepClone(game.time.components);\n this.#lastSeason = game.time.components?.season ?? null;\n this.#lastMoonPhases = this.#getCurrentMoonPhases();\n this.#lastRestDay = this.#isCurrentDayRestDay();\n ATLAS.log(3, 'Time Tracker initialized');\n }\n\n /**\n * Handle world time updates.\n * @param {number} worldTime - The new world time in seconds\n * @param {number} delta - The time delta in seconds\n */\n static onUpdateWorldTime(worldTime, delta) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return;\n const currentComponents = game.time.components;\n if (this.#lastWorldTime === null || this.#lastComponents === null) {\n this.#lastWorldTime = worldTime;\n this.#lastComponents = foundry.utils.deepClone(currentComponents);\n this.#lastSeason = currentComponents?.season ?? null;\n return;\n }\n if (this.#skipNextHooks) {\n this.#skipNextHooks = false;\n ATLAS.log(3, 'Skipping threshold/period macros (timepoint jump)');\n this.#fireDateTimeChangeHook(this.#lastComponents, currentComponents, delta, calendar);\n this.#checkPeriodChanges(this.#lastComponents, currentComponents, calendar, { skipMacros: true });\n this.#lastWorldTime = worldTime;\n this.#lastComponents = foundry.utils.deepClone(currentComponents);\n this.#lastSeason = currentComponents?.season ?? null;\n this.#lastMoonPhases = this.#getCurrentMoonPhases();\n this.#lastRestDay = this.#isCurrentDayRestDay();\n return;\n }\n this.#fireDateTimeChangeHook(this.#lastComponents, currentComponents, delta, calendar);\n this.#checkPeriodChanges(this.#lastComponents, currentComponents, calendar);\n this.#checkThresholds(this.#lastWorldTime, worldTime, calendar);\n this.#checkMoonPhaseChanges(calendar);\n this.#checkRestDayChange(calendar);\n this.#lastWorldTime = worldTime;\n this.#lastComponents = foundry.utils.deepClone(currentComponents);\n this.#lastSeason = currentComponents?.season ?? null;\n this.#lastMoonPhases = this.#getCurrentMoonPhases();\n this.#lastRestDay = this.#isCurrentDayRestDay();\n }\n\n /**\n * Fire the dateTimeChange hook with comprehensive time change data.\n * @param {object} previousComponents - Previous time components\n * @param {object} currentComponents - Current time components\n * @param {number} delta - Time delta in seconds\n * @param {object} calendar - Active calendar\n * @private\n */\n static #fireDateTimeChangeHook(previousComponents, currentComponents, delta, calendar) {\n const yearZero = calendar?.years?.yearZero ?? 0;\n const hookData = {\n previous: { ...previousComponents, year: previousComponents.year + yearZero },\n current: { ...currentComponents, year: currentComponents.year + yearZero },\n diff: delta,\n calendar: calendar,\n worldTime: game.time.worldTime\n };\n Hooks.callAll(HOOKS.DATE_TIME_CHANGE, hookData);\n }\n\n /**\n * Check for and fire period change hooks (day, month, year, season).\n * @param {object} previousComponents - Previous time components\n * @param {object} currentComponents - Current time components\n * @param {object} calendar - Active calendar\n * @param {object} [options] - Additional options\n * @param {boolean} [options.skipMacros] - If true, skip macro execution\n * @private\n */\n static #checkPeriodChanges(previousComponents, currentComponents, calendar, { skipMacros = false } = {}) {\n const yearZero = calendar?.years?.yearZero ?? 0;\n const hookData = { previous: { ...previousComponents, year: previousComponents.year + yearZero }, current: { ...currentComponents, year: currentComponents.year + yearZero }, calendar: calendar };\n if (previousComponents.year !== currentComponents.year) {\n ATLAS.log(3, `Year changed: ${previousComponents.year + yearZero} -> ${currentComponents.year + yearZero}`);\n Hooks.callAll(HOOKS.YEAR_CHANGE, hookData);\n }\n if (previousComponents.month !== currentComponents.month) {\n ATLAS.log(3, `Month changed: ${previousComponents.month} -> ${currentComponents.month}`);\n Hooks.callAll(HOOKS.MONTH_CHANGE, hookData);\n }\n if (previousComponents.dayOfMonth !== currentComponents.dayOfMonth || previousComponents.month !== currentComponents.month || previousComponents.year !== currentComponents.year) {\n ATLAS.log(3, `Day changed`);\n Hooks.callAll(HOOKS.DAY_CHANGE, hookData);\n if (!skipMacros) this.#executePeriodMacro('day', hookData);\n }\n const previousSeason = previousComponents.season ?? this.#lastSeason;\n const currentSeason = currentComponents.season;\n if (previousSeason !== null && currentSeason !== null && previousSeason !== currentSeason) {\n const seasonData = { ...hookData, previousSeason: calendar.seasonsArray?.[previousSeason] ?? null, currentSeason: calendar.seasonsArray?.[currentSeason] ?? null };\n ATLAS.log(3, `Season changed: ${previousSeason} -> ${currentSeason}`);\n Hooks.callAll(HOOKS.SEASON_CHANGE, seasonData);\n if (!skipMacros) this.#executePeriodMacro('season', seasonData);\n }\n }\n\n /**\n * Check if any thresholds were crossed between two times.\n * @param {number} previousTime - Previous world time in seconds\n * @param {number} currentTime - Current world time in seconds\n * @param {object} calendar - The active calendar\n * @private\n */\n static #checkThresholds(previousTime, currentTime, calendar) {\n if (currentTime <= previousTime) {\n ATLAS.log(2, 'Time went backwards, skipping threshold checks');\n return;\n }\n const previousComponents = this.#getComponentsForTime(previousTime);\n const currentComponents = game.time.components;\n const thresholds = this.#getAllThresholdsCrossed(previousComponents, currentComponents, calendar);\n for (const threshold of thresholds) this.#fireThresholdHook(threshold.name, threshold.data);\n }\n\n /**\n * Get all thresholds crossed between two time points.\n * @param {object} startComponents - Starting time components\n * @param {object} endComponents - Ending time components\n * @param {object} calendar - The active calendar\n * @returns {Array} Array of crossed thresholds with {name, data}\n * @private\n */\n static #getAllThresholdsCrossed(startComponents, endComponents, calendar) {\n const thresholds = [];\n const minutesPerHour = calendar?.days?.minutesPerHour ?? 60;\n const secondsPerMinute = calendar?.days?.secondsPerMinute ?? 60;\n const secondsPerHour = minutesPerHour * secondsPerMinute;\n const startHour = startComponents.hour + startComponents.minute / minutesPerHour + (startComponents.second || 0) / secondsPerHour;\n const endHour = endComponents.hour + endComponents.minute / minutesPerHour + (endComponents.second || 0) / secondsPerHour;\n const totalDays = this.#calculateDaysBetween(startComponents, endComponents, calendar);\n if (totalDays === 0) {\n const dayThresholds = this.#getThresholdsForDay(endComponents, calendar);\n for (const [name, hour] of Object.entries(dayThresholds)) {\n if (hour !== null && startHour < hour && endHour >= hour) thresholds.push({ name, data: this.#createThresholdData(endComponents, calendar) });\n }\n } else {\n const dayThresholds = this.#getThresholdsForDay(startComponents, calendar);\n for (const [name, hour] of Object.entries(dayThresholds)) {\n if (hour !== null && startHour < hour) thresholds.push({ name, data: this.#createThresholdData(startComponents, calendar) });\n }\n const intermediateDays = Math.min(totalDays - 1, this.#MAX_THRESHOLD_DAYS);\n if (totalDays - 1 > this.#MAX_THRESHOLD_DAYS) ATLAS.log(2, `Capping threshold hooks: ${totalDays - 1} intermediate days reduced to ${this.#MAX_THRESHOLD_DAYS}`);\n for (let day = 0; day < intermediateDays; day++) {\n for (const [name, hour] of Object.entries(dayThresholds)) if (hour !== null) thresholds.push({ name, data: this.#createThresholdData(endComponents, calendar) });\n }\n const endDayThresholds = this.#getThresholdsForDay(endComponents, calendar);\n for (const [name, hour] of Object.entries(endDayThresholds)) if (hour !== null && endHour >= hour) thresholds.push({ name, data: this.#createThresholdData(endComponents, calendar) });\n }\n return thresholds;\n }\n\n /**\n * Calculate the number of day boundaries crossed between two time points.\n * @param {object} startComponents - Starting time components\n * @param {object} endComponents - Ending time components\n * @param {object} calendar - The active calendar\n * @returns {number} Number of day boundaries crossed\n * @private\n */\n static #calculateDaysBetween(startComponents, endComponents, calendar) {\n const startDayOfYear = this.#getDayOfYear(startComponents, calendar);\n const endDayOfYear = this.#getDayOfYear(endComponents, calendar);\n const yearDiff = endComponents.year - startComponents.year;\n if (yearDiff === 0) return endDayOfYear - startDayOfYear;\n const daysInYear = this.#getDaysInYear(calendar);\n return daysInYear - startDayOfYear + endDayOfYear + (yearDiff - 1) * daysInYear;\n }\n\n /**\n * Get total days in a year for the calendar.\n * @param {object} calendar - The active calendar\n * @returns {number} Total days in year\n * @private\n */\n static #getDaysInYear(calendar) {\n let total = 0;\n const months = calendar.monthsArray || [];\n for (const month of months) total += month?.days || 30;\n return total || 365;\n }\n\n /**\n * Get threshold times for a specific day.\n * @param {object} _components - Time components for the day\n * @param {object} calendar - The active calendar\n * @returns {object} Object with threshold names and their times in hours\n * @private\n */\n static #getThresholdsForDay(_components, calendar) {\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const sunrise = calendar.sunrise(undefined, zone);\n const sunset = calendar.sunset(undefined, zone);\n const hoursPerDay = calendar?.days?.hoursPerDay ?? 24;\n return { midnight: 0, sunrise: sunrise, midday: hoursPerDay / 2, sunset: sunset };\n }\n\n /**\n * Create threshold event data.\n * @param {object} components - Time components\n * @param {object} calendar - The active calendar\n * @returns {object} Threshold event data\n * @private\n */\n static #createThresholdData(components, calendar) {\n return { worldTime: game.time.worldTime, components: components, calendar: calendar };\n }\n\n /**\n * Fire a threshold hook.\n * @param {string} thresholdName - Name of the threshold (midnight, sunrise, midday, sunset)\n * @param {object} data - Event data to pass to the hook\n * @private\n */\n static #fireThresholdHook(thresholdName, data) {\n const hookName = HOOKS[thresholdName.toUpperCase()];\n if (!hookName) return;\n ATLAS.log(3, `Threshold crossed: ${thresholdName}`);\n Hooks.callAll(hookName, data);\n this.#executeThresholdMacro(thresholdName, data);\n }\n\n /**\n * Get time components for a specific world time.\n * @param {number} worldTime - World time in seconds\n * @returns {object} Time components\n * @private\n */\n static #getComponentsForTime(worldTime) {\n const calendar = CalendarManager.getActiveCalendar();\n if (calendar) return calendar.timeToComponents(worldTime);\n return this.#lastComponents || game.time.components;\n }\n\n /**\n * Calculate day of year from components.\n * @param {object} components - Time components\n * @param {object} calendar - The active calendar\n * @returns {number} Day of year (0-based)\n * @private\n */\n static #getDayOfYear(components, calendar) {\n let dayOfYear = 0;\n for (let i = 0; i < components.month; i++) {\n const month = calendar.monthsArray?.[i];\n dayOfYear += month?.days || 30;\n }\n dayOfYear += components.dayOfMonth;\n return dayOfYear;\n }\n\n /**\n * Get the current moon phase indices for all moons.\n * @returns {Map|null} Map of moonIndex -> phaseIndex, or null if no calendar\n * @private\n */\n static #getCurrentMoonPhases() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar?.moonsArray?.length) return null;\n const phases = new Map();\n for (let i = 0; i < calendar.moonsArray.length; i++) {\n const phaseData = calendar.getMoonPhase?.(i);\n if (phaseData?.phaseIndex !== undefined) phases.set(i, phaseData.phaseIndex);\n }\n return phases.size > 0 ? phases : null;\n }\n\n /**\n * Check for moon phase changes and fire hooks/macros.\n * @param {object} calendar - The active calendar\n * @private\n */\n static #checkMoonPhaseChanges(calendar) {\n if (!calendar?.moonsArray?.length) return;\n if (!this.#lastMoonPhases) return;\n const currentPhases = this.#getCurrentMoonPhases();\n if (!currentPhases) return;\n const changedMoons = [];\n for (const [moonIndex, currentPhaseIndex] of currentPhases) {\n const lastPhaseIndex = this.#lastMoonPhases.get(moonIndex);\n if (lastPhaseIndex !== undefined && lastPhaseIndex !== currentPhaseIndex) {\n const moon = calendar.moonsArray[moonIndex];\n const phasesArr = Object.values(moon.phases ?? {});\n const previousPhase = phasesArr[lastPhaseIndex];\n const currentPhase = phasesArr[currentPhaseIndex];\n changedMoons.push({\n moonIndex,\n visible: isMoonVisible(moon),\n moonName: moon.name ? _loc(moon.name) : _loc('CALENDARIA.Calendar.MoonFallback', { num: moonIndex + 1 }),\n previousPhaseIndex: lastPhaseIndex,\n previousPhaseName: previousPhase?.name ? _loc(previousPhase.name) : null,\n currentPhaseIndex,\n currentPhaseName: currentPhase?.name ? _loc(currentPhase.name) : null\n });\n }\n }\n if (changedMoons.length > 0) {\n ATLAS.log(3, `Moon phase changed for ${changedMoons.length} moon(s)`);\n Hooks.callAll(HOOKS.MOON_PHASE_CHANGE, { moons: changedMoons, calendar, worldTime: game.time.worldTime });\n this.#executeMoonPhaseMacros(changedMoons);\n }\n }\n\n /**\n * Get the macro trigger configuration.\n * @returns {object} The macro trigger config\n * @private\n */\n static #getMacroConfig() {\n return game.settings.get(MODULE.ID, SETTINGS.MACRO_TRIGGERS) || { global: {}, moonPhase: [] };\n }\n\n /**\n * Execute a global trigger macro if configured.\n * @param {string} triggerKey - The trigger key (dawn, dusk, midday, midnight, newDay, seasonChange)\n * @param {object} context - Context data to pass to the macro\n * @private\n */\n static #executeGlobalTrigger(triggerKey, context) {\n if (!game.user.isGM) return;\n const config = this.#getMacroConfig();\n const macroId = config.global?.[triggerKey];\n if (!macroId) return;\n executeMacroById(macroId, context);\n }\n\n /**\n * Execute the appropriate macro for a threshold crossing.\n * @param {string} thresholdName - Name of the threshold (midnight, sunrise, midday, sunset)\n * @param {object} data - Event data\n * @private\n */\n static #executeThresholdMacro(thresholdName, data) {\n const keyMap = { midnight: 'midnight', sunrise: 'dawn', midday: 'midday', sunset: 'dusk' };\n const triggerKey = keyMap[thresholdName];\n if (!triggerKey) return;\n this.#executeGlobalTrigger(triggerKey, { trigger: thresholdName, ...data });\n }\n\n /**\n * Execute the appropriate macro for a period change.\n * @param {string} periodName - Name of the period (day, season)\n * @param {object} data - Event data\n * @private\n */\n static #executePeriodMacro(periodName, data) {\n if (periodName === 'day') this.#executeGlobalTrigger('newDay', { trigger: 'newDay', ...data });\n else if (periodName === 'season') this.#executeSeasonMacros(data);\n }\n\n /**\n * Execute macros for season changes based on config.\n * @param {object} data - Season change event data\n * @private\n */\n static #executeSeasonMacros(data) {\n if (!game.user.isGM) return;\n const config = this.#getMacroConfig();\n const seasonTriggers = config.season || [];\n if (!seasonTriggers.length) return;\n const currentSeasonIndex = data.current?.season;\n if (currentSeasonIndex === undefined) return;\n const matchingTriggers = seasonTriggers.filter((t) => t.seasonIndex === -1 || t.seasonIndex === currentSeasonIndex);\n for (const trigger of matchingTriggers) executeMacroById(trigger.macroId, { trigger: 'seasonChange', ...data });\n }\n\n /**\n * Execute macros for moon phase changes based on config.\n * @param {Array} changedMoons - Array of moon phase change data\n * @private\n */\n static #executeMoonPhaseMacros(changedMoons) {\n if (!game.user.isGM) return;\n const config = this.#getMacroConfig();\n const moonTriggers = config.moonPhase || [];\n if (!moonTriggers.length) return;\n for (const changed of changedMoons) {\n const matchingTriggers = moonTriggers.filter((t) => {\n const moonMatches = t.moonIndex === -1 || t.moonIndex === changed.moonIndex;\n const phaseMatches = t.phaseIndex === -1 || t.phaseIndex === changed.currentPhaseIndex;\n return moonMatches && phaseMatches;\n });\n for (const trigger of matchingTriggers) executeMacroById(trigger.macroId, { trigger: 'moonPhaseChange', moon: changed });\n }\n }\n\n /**\n * Check if the current day is a rest day.\n * @returns {boolean} True if current day is a rest day\n * @private\n */\n static #isCurrentDayRestDay() {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar) return false;\n const weekdayInfo = calendar.getWeekdayForDate?.();\n return weekdayInfo?.isRestDay || false;\n }\n\n /**\n * Check for rest day status change and fire hook if changed.\n * @param {object} calendar - The active calendar\n * @private\n */\n static #checkRestDayChange(calendar) {\n if (this.#lastRestDay === null) return;\n const currentRestDay = this.#isCurrentDayRestDay();\n if (this.#lastRestDay !== currentRestDay) {\n const weekdayInfo = calendar?.getWeekdayForDate?.();\n const hookData = {\n isRestDay: currentRestDay,\n wasRestDay: this.#lastRestDay,\n weekday: weekdayInfo ? { index: weekdayInfo.index, name: weekdayInfo.name || '', abbreviation: weekdayInfo.abbreviation || '' } : null,\n worldTime: game.time.worldTime,\n calendar\n };\n ATLAS.log(3, `Rest day status changed: ${this.#lastRestDay} -> ${currentRestDay}`);\n Hooks.callAll(HOOKS.REST_DAY_CHANGE, hookData);\n }\n }\n}\n","import { CinematicOverlay } from '../applications/_module.mjs';\nimport { HOOKS, MODULE, SETTINGS, SOCKET_TYPES } from '../constants.mjs';\nimport { CalendariaSocket, canChangeDateTime } from '../utils/_module.mjs';\nimport { updateDarknessFromWorldTime } from './darkness.mjs';\nimport EventScheduler from './event-scheduler.mjs';\nimport ReminderScheduler from './reminder-scheduler.mjs';\nimport TimeTracker from './time-tracker.mjs';\n\n/**\n * Get calendar-aware time increment presets in seconds.\n * @returns {Object} Time increment presets\n */\nexport function getTimeIncrements() {\n const cal = game.time?.calendar;\n const days = cal?.days ?? {};\n const seasons = cal?.seasonsArray ?? [];\n const secsPerMinute = days.secondsPerMinute ?? 60;\n const minutesPerHour = days.minutesPerHour ?? 60;\n const hoursPerDay = days.hoursPerDay ?? 24;\n const daysPerYear = days.daysPerYear ?? 365;\n const secsPerHour = secsPerMinute * minutesPerHour;\n const secsPerDay = secsPerHour * hoursPerDay;\n const monthDays = cal?.monthsArray?.[0]?.days ?? Math.floor(daysPerYear / 12);\n const secsPerMonth = secsPerDay * monthDays;\n const seasonDays = seasons[0]?.duration ?? Math.floor(daysPerYear / 4);\n const secsPerSeason = secsPerDay * seasonDays;\n const secsPerYear = secsPerDay * daysPerYear;\n const secsPerRound = cal?.secondsPerRound ?? 6;\n return { second: 1, round: secsPerRound, minute: secsPerMinute, hour: secsPerHour, day: secsPerDay, week: secsPerDay * 7, month: secsPerMonth, season: secsPerSeason, year: secsPerYear };\n}\n\n/**\n * Resolve an increment value to a valid string key, coercing a numeric index to its key.\n * @param {Object} increments - Increment map from getTimeIncrements()\n * @param {string|number} key - Increment key or numeric index\n * @returns {string|number} Resolved string key, or the original value if it cannot be resolved\n */\nfunction resolveIncrementKey(increments, key) {\n if (increments[key] != null) return key;\n if (/^\\d+$/.test(String(key))) return Object.keys(increments)[Number(key)] ?? key;\n return key;\n}\n\n/**\n * Real-time clock controller for advancing game time automatically.\n */\nexport default class TimeClock {\n /** @type {number|null} Interval ID for the 60s advance tick */\n static #advanceIntervalId = null;\n\n /** @type {number|null} Interval ID for the 1s visual tick */\n static #visualIntervalId = null;\n\n /** @type {number} Game seconds accumulated since last advance */\n static #accumulatedSeconds = 0;\n\n /** @type {number} Game seconds handed to game.time.advance but not yet landed */\n static #pendingCommit = 0;\n\n /** @type {boolean} Guard against overlapping commits */\n static #committing = false;\n\n /** @type {boolean} Whether the clock is currently running */\n static #running = false;\n\n /** @type {boolean} Whether the clock is locked (prevents all time advancement) */\n static #locked = false;\n\n /** @type {number} Time increment in seconds per tick (for manual advancement) */\n static #increment = 60;\n\n /** @type {string} Current increment key (for manual advancement) */\n static #incrementKey = 'minute';\n\n /** @type {number} Real-time clock speed (game seconds per real second, from settings) */\n static #realTimeSpeed = 1;\n\n /** @type {Map} Per-app settings */\n static #appSettings = new Map();\n\n /**\n * Whether combat should block clock operations.\n * @returns {boolean} True if combat is active and the setting does not allow running during combat\n */\n static get #combatBlocks() {\n return game.combat?.started && !game.settings.get(MODULE.ID, SETTINGS.CLOCK_RUN_DURING_COMBAT);\n }\n\n /**\n * Whether the clock is running.\n * @returns {boolean} True if running\n */\n static get running() {\n return this.#running;\n }\n\n /**\n * Whether the clock is locked.\n * @returns {boolean} True if locked\n */\n static get locked() {\n return this.#locked;\n }\n\n /**\n * Toggle the clock lock state. When locked, all time advancement is blocked.\n */\n static async toggleLock() {\n this.#locked = !this.#locked;\n if (this.#locked && this.#running) this.stop();\n await game.settings.set(MODULE.ID, SETTINGS.CLOCK_LOCKED, this.#locked);\n ui.notifications.info(this.#locked ? 'CALENDARIA.TimeClock.Locked' : 'CALENDARIA.TimeClock.Unlocked', { localize: true });\n Hooks.callAll(HOOKS.CLOCK_START_STOP, { running: this.#running, increment: this.#increment, locked: this.#locked });\n ATLAS.log(3, `Clock lock ${this.#locked ? 'engaged' : 'released'}`);\n }\n\n /**\n * Current time increment in seconds (for manual advancement).\n * @returns {number} Increment in seconds\n */\n static get increment() {\n return this.#increment;\n }\n\n /**\n * Current increment key (for manual advancement).\n * @returns {string} Increment key\n */\n static get incrementKey() {\n return this.#incrementKey;\n }\n\n /**\n * Real-time clock speed (game seconds per real second).\n * @returns {number} Speed multiplier\n */\n static get realTimeSpeed() {\n return this.#realTimeSpeed;\n }\n\n /**\n * Whether the real-time clock is disabled (speed set to 0).\n * @returns {boolean} True if disabled\n */\n static get disabled() {\n return this.#realTimeSpeed === 0;\n }\n\n /**\n * Predicted world time including accumulated seconds for UI display.\n * @returns {number} Predicted world time in seconds\n */\n static get predictedWorldTime() {\n return game.time.worldTime + this.#accumulatedSeconds + this.#pendingCommit;\n }\n\n /**\n * Check if the current user can adjust time.\n * @returns {boolean} True if user has permission to adjust time\n */\n static canAdjustTime() {\n return canChangeDateTime();\n }\n\n /**\n * Initialize the TimeClock and register socket listeners.\n */\n static initialize() {\n this.setIncrement('minute');\n this.#locked = game.settings.get(MODULE.ID, SETTINGS.CLOCK_LOCKED) ?? false;\n this.loadSpeedFromSettings();\n Hooks.on(HOOKS.CLOCK_UPDATE, this.#onRemoteClockUpdate.bind(this));\n Hooks.on('pauseGame', this.#onPauseGame.bind(this));\n Hooks.on('combatStart', this.#onCombatStart.bind(this));\n Hooks.on('deleteCombat', this.#onCombatEnd.bind(this));\n this.#autoStartIfSynced();\n ATLAS.log(3, 'TimeClock initialized');\n }\n\n /**\n * Auto-start clock if sync enabled and game unpaused.\n */\n static #autoStartIfSynced() {\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (!game.settings.get(MODULE.ID, SETTINGS.SYNC_CLOCK_PAUSE)) return;\n if (this.#locked || this.disabled || game.paused || this.#combatBlocks) return;\n this.start();\n ATLAS.log(3, 'Clock auto-started (sync enabled, game unpaused)');\n }\n\n /**\n * Load real-time clock speed from settings.\n */\n static loadSpeedFromSettings() {\n const wasDisabled = this.disabled;\n const multiplier = game.settings.get(MODULE.ID, SETTINGS.TIME_SPEED_MULTIPLIER) ?? 1;\n const incrementKey = game.settings.get(MODULE.ID, SETTINGS.TIME_SPEED_INCREMENT) || 'second';\n const increments = getTimeIncrements();\n const incrementSeconds = increments[incrementKey] || 1;\n this.#realTimeSpeed = multiplier * incrementSeconds;\n ATLAS.log(3, `TimeClock real-time speed set to: ${this.#realTimeSpeed} game seconds per real second (${multiplier} ${incrementKey}s)`);\n if (this.#running) {\n if (this.disabled) this.stop();\n else {\n this.#flushAccumulated();\n this.#stopIntervals();\n this.#startIntervals();\n }\n }\n if (wasDisabled !== this.disabled) {\n Hooks.callAll(HOOKS.CLOCK_START_STOP, { running: this.#running, increment: this.#increment, disabled: this.disabled });\n }\n }\n\n /**\n * Handle game pause/unpause to sync clock state.\n * @param {boolean} paused - Whether the game is paused\n */\n static #onPauseGame(paused) {\n if (!game.settings.get(MODULE.ID, SETTINGS.SYNC_CLOCK_PAUSE)) return;\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (paused) {\n if (this.#running) this.stop();\n ATLAS.log(3, 'Clock stopped: game paused');\n } else if (!this.#combatBlocks && !this.#locked && !this.disabled) {\n if (!this.#running) this.start();\n ATLAS.log(3, 'Clock started at configured speed (game unpaused)');\n }\n }\n\n /**\n * Handle combat start to pause clock.\n * @param {object} _combat - The combat that started\n */\n static #onCombatStart(_combat) {\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (game.settings.get(MODULE.ID, SETTINGS.CLOCK_RUN_DURING_COMBAT)) return;\n if (this.#running) {\n this.stop();\n ATLAS.log(3, 'Clock stopped: combat started');\n }\n }\n\n /**\n * Handle combat end to resume clock.\n * @param {object} _combat - The combat that ended\n */\n static #onCombatEnd(_combat) {\n if (!CalendariaSocket.isPrimaryGM()) return;\n if (!game.settings.get(MODULE.ID, SETTINGS.SYNC_CLOCK_PAUSE)) return;\n if (!this.#locked && !this.disabled && !game.paused && !this.#running) {\n this.start();\n ATLAS.log(3, 'Clock started at configured speed (combat ended)');\n }\n }\n\n /**\n * Start the real-time clock.\n * @param {object} [options] - Start options\n * @param {boolean} [options.broadcast] - Whether to broadcast to other clients\n */\n static start({ broadcast = true } = {}) {\n if (this.#running) return;\n if (this.#locked || this.disabled) {\n ATLAS.log(3, `Clock start blocked: ${this.disabled ? 'disabled (speed 0)' : 'locked'}`);\n return;\n }\n if (!this.canAdjustTime()) {\n ui.notifications.warn('CALENDARIA.TimeClock.NoPermission', { localize: true });\n return;\n }\n if (this.#combatBlocks) {\n ATLAS.log(3, 'Clock start blocked: combat active');\n ui.notifications.clear();\n ui.notifications.warn('CALENDARIA.TimeClock.ClockBlocked', { localize: true });\n return;\n }\n if (game.settings.get(MODULE.ID, SETTINGS.SYNC_CLOCK_PAUSE) && game.paused) {\n ATLAS.log(3, 'Clock start blocked: game paused');\n ui.notifications.clear();\n ui.notifications.warn('CALENDARIA.TimeClock.ClockBlocked', { localize: true });\n return;\n }\n this.#running = true;\n this.#accumulatedSeconds = 0;\n this.#pendingCommit = 0;\n this.#startIntervals();\n Hooks.callAll(HOOKS.CLOCK_START_STOP, { running: true, increment: this.#increment });\n if (broadcast && CalendariaSocket.isPrimaryGM()) CalendariaSocket.emitClockUpdate(true, this.#increment);\n }\n\n /**\n * Stop the real-time clock.\n * @param {object} [options] - Stop options\n * @param {boolean} [options.broadcast] - Whether to broadcast to other clients\n */\n static stop({ broadcast = true } = {}) {\n if (!this.#running) return;\n this.#running = false;\n this.#flushAccumulated();\n this.#stopIntervals();\n ATLAS.log(3, 'TimeClock stopped');\n Hooks.callAll(HOOKS.CLOCK_START_STOP, { running: false, increment: this.#increment });\n if (broadcast && CalendariaSocket.isPrimaryGM()) CalendariaSocket.emitClockUpdate(false, this.#increment);\n }\n\n /**\n * Toggle the clock running state.\n */\n static toggle() {\n if (this.#running) this.stop();\n else this.start();\n }\n\n /**\n * Set the time increment for manual advancement.\n * @param {string} key - Increment key from getTimeIncrements()\n */\n static setIncrement(key) {\n const increments = getTimeIncrements();\n key = resolveIncrementKey(increments, key);\n if (!increments[key]) return;\n this.#incrementKey = key;\n this.#increment = increments[key];\n ATLAS.log(3, `TimeClock manual increment set to: ${key} (${this.#increment}s)`);\n }\n\n /**\n * Get settings for a specific application.\n * @param {string} appId - Application identifier\n * @returns {{incrementKey: string, multiplier: number}} - Application settings\n */\n static getAppSettings(appId) {\n if (!this.#appSettings.has(appId)) this.#appSettings.set(appId, { incrementKey: 'minute', multiplier: 1 });\n return this.#appSettings.get(appId);\n }\n\n /**\n * Set increment for a specific application.\n * @param {string} appId - Application identifier\n * @param {string} key - Increment key from getTimeIncrements()\n */\n static setAppIncrement(appId, key) {\n const increments = getTimeIncrements();\n key = resolveIncrementKey(increments, key);\n if (!increments[key]) {\n ATLAS.log(2, `Invalid increment key: ${key}`);\n return;\n }\n const settings = this.getAppSettings(appId);\n settings.incrementKey = key;\n }\n\n /**\n * Set multiplier for a specific application.\n * @param {string} appId - Application identifier\n * @param {number} multiplier - Multiplier value (0.25 to 10)\n */\n static setAppMultiplier(appId, multiplier) {\n const settings = this.getAppSettings(appId);\n settings.multiplier = Math.max(0.25, Math.min(10, multiplier));\n }\n\n /**\n * Advance time using a specific application's settings.\n * @param {string} appId - Application identifier\n */\n static async forwardFor(appId) {\n if (!this.canAdjustTime()) return;\n const settings = this.getAppSettings(appId);\n const increments = getTimeIncrements();\n const increment = increments[settings.incrementKey] ?? 60;\n const amount = increment * settings.multiplier;\n if (!game.user.isGM) {\n CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'advance', delta: amount });\n return;\n }\n if (this.#running) await this.#flushAccumulated();\n await this.#advanceWithCinematic(amount);\n ATLAS.log(3, `Time advanced by ${amount}s for ${appId}`);\n }\n\n /**\n * Reverse time using a specific application's settings.\n * @param {string} appId - Application identifier\n */\n static async reverseFor(appId) {\n if (!this.canAdjustTime()) return;\n const settings = this.getAppSettings(appId);\n const increments = getTimeIncrements();\n const increment = increments[settings.incrementKey] ?? 60;\n const amount = increment * settings.multiplier;\n if (!game.user.isGM) {\n CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'advance', delta: -amount });\n return;\n }\n if (this.#running) await this.#flushAccumulated();\n await game.time.advance(-amount);\n ATLAS.log(3, `Time reversed by ${amount}s for ${appId}`);\n }\n\n /**\n * Advance time by the current increment.\n * @param {number} [multiplier] - Multiplier for the increment\n */\n static async forward(multiplier = 1) {\n if (!this.canAdjustTime()) return;\n const amount = this.#increment * multiplier;\n if (!game.user.isGM) {\n CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'advance', delta: amount });\n return;\n }\n if (this.#running) await this.#flushAccumulated();\n await this.#advanceWithCinematic(amount);\n ATLAS.log(3, `Time advanced by ${amount}s (${multiplier}x)`);\n }\n\n /**\n * Reverse time by the current increment.\n * @param {number} [multiplier] - Multiplier for the increment\n */\n static async reverse(multiplier = 1) {\n if (!this.canAdjustTime()) return;\n const amount = this.#increment * multiplier;\n if (!game.user.isGM) {\n CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'advance', delta: -amount });\n return;\n }\n if (this.#running) await this.#flushAccumulated();\n await game.time.advance(-amount);\n ATLAS.log(3, `Time reversed by ${amount}s (${multiplier}x)`);\n }\n\n /**\n * Advance time by a specific amount.\n * @param {number} seconds - Seconds to advance (negative to reverse)\n */\n static async advance(seconds) {\n if (!this.canAdjustTime()) return;\n if (!game.user.isGM) {\n CalendariaSocket.emit(SOCKET_TYPES.TIME_REQUEST, { action: 'advance', delta: seconds });\n return;\n }\n if (this.#running) await this.#flushAccumulated();\n await this.#advanceWithCinematic(seconds);\n ATLAS.log(3, `Time advanced by ${seconds}s`);\n }\n\n /**\n * Advance with cinematic threshold check.\n * @param {number} seconds - Seconds to advance\n * @private\n */\n static async #advanceWithCinematic(seconds) {\n if (seconds > 0 && CinematicOverlay.shouldTrigger(seconds)) {\n await CinematicOverlay.triggerFromAdvance(seconds);\n return;\n }\n await game.time.advance(seconds);\n }\n\n /**\n * Advance interval period in milliseconds (from setting, default 60s).\n * @returns {number} Interval in milliseconds\n */\n static get ADVANCE_INTERVAL_MS() {\n return (game.settings?.get(MODULE.ID, SETTINGS.TIME_ADVANCE_INTERVAL) ?? 60) * 1000;\n }\n\n /**\n * Restart advance/visual intervals (e.g. after changing advance interval setting).\n */\n static restartIntervals() {\n if (!this.#running) return;\n this.#flushAccumulated();\n this.#stopIntervals();\n this.#startIntervals();\n }\n\n /**\n * Start the visual tick and advance intervals.\n * @private\n */\n static #startIntervals() {\n if (this.#visualIntervalId) return;\n const speed = this.#realTimeSpeed;\n const intervalMs = this.ADVANCE_INTERVAL_MS;\n const directMode = intervalMs <= 1000;\n ATLAS.log(3, `TimeClock intervals started (speed: ${speed}, advance every ${intervalMs / 1000}s, direct: ${directMode})`);\n if (directMode) {\n let advancing = false;\n this.#visualIntervalId = setInterval(async () => {\n if (!TimeClock.#running || TimeClock.#combatBlocks || advancing) return;\n advancing = true;\n try {\n if (CalendariaSocket.isPrimaryGM()) await game.time.advance(speed);\n Hooks.callAll(HOOKS.VISUAL_TICK, { predictedWorldTime: game.time.worldTime });\n } finally {\n advancing = false;\n }\n }, 1000);\n return;\n }\n this.#visualIntervalId = setInterval(() => {\n if (!TimeClock.#running || TimeClock.#combatBlocks) return;\n this.#accumulatedSeconds += speed;\n const predicted = this.predictedWorldTime;\n Hooks.callAll(HOOKS.VISUAL_TICK, { predictedWorldTime: predicted });\n if (!this.#committing && CalendariaSocket.isPrimaryGM() && this.#accumulatedSeconds > 0) {\n const cal = game.time?.calendar;\n if (cal) {\n const committed = cal.timeToComponents(game.time.worldTime);\n const pred = cal.timeToComponents(predicted);\n if (committed.dayOfMonth !== pred.dayOfMonth || committed.month !== pred.month || committed.year !== pred.year) {\n this.#commit(this.#accumulatedSeconds);\n }\n }\n }\n }, 1000);\n this.#advanceIntervalId = setInterval(async () => {\n if (!TimeClock.#running || TimeClock.#combatBlocks) return;\n if (!CalendariaSocket.isPrimaryGM()) return;\n await this.#commit(this.#accumulatedSeconds);\n }, intervalMs);\n }\n\n /**\n * Stop both intervals and reset accumulated seconds. Any in-flight commit is left to retire on its own.\n * @private\n */\n static #stopIntervals() {\n if (this.#visualIntervalId) {\n clearInterval(this.#visualIntervalId);\n this.#visualIntervalId = null;\n }\n if (this.#advanceIntervalId) {\n clearInterval(this.#advanceIntervalId);\n this.#advanceIntervalId = null;\n }\n this.#accumulatedSeconds = 0;\n }\n\n /**\n * Commit accumulated seconds to world time, holding them in #pendingCommit while in flight.\n * @param {number} seconds - Seconds to commit\n * @private\n */\n static async #commit(seconds) {\n if (!(seconds > 0) || this.#committing) return;\n this.#committing = true;\n this.#accumulatedSeconds = Math.max(0, this.#accumulatedSeconds - seconds);\n this.#pendingCommit += seconds;\n try {\n await game.time.advance(seconds);\n } catch (error) {\n this.#pendingCommit = Math.max(0, this.#pendingCommit - seconds);\n ATLAS.log(1, 'TimeClock failed to advance world time:', error);\n } finally {\n this.#committing = false;\n }\n }\n\n /**\n * Retire an in-flight commit, or drain locally accumulated seconds on clients that never commit.\n * @param {number} dt - World time delta in seconds\n * @private\n */\n static #reconcileCommit(dt) {\n if (!(dt > 0)) return;\n const fromPending = Math.min(this.#pendingCommit, dt);\n this.#pendingCommit -= fromPending;\n const rest = dt - fromPending;\n if (rest > 0) this.#accumulatedSeconds = Math.max(0, this.#accumulatedSeconds - rest);\n }\n\n /**\n * Flush any accumulated seconds as a final advance before stopping.\n * @private\n */\n static async #flushAccumulated() {\n if (!CalendariaSocket.isPrimaryGM()) return;\n await this.#commit(this.#accumulatedSeconds);\n }\n\n /**\n * Handle remote clock update from socket.\n * @param {object} data - Clock update data\n * @param {boolean} data.running - Whether clock is running\n * @param {number} data.ratio - Time increment\n * @private\n */\n static #onRemoteClockUpdate({ running, ratio }) {\n ATLAS.log(3, `Remote clock update: running=${running}, ratio=${ratio}`);\n const increments = getTimeIncrements();\n const key = Object.entries(increments).find(([, v]) => v === ratio)?.[0];\n if (key) {\n this.#incrementKey = key;\n this.#increment = ratio;\n }\n if (running && !this.#running) {\n this.#running = true;\n this.#accumulatedSeconds = 0;\n this.#pendingCommit = 0;\n this.#startIntervals();\n Hooks.callAll(HOOKS.CLOCK_START_STOP, { running: true, increment: this.#increment });\n } else if (!running && this.#running) {\n this.#running = false;\n this.#stopIntervals();\n Hooks.callAll(HOOKS.CLOCK_START_STOP, { running: false, increment: this.#increment });\n }\n }\n\n /**\n * Get the current time formatted as HH:MM:SS.\n * @returns {string} Formatted time string\n */\n static getFormattedTime() {\n const cal = game.time?.calendar;\n if (!cal) return '--:--:--';\n const components = cal.timeToComponents(game.time.worldTime);\n const h = String(components.hour ?? 0).padStart(2, '0');\n const m = String(components.minute ?? 0).padStart(2, '0');\n const s = String(components.second ?? 0).padStart(2, '0');\n return `${h}:${m}:${s}`;\n }\n\n /**\n * Get the current date formatted.\n * @returns {string} Formatted date string\n */\n static getFormattedDate() {\n const cal = game.time?.calendar;\n if (!cal) return '';\n const components = cal.timeToComponents(game.time.worldTime);\n const monthData = cal.monthsArray?.[components.month];\n const monthNameRaw = monthData?.name ?? `Month ${components.month + 1}`;\n const monthName = _loc(monthNameRaw);\n const day = components.dayOfMonth + 1;\n const yearZero = cal.years?.yearZero ?? 0;\n const year = components.year + yearZero;\n return `${day} ${monthName}, ${year}`;\n }\n\n /**\n * Block combat round/turn time advancement when the clock is locked or running during combat.\n * @param {object} _combat - The combat document\n * @param {object} _updateData - The update data\n * @param {object} updateOptions - The update options containing worldTime delta\n */\n static onCombatTimeBlock(_combat, _updateData, updateOptions) {\n if (!updateOptions.worldTime) return;\n if (TimeClock.locked) updateOptions.worldTime.delta = 0;\n else if (game.settings.get(MODULE.ID, SETTINGS.CLOCK_RUN_DURING_COMBAT)) updateOptions.worldTime.delta = 0;\n }\n\n /**\n * Unified updateWorldTime handler — dispatches to all time subsystems.\n * @param {number} worldTime - The new world time\n * @param {number} dt - The delta time in seconds\n */\n static async onUpdateWorldTime(worldTime, dt) {\n TimeClock.#reconcileCommit(dt);\n ReminderScheduler.onUpdateWorldTime(worldTime, dt);\n TimeTracker.onUpdateWorldTime(worldTime, dt);\n Hooks.callAll(HOOKS.WORLD_TIME_UPDATED, worldTime, dt);\n updateDarknessFromWorldTime(worldTime, dt);\n await EventScheduler.onUpdateWorldTime(worldTime, dt);\n }\n}\n","import { BigCal, Chronicle, MiniCal } from '../applications/_module.mjs';\nimport { CalendarManager, CalendarRegistry } from '../calendar/_module.mjs';\nimport { COMPASS_DIRECTIONS, HOOKS, MODULE, SETTINGS, WIND_SPEEDS } from '../constants.mjs';\nimport { NoteManager, addDays, addMonths, addYears, daysBetween, getNextOccurrences, monthsBetween } from '../notes/_module.mjs';\nimport TimeClock, { getTimeIncrements } from '../time/time-clock.mjs';\nimport {\n ECLIPSE_TYPES,\n PRESET_FORMATTERS,\n formatCustom,\n getEclipseAtDate,\n getMoonPhasePosition,\n getNextConvergence,\n getNextEclipse,\n getNextFullMoon,\n isMoonFull,\n isMoonVisible,\n resolveFormatString,\n timeSince\n} from '../utils/_module.mjs';\nimport { WeatherManager, getPreset } from '../weather/_module.mjs';\n\n/** @type {Object} Combined handler map for all enricher types. */\nexport const handlers = {\n date: enrichDate,\n time: enrichTime,\n weekday: enrichWeekday,\n season: enrichSeason,\n era: enrichEra,\n cycle: enrichCycle,\n festival: enrichFestival,\n restday: enrichRestDay,\n countdown: enrichCountdown,\n countup: enrichCountup,\n elapsed: enrichCountup,\n between: enrichBetween,\n timeuntil: enrichTimeUntil,\n datemath: enrichDateMath,\n calname: enrichCalName,\n month: enrichMonth,\n year: enrichYear,\n dayofyear: enrichDayOfYear,\n yearprogress: enrichYearProgress,\n leapyear: enrichLeapYear,\n intercalary: enrichIntercalary,\n daysinyear: enrichDaysInYear,\n sunrise: enrichSunrise,\n sunset: enrichSunset,\n daylight: enrichDaylight,\n isdaytime: enrichIsDaytime,\n dayprogress: enrichDayProgress,\n nightprogress: enrichNightProgress,\n untilsunrise: enrichUntilSunrise,\n untilsunset: enrichUntilSunset,\n moon: enrichMoon,\n moons: enrichMoons,\n nextfullmoon: enrichNextFullMoon,\n convergence: enrichConvergence,\n eclipse: enrichEclipse,\n nexteclipse: enrichNextEclipse,\n weather: enrichWeather,\n temperature: enrichTemperature,\n wind: enrichWind,\n precipitation: enrichPrecipitation,\n weathericon: enrichWeatherIcon,\n zone: enrichZone,\n forecast: enrichForecast,\n advancetotime: enrichAdvanceToTime,\n advanceinterval: enrichAdvanceInterval,\n advancetopreset: enrichAdvanceToPreset,\n settime: enrichSetTime,\n jumptodate: enrichJumpToDate,\n toggleclock: enrichToggleClock,\n setweather: enrichSetWeather,\n event: enrichEvent,\n notes: enrichNotes,\n next: enrichNext,\n category: enrichCategory,\n chronicle: enrichChronicle,\n summary: enrichSummary,\n almanac: enrichAlmanac,\n format: enrichFormat,\n compare: enrichCompare,\n peek: enrichPeek\n};\n\n/** @type {Object} Eclipse type display name localization keys. */\nconst ECLIPSE_TYPE_LABELS = {\n [ECLIPSE_TYPES.TOTAL_SOLAR]: 'CALENDARIA.Eclipse.TotalSolar',\n [ECLIPSE_TYPES.PARTIAL_SOLAR]: 'CALENDARIA.Eclipse.PartialSolar',\n [ECLIPSE_TYPES.ANNULAR_SOLAR]: 'CALENDARIA.Eclipse.AnnularSolar',\n [ECLIPSE_TYPES.TOTAL_LUNAR]: 'CALENDARIA.Eclipse.TotalLunar',\n [ECLIPSE_TYPES.PARTIAL_LUNAR]: 'CALENDARIA.Eclipse.PartialLunar',\n [ECLIPSE_TYPES.PENUMBRAL_LUNAR]: 'CALENDARIA.Eclipse.PenumbralLunar'\n};\n\n/** Time-of-day presets accepted by the advanceToPreset enricher. */\nconst ADVANCE_PRESETS = ['sunrise', 'midday', 'noon', 'sunset', 'midnight'];\n\n/** Short labels for interval units when assembling the button text. */\nconst INTERVAL_UNIT_LABELS = { second: 's', round: 'r', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', season: 'sea', year: 'y' };\n\n/**\n * Convert public date to internal format.\n * @param {object} date - Public date {year, month, day}\n * @returns {object} Internal date {year, month, dayOfMonth}\n */\nfunction toInternal(date) {\n const d = { ...date };\n if (d.month != null) d.month -= 1;\n if (d.day != null) {\n d.dayOfMonth = d.day - 1;\n delete d.day;\n }\n return d;\n}\n\n/**\n * Convert internal date to public format.\n * @param {object} date - Internal date {year, month, dayOfMonth}\n * @returns {object} Public date {year, month, day}\n */\nfunction toPublic(date) {\n const d = { ...date };\n d.month = (d.month ?? 0) + 1;\n d.day = (d.dayOfMonth ?? 0) + 1;\n delete d.dayOfMonth;\n return d;\n}\n\n/**\n * Resolve calendar context from config.\n * @param {object} config - Parsed enricher config (checks config.cal)\n * @returns {{calendar: object|null, components: object}} Calendar instance and time components\n */\nfunction resolveCalendar(config) {\n if (config?.cal) {\n const calendar = CalendarRegistry.get(config.cal);\n if (calendar) {\n const components = calendar.timeToComponents(game.time.worldTime);\n return { calendar, components };\n }\n }\n return { calendar: CalendarManager.getActiveCalendar(), components: game.time.components };\n}\n\n/**\n * Convert internal date to public format, applying yearZero.\n * @param {object} internalDate - Internal date {year, month, dayOfMonth}\n * @param {object} [calendar] - Calendar instance (defaults to active)\n * @returns {object} Public date {year, month, day}\n */\nfunction internalToPublic(internalDate, calendar = null) {\n const cal = calendar || CalendarManager.getActiveCalendar();\n const yearZero = cal?.years?.yearZero ?? 0;\n return { year: internalDate.year + yearZero, month: (internalDate.month ?? 0) + 1, day: (internalDate.dayOfMonth ?? 0) + 1 };\n}\n\n/**\n * Get current date and time.\n * @param {object} [calendar] - Calendar instance (defaults to active)\n * @param {object} [components] - Time components (defaults to game.time.components)\n * @returns {object} {year, month, day, hour, minute, second}\n */\nfunction getCurrentDateTime(calendar = null, components = null) {\n const comp = components || game.time.components;\n const cal = calendar || CalendarManager.getActiveCalendar();\n const yearZero = cal?.years?.yearZero ?? 0;\n const { dayOfMonth: _dom, ...rest } = comp;\n return { ...rest, year: comp.year + yearZero, month: comp.month + 1, day: _dom + 1 };\n}\n\n/**\n * Format a date using a preset or token string.\n * @param {object|null} components - Public date (1-indexed) or null for current\n * @param {string} [formatOrPreset] - Preset name or format string\n * @param {object} [calendar] - Calendar instance (defaults to active)\n * @returns {string} Formatted date string\n */\nfunction formatDate(components = null, formatOrPreset = 'dateLong', calendar = null) {\n const cal = calendar || CalendarManager.getActiveCalendar();\n if (!cal) return '';\n const raw = components || game.time.components;\n const formatted = {\n ...raw,\n year: components ? raw.year : raw.year + (cal.years?.yearZero ?? 0),\n month: components ? (raw.month ?? 1) - 1 : raw.month,\n dayOfMonth: components ? (raw.day ?? 1) - 1 : (raw.dayOfMonth ?? 0)\n };\n delete formatted.day;\n if (PRESET_FORMATTERS[formatOrPreset]) return PRESET_FORMATTERS[formatOrPreset](cal, formatted);\n const formatStr = resolveFormatString(formatOrPreset);\n return formatCustom(cal, formatted, formatStr);\n}\n\n/**\n * Parse enricher config string.\n * @param {string} configStr - Raw config string from regex match\n * @returns {object} Parsed config with values array, raw string, and key-value properties\n */\nfunction parseConfig(configStr) {\n const config = { values: [], raw: configStr?.trim() || '' };\n if (!config.raw) return config;\n for (const part of config.raw.match(/(?:[^\\s\"]+|\"[^\"]*\")+/g) ?? []) {\n if (!part) continue;\n const eqIndex = part.indexOf('=');\n if (eqIndex > 0) {\n const key = part.slice(0, eqIndex);\n let value = part.slice(eqIndex + 1).replace(/(^\"|\"$)/g, '');\n if (value === 'true') value = true;\n else if (value === 'false') value = false;\n else if (value !== '' && !isNaN(value)) value = Number(value);\n config[key] = value;\n } else {\n config.values.push(part.replace(/(^\"|\"$)/g, ''));\n }\n }\n return config;\n}\n\n/**\n * Parse a date from positional values.\n * @param {string[]} values - Array of positional string values\n * @returns {object|null} Date object {year, month, day} (1-indexed) or null\n */\nfunction parseDateFromValues(values) {\n const calendar = CalendarManager.getActiveCalendar();\n if (!calendar || !values?.length) return null;\n const months = calendar.monthsArray || [];\n let day, monthIdx, year;\n let found = false;\n for (let i = 0; i < values.length; i++) {\n if (!isNaN(values[i])) continue;\n const val = values[i].toLowerCase();\n const idx = months.findIndex((m) => {\n const name = _loc(m.name).toLowerCase();\n const abbr = m.abbreviation ? _loc(m.abbreviation).toLowerCase() : '';\n return name === val || abbr === val;\n });\n if (idx >= 0) {\n monthIdx = idx + 1;\n found = true;\n const before = values\n .slice(0, i)\n .filter((v) => !isNaN(v))\n .map(Number);\n const after = values\n .slice(i + 1)\n .filter((v) => !isNaN(v))\n .map(Number);\n if (before.length) day = before[0];\n if (after.length) year = after[0];\n break;\n }\n }\n if (!found) {\n const nonNumeric = values.some((v) => isNaN(v));\n if (nonNumeric) return null;\n const nums = values.filter((v) => !isNaN(v)).map(Number);\n if (nums.length >= 3) [day, monthIdx, year] = nums;\n else if (nums.length === 2) [day, monthIdx] = nums;\n else if (nums.length === 1) day = nums[0];\n else return null;\n }\n const monthCount = months.length;\n if (monthIdx != null && monthCount > 0 && (monthIdx < 1 || monthIdx > monthCount)) return null;\n const current = getCurrentDateTime();\n return { year: year ?? current.year, month: monthIdx ?? current.month, day: day ?? current.day };\n}\n\n/**\n * Create a badge enricher element.\n * @param {string} type - Enricher type name\n * @param {string|HTMLElement} content - Text content or child element\n * @param {string} configStr - Raw config for live refresh\n * @param {boolean} isLive - Whether this element should live-update\n * @param {string|null} [iconClass] - FontAwesome icon class\n * @param {string|null} [tooltip] - Tooltip text\n * @returns {HTMLElement} The enricher span element\n */\nfunction createElement(type, content, configStr, isLive = false, iconClass = null, tooltip = null) {\n const span = document.createElement('span');\n span.classList.add('calendaria-enricher', `calendaria-enricher--${type}`);\n if (iconClass) span.classList.add('calendaria-enricher--badge');\n if (isLive) {\n span.classList.add('calendaria-enricher--live');\n span.dataset.calType = type;\n span.dataset.calConfig = configStr || '';\n }\n if (iconClass) {\n const icon = document.createElement('i');\n icon.className = `fa-solid ${iconClass}`;\n icon.setAttribute('inert', '');\n span.appendChild(icon);\n span.append(typeof content === 'string' ? ` ${content}` : '');\n } else if (typeof content === 'string') {\n span.textContent = content;\n }\n if (content instanceof HTMLElement) span.appendChild(content);\n if (tooltip) span.dataset.tooltip = tooltip;\n return span;\n}\n\n/**\n * Create an error enricher element.\n * @param {string} messageKey - Localization key for the error message\n * @param {object} [data] - Data for localization string interpolation\n * @returns {HTMLElement} The error span element\n */\nfunction createErrorElement(messageKey, data = {}) {\n const span = document.createElement('span');\n span.classList.add('calendaria-enricher', 'calendaria-enricher--error');\n const message = Object.keys(data).length ? _loc(messageKey, data) : _loc(messageKey);\n span.textContent = message;\n span.dataset.tooltip = message;\n return span;\n}\n\n/**\n * Create a clickable enricher link.\n * @param {string} type - Enricher type name\n * @param {string} text - Link label text\n * @param {object} [dataset] - Data attributes\n * @param {string} [iconClass] - FontAwesome icon class\n * @param {string|null} [tooltip] - Tooltip text\n * @param {string|null} [liveConfig] - Makes the link live-updating when set\n * @returns {HTMLAnchorElement} The content link element\n */\nfunction createContentLink(type, text, dataset = {}, iconClass = 'fa-calendar-day', tooltip = null, liveConfig = null) {\n const anchor = document.createElement('a');\n anchor.classList.add('calendaria-enricher', `calendaria-enricher--${type}`, 'calendaria-enricher--link');\n const icon = document.createElement('i');\n icon.className = `fa-solid ${iconClass}`;\n icon.setAttribute('inert', '');\n anchor.appendChild(icon);\n anchor.append(` ${text}`);\n for (const [key, value] of Object.entries(dataset)) anchor.dataset[key] = value;\n if (tooltip) anchor.dataset.tooltip = tooltip;\n if (liveConfig != null) {\n anchor.classList.add('calendaria-enricher--live');\n anchor.dataset.calType = type;\n anchor.dataset.calConfig = liveConfig;\n }\n return anchor;\n}\n\n/**\n * Format decimal hours to HH:MM.\n * @param {number} hours - Hours as decimal\n * @returns {string} Formatted time string\n */\nfunction formatHoursToTime(hours) {\n const h = Math.floor(hours);\n const m = Math.round((hours - h) * 60);\n return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;\n}\n\n/**\n * Format a duration to compact string.\n * @param {object} duration - Duration with hours, minutes, seconds\n * @returns {string} Formatted duration string (e.g. \"3h 20m\")\n */\nfunction formatDuration(duration) {\n const parts = [];\n if (duration.hours) parts.push(`${duration.hours}h`);\n if (duration.minutes) parts.push(`${duration.minutes}m`);\n if (!parts.length) parts.push(`${duration.seconds || 0}s`);\n return parts.join(' ');\n}\n\n/**\n * Format a value as percentage.\n * @param {number} value - Value between 0 and 1\n * @returns {string} Formatted percentage (e.g. \"75%\")\n */\nfunction formatPercent(value) {\n return `${Math.round(value * 100)}%`;\n}\n\n/**\n * Format a day count as countdown string.\n * @param {number} days - Number of days (positive = future, negative = past)\n * @returns {string} Localized countdown string\n */\nfunction formatCountdown(days) {\n if (days === 0) return _loc('CALENDARIA.Common.Today');\n const abs = Math.abs(days);\n if (days > 0) return abs === 1 ? _loc('CALENDARIA.Enricher.Label.DayUntil') : _loc('CALENDARIA.Enricher.Label.DaysUntil', { count: abs });\n return abs === 1 ? _loc('CALENDARIA.Enricher.Label.DayAgo') : _loc('CALENDARIA.Enricher.Label.DaysAgo', { count: abs });\n}\n\n/**\n * Parse date math expression.\n * @param {string} input - Date math expression\n * @returns {Array<{amount: number, unit: string}>} Array of operations\n */\nfunction parseDateMath(input) {\n const operations = [];\n const regex = /([+-]?\\d+)([dmy])/gi;\n let match;\n while ((match = regex.exec(input)) !== null) operations.push({ amount: parseInt(match[1]), unit: match[2].toLowerCase() });\n return operations;\n}\n\n/**\n * Append a moon phase icon.\n * @param {HTMLElement} parent - Parent element to append to\n * @param {string} iconPath - SVG file path\n * @param {string|null} [color] - Hex color for tinting\n */\nfunction appendMoonIcon(parent, iconPath, color = null) {\n if (!iconPath) return;\n const img = document.createElement('img');\n img.src = iconPath;\n img.classList.add('calendaria-enricher--moon-icon');\n if (color) {\n const wrapper = document.createElement('span');\n wrapper.classList.add('calendaria-enricher--moon-icon-wrapper', 'tinted');\n wrapper.style.setProperty('--moon-color', color);\n wrapper.appendChild(img);\n parent.appendChild(wrapper);\n } else {\n parent.appendChild(img);\n }\n parent.append(' ');\n}\n\n/**\n * Append a weather icon.\n * @param {HTMLElement} parent - Parent element to append to\n * @param {string} iconClass - FontAwesome icon class\n */\nfunction appendWeatherIcon(parent, iconClass) {\n if (!iconClass) return;\n const i = document.createElement('i');\n i.className = `fa-solid ${iconClass}`;\n i.setAttribute('inert', '');\n parent.appendChild(i);\n parent.append(' ');\n}\n\n/**\n * Get wind speed label.\n * @param {number} speed - Wind speed on 0-5 scale\n * @returns {string} Localized wind label\n */\nfunction getWindLabel(speed) {\n const entry = Object.values(WIND_SPEEDS).find((w) => w.value === speed);\n return _loc(entry?.label || WIND_SPEEDS.CALM.label);\n}\n\n/**\n * Convert degrees to compass direction.\n * @param {number} degrees - Compass degrees (0-360)\n * @returns {string} Direction abbreviation (N, NNE, NE, ENE, etc.)\n */\nfunction degreesToCompass(degrees) {\n const entries = Object.entries(COMPASS_DIRECTIONS);\n let closest = entries[0];\n let minDiff = 360;\n for (const [id, deg] of entries) {\n const diff = Math.abs(((degrees - deg + 540) % 360) - 180);\n if (diff < minDiff) {\n minDiff = diff;\n closest = [id, deg];\n }\n }\n return closest[0];\n}\n\n/**\n * Date enricher.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichDate(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (config.approx) {\n const formatted = formatDate(null, 'approxDate', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.TimeOfYear', { value: formatted });\n return createElement('date', label || formatted, 'approx=true', true, 'fa-calendar', tooltip);\n }\n if (config.time) {\n const formatted = formatDate(null, 'datetime24', calendar);\n const current = getCurrentDateTime(calendar, components);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.CurrentDateTime', { value: formatted });\n return createContentLink('date', label || formatted, { calYear: current.year, calMonth: current.month, calDay: current.day }, 'fa-calendar-days', tooltip, 'time=true');\n }\n if (config.values.length > 0) {\n const date = parseDateFromValues(config.values);\n if (!date) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n const formatted = formatDate(date, config.format || 'dateLong', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Date', { value: formatted });\n return createContentLink('date', label || formatted, { calYear: date.year, calMonth: date.month, calDay: date.day }, 'fa-calendar', tooltip);\n }\n const preset = config.format || 'dateLong';\n const formatted = formatDate(null, preset, calendar);\n const current = getCurrentDateTime(calendar, components);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.CurrentDate', { value: formatted });\n return createContentLink('date', label || formatted, { calYear: current.year, calMonth: current.month, calDay: current.day }, 'fa-calendar', tooltip, `format=${preset}`);\n}\n\n/**\n * Time enricher.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichTime(config, label) {\n const { calendar } = resolveCalendar(config);\n if (config.approx) {\n const formatted = formatDate(null, 'approxTime', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.TimeOfDay', { value: formatted });\n return createElement('time', label || formatted, 'approx=true', true, 'fa-clock', tooltip);\n }\n const preset = config['12h'] ? 'time12' : 'time24';\n const formatted = formatDate(null, preset, calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.CurrentTime', { value: formatted });\n const configStr = config['12h'] ? '12h=true' : '';\n return createElement('time', label || formatted, configStr, true, 'fa-clock', tooltip);\n}\n\n/**\n * Current weekday name.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichWeekday(config, label) {\n const { calendar } = resolveCalendar(config);\n const weekday = calendar?.getWeekdayForDate?.();\n if (!weekday) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const text = label || weekday.name;\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Weekday', { value: weekday.name });\n return createElement('weekday', text, '', true, 'fa-calendar-week', tooltip);\n}\n\n/**\n * Current season name.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichSeason(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const rawSeason = calendar?.getCurrentSeason?.(components);\n const season = WeatherManager.applySeasonAlias(rawSeason, WeatherManager.getActiveZone?.(null, game.scenes?.active));\n if (!season) return createErrorElement('CALENDARIA.Enricher.Error.NoSeasons');\n const name = _loc(season.name);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Season', { value: name });\n return createElement('season', label || name, '', true, season.icon || 'fa-leaf', tooltip);\n}\n\n/**\n * Current era name.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichEra(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const eras = calendar?.erasArray || [];\n if (!eras.length) return createErrorElement('CALENDARIA.Enricher.Error.NoEras');\n const current = getCurrentDateTime(calendar, components);\n const match = eras.find((e) => current.year >= e.startYear && (e.endYear == null || current.year <= e.endYear));\n if (!match) return createErrorElement('CALENDARIA.Enricher.Error.NoEras');\n const name = _loc(match.name);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Era', { value: name });\n return createElement('era', label || name, '', true, 'fa-landmark', tooltip);\n}\n\n/**\n * Current cycle values.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichCycle(config, label) {\n const { calendar } = resolveCalendar(config);\n const result = calendar?.getCycleValues();\n if (!result) return createErrorElement('CALENDARIA.Enricher.Error.NoCycles');\n const text = result.values.map((v) => _loc(v.entryName)).join(', ');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Cycle', { value: text });\n return createElement('cycle', label || text, '', true, 'fa-rotate', tooltip);\n}\n\n/**\n * Current festival.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichFestival(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const festival = calendar?.findFestivalDay?.();\n if (!festival) {\n const text = label || _loc('CALENDARIA.Enricher.Label.NoFestival');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Festival', { value: _loc('CALENDARIA.Enricher.Label.NoFestival') });\n return createElement('festival', text, '', true, 'fa-champagne-glasses', tooltip);\n }\n const name = _loc(festival.name);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Festival', { value: name });\n const notes = searchNotes(name);\n const festivalNote = notes.find((n) => n.flagData?.linkedFestival);\n if (festivalNote) return createContentLink('festival', label || name, { calPageId: festivalNote.id }, 'fa-champagne-glasses', tooltip, '');\n const current = getCurrentDateTime(calendar, components);\n return createContentLink('festival', label || name, { calYear: current.year, calMonth: current.month, calDay: current.day }, 'fa-champagne-glasses', tooltip, '');\n}\n\n/**\n * Rest day or work day.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichRestDay(config, label) {\n const { calendar } = resolveCalendar(config);\n const weekday = calendar?.getWeekdayForDate?.();\n const isRest = weekday?.isRestDay ?? false;\n const key = isRest ? 'CALENDARIA.Common.RestDay' : 'CALENDARIA.Enricher.Label.WorkDay';\n const text = _loc(key);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DayType', { value: text });\n return createElement('restday', label || text, '', true, 'fa-couch', tooltip);\n}\n\n/**\n * Countdown to a date.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichCountdown(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const target = parseDateFromValues(config.values);\n if (!target) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n const current = getCurrentDateTime(calendar, components);\n const days = daysBetween(toInternal(current), toInternal(target));\n const abs = Math.abs(days);\n const unit = abs === 1 ? _loc('CALENDARIA.Common.UnitDay') : _loc('CALENDARIA.Common.UnitDays');\n const text = label || formatCountdown(days);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Countdown', { date: formatDate(target, 'dateLong', calendar), value: `${abs} ${unit}` });\n return createContentLink('countdown', text, { calYear: target.year, calMonth: target.month, calDay: target.day }, 'fa-hourglass-half', tooltip, config.raw);\n}\n\n/**\n * Find a calendar note's startDate by name (case-insensitive; exact match preferred).\n * @param {string} name - Note name to look up\n * @returns {object|null} startDate {year, month, dayOfMonth} (0-indexed) or null\n */\nfunction findNoteStartByName(name) {\n const needle = name?.trim().toLowerCase();\n if (!needle) return null;\n const notes = NoteManager.getAllNotes().filter((n) => n.visible && n.flagData?.startDate);\n const match = notes.find((n) => n.name?.toLowerCase() === needle) || notes.find((n) => n.name?.toLowerCase().includes(needle));\n return match?.flagData?.startDate || null;\n}\n\n/**\n * Count up from a date.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichCountup(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const target = parseDateFromValues(config.values);\n if (!target) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n let endDate, endComponents;\n const isStatic = config.to != null;\n if (isStatic) {\n if (config.to === 'event') {\n const start = config._options?.relativeTo?.system?.startDate;\n if (!start) return createErrorElement('CALENDARIA.Enricher.Error.NoEventContext');\n endDate = { year: start.year, month: start.month + 1, day: start.dayOfMonth + 1 };\n } else {\n const toStr = String(config.to);\n endDate = parseDateFromValues(toStr.split(/\\s+/));\n if (!endDate) {\n const start = findNoteStartByName(toStr);\n if (start) endDate = { year: start.year, month: start.month + 1, day: start.dayOfMonth + 1 };\n }\n }\n if (!endDate) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n endComponents = toInternal(endDate);\n } else {\n endDate = getCurrentDateTime(calendar, components);\n endComponents = components;\n }\n const totalDays = daysBetween(toInternal(target), toInternal(endDate));\n const absDays = Math.abs(totalDays);\n const isUntil = isStatic && totalDays > 0;\n const tooltipDate = formatDate(isStatic ? endDate : target, 'dateLong', calendar);\n const formatTooltip = (value) =>\n isUntil ? _loc('CALENDARIA.Enricher.Tooltip.TimeUntil', { target: tooltipDate, value }) : _loc('CALENDARIA.Enricher.Tooltip.TimeSince', { date: tooltipDate, value });\n const unit = absDays === 1 ? _loc('CALENDARIA.Common.UnitDay') : _loc('CALENDARIA.Common.UnitDays');\n const dataset = { calYear: target.year, calMonth: target.month, calDay: target.day };\n const liveConfig = isStatic ? null : config.raw;\n if (config.relative) {\n const yearZero = calendar?.years?.yearZero ?? 0;\n const text = timeSince({ year: target.year - yearZero, month: target.month - 1, dayOfMonth: target.day - 1 }, endComponents, !!config.simple);\n return createContentLink('countup', label || text, dataset, 'fa-hourglass-half', formatTooltip(`${absDays} ${unit}`), liveConfig);\n }\n const text = label || (config.simple ? String(absDays) : `${absDays} ${unit}`);\n return createContentLink('countup', text, dataset, 'fa-hourglass-half', formatTooltip(`${absDays} ${unit}`), liveConfig);\n}\n\n/**\n * Distance between two dates.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichBetween(config, label) {\n const { calendar } = resolveCalendar(config);\n const dates = parseTwoDates(config.values);\n if (!dates) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n const useMonths = config.unit === 'months';\n if (useMonths) {\n const months = Math.abs(monthsBetween(toInternal(dates.date1), toInternal(dates.date2)));\n const unit = months === 1 ? _loc('CALENDARIA.Common.UnitMonth') : _loc('CALENDARIA.Common.UnitMonths');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DaysBetween', {\n value: `${months} ${unit}`,\n from: formatDate(dates.date1, 'dateLong', calendar),\n to: formatDate(dates.date2, 'dateLong', calendar)\n });\n return createElement('between', label || `${months} ${unit}`, null, false, 'fa-ruler-horizontal', tooltip);\n }\n const days = Math.abs(daysBetween(toInternal(dates.date1), toInternal(dates.date2)));\n const unit = days === 1 ? _loc('CALENDARIA.Common.UnitDay') : _loc('CALENDARIA.Common.UnitDays');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DaysBetween', {\n value: `${days} ${unit}`,\n from: formatDate(dates.date1, 'dateLong', calendar),\n to: formatDate(dates.date2, 'dateLong', calendar)\n });\n return createElement('between', label || `${days} ${unit}`, null, false, 'fa-ruler-horizontal', tooltip);\n}\n\n/**\n * Time until an event.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichTimeUntil(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const target = config.values[0]?.toLowerCase();\n const result = resolveTimeUntilTarget(target, calendar, components);\n if (!result) return createErrorElement('CALENDARIA.Enricher.Error.InvalidConfig');\n if (config.hours) {\n const parts = [];\n if (result.hours) parts.push(`${result.hours}h`);\n if (result.minutes) parts.push(`${result.minutes}m`);\n const text = parts.length ? parts.join(' ') : '0m';\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.TimeUntil', { target, value: text });\n return createElement('timeuntil', label || text, config.raw, true, 'fa-hourglass-half', tooltip);\n }\n const text = formatDuration(result);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.TimeUntil', { target, value: text });\n return createElement('timeuntil', label || text, config.raw, true, 'fa-hourglass-half', tooltip);\n}\n\n/**\n * Date arithmetic.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichDateMath(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const ops = parseDateMath(config.raw);\n if (!ops.length) return createErrorElement('CALENDARIA.Enricher.Error.InvalidMath', { input: config.raw });\n let date = getCurrentDateTime(calendar, components);\n for (const op of ops) {\n switch (op.unit) {\n case 'd':\n date = toPublic(addDays(toInternal(date), op.amount));\n break;\n case 'm':\n date = toPublic(addMonths(toInternal(date), op.amount));\n break;\n case 'y':\n date = toPublic(addYears(toInternal(date), op.amount));\n break;\n }\n }\n const formatted = formatDate(date, 'dateLong', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DateMath', { expr: config.raw, value: formatted });\n return createContentLink('datemath', label || formatted, { calYear: date.year, calMonth: date.month, calDay: date.day }, 'fa-calculator', tooltip, config.raw);\n}\n\n/**\n * Split values on \"to\" into two dates.\n * @param {string[]} values - Array of value tokens\n * @returns {object|null} Parsed date pair or null\n */\nfunction parseTwoDates(values) {\n const toIndex = values.indexOf('to');\n if (toIndex < 0) return null;\n const date1 = parseDateFromValues(values.slice(0, toIndex));\n const date2 = parseDateFromValues(values.slice(toIndex + 1));\n return date1 && date2 ? { date1, date2 } : null;\n}\n\n/**\n * Compute time until a named target.\n * @param {string} target - Target keyword (sunrise, sunset, midnight, midday)\n * @param {object} calendar - Resolved calendar instance\n * @param {object} components - Time components from the calendar\n * @returns {object|null} Time result or null\n */\nfunction resolveTimeUntilTarget(target, calendar, components) {\n if (!calendar) return null;\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n let targetHour;\n switch (target) {\n case 'sunrise':\n targetHour = calendar.sunrise?.(undefined, zone);\n break;\n case 'sunset':\n targetHour = calendar.sunset?.(undefined, zone);\n break;\n case 'midnight':\n targetHour = 0;\n break;\n case 'midday':\n targetHour = (calendar.days?.hoursPerDay ?? 24) / 2;\n break;\n default:\n return null;\n }\n if (targetHour == null) return null;\n const hoursPerDay = calendar.days?.hoursPerDay ?? 24;\n const minutesPerHour = calendar.days?.minutesPerHour ?? 60;\n const secondsPerMinute = calendar.days?.secondsPerMinute ?? 60;\n const currentHour = components.hour + components.minute / minutesPerHour + components.second / (minutesPerHour * secondsPerMinute);\n const hoursUntil = currentHour < targetHour ? targetHour - currentHour : hoursPerDay - currentHour + targetHour;\n const hours = Math.floor(hoursUntil);\n const remainingMinutes = (hoursUntil - hours) * minutesPerHour;\n const minutes = Math.floor(remainingMinutes);\n const seconds = Math.floor((remainingMinutes - minutes) * secondsPerMinute);\n return { hours, minutes, seconds };\n}\n\n/**\n * Calendar name.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichCalName(config, label) {\n const { calendar } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const name = calendar.metadata?.name || calendar.name || '';\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Calendar', { value: name });\n return createElement('calname', label || name, null, false, 'fa-book', tooltip);\n}\n\n/**\n * Current month name.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichMonth(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const current = getCurrentDateTime(calendar, components);\n const months = calendar.monthsArray || [];\n const monthData = months[current.month - 1];\n const name = monthData ? _loc(monthData.name) : '';\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.CurrentMonth', { value: name });\n return createElement('month', label || name, '', true, 'fa-calendar', tooltip);\n}\n\n/**\n * Current year.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichYear(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const current = getCurrentDateTime(calendar, components);\n const text = String(current.year);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.CurrentYear', { value: text });\n return createElement('year', label || text, '', true, 'fa-calendar', tooltip);\n}\n\n/**\n * Day of year.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichDayOfYear(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const dayOfYear = getDayOfYear(calendar, components);\n if (dayOfYear == null) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const text = String(dayOfYear);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DayOfYear', { value: text });\n return createElement('dayofyear', label || text, '', true, 'fa-calendar', tooltip);\n}\n\n/**\n * Year progress bar.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichYearProgress(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const current = getCurrentDateTime(calendar, components);\n const yearZero = calendar.years?.yearZero ?? 0;\n const daysInYear = calendar.getDaysInYear?.(current.year - yearZero) ?? 365;\n const dayOfYear = getDayOfYear(calendar, components);\n const progress = (dayOfYear ?? 1) / daysInYear;\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.YearProgress', { value: formatPercent(progress) });\n if (label) return createElement('yearprogress', label, '', true, null, tooltip);\n const el = createProgressElement('yearprogress', progress);\n el.dataset.tooltip = tooltip;\n return el;\n}\n\n/**\n * Leap year status.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichLeapYear(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const current = getCurrentDateTime(calendar, components);\n const yearZero = calendar.years?.yearZero ?? 0;\n const isLeap = calendar.isLeapYear?.(current.year - yearZero) ?? false;\n const key = isLeap ? 'CALENDARIA.Editor.Section.LeapYear' : 'CALENDARIA.Enricher.Label.NotLeapYear';\n const text = _loc(key);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.LeapYear', { value: text });\n return createElement('leapyear', label || text, '', true, 'fa-calendar', tooltip);\n}\n\n/**\n * Intercalary month status.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichIntercalary(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const current = getCurrentDateTime(calendar, components);\n const months = calendar.monthsArray || [];\n const monthData = months[current.month - 1];\n const isIntercalary = monthData?.type === 'intercalary';\n const key = isIntercalary ? 'CALENDARIA.Editor.MonthType.Intercalary' : 'CALENDARIA.Common.Standard';\n const text = _loc(key);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.MonthType', { value: text });\n return createElement('intercalary', label || text, '', true, 'fa-calendar', tooltip);\n}\n\n/**\n * Days in year.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichDaysInYear(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const current = getCurrentDateTime(calendar, components);\n const yearZero = calendar.years?.yearZero ?? 0;\n const days = calendar.getDaysInYear?.(current.year - yearZero) ?? 365;\n const text = String(days);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DaysInYear', { value: text });\n return createElement('daysinyear', label || text, null, false, 'fa-calendar', tooltip);\n}\n\n/**\n * Calculate day-of-year.\n * @param {object} calendar - Resolved calendar instance\n * @param {object} components - Time components from the calendar\n * @returns {number|null} Day of year or null if no calendar\n */\nfunction getDayOfYear(calendar, components) {\n if (!calendar) return null;\n const current = getCurrentDateTime(calendar, components);\n const yearZero = calendar.years?.yearZero ?? 0;\n const months = calendar.monthsArray || [];\n let dayOfYear = 0;\n for (let m = 0; m < current.month - 1; m++) dayOfYear += calendar.getDaysInMonth?.(m, current.year - yearZero) ?? (months[m]?.days || 30);\n dayOfYear += current.day;\n return dayOfYear;\n}\n\n/**\n * Create progress bar element.\n * @param {string} type - Enricher type identifier\n * @param {number} progress - Progress value between 0 and 1\n * @returns {HTMLElement} Progress bar element\n */\nfunction createProgressElement(type, progress) {\n const clamped = Math.min(1, Math.max(0, progress));\n const span = document.createElement('span');\n span.classList.add('calendaria-enricher', `calendaria-enricher--${type}`, 'calendaria-enricher--progress', 'calendaria-enricher--live');\n span.dataset.calType = type;\n span.dataset.calConfig = '';\n const bar = document.createElement('span');\n bar.classList.add('calendaria-enricher--progress-bar');\n const fill = document.createElement('span');\n fill.classList.add('calendaria-enricher--progress-fill');\n fill.style.width = formatPercent(clamped);\n bar.appendChild(fill);\n span.appendChild(bar);\n const text = document.createElement('span');\n text.textContent = formatPercent(progress);\n span.appendChild(text);\n return span;\n}\n\n/**\n * Sunrise time.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichSunrise(config, label) {\n const { calendar } = resolveCalendar(config);\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const sunrise = calendar?.sunrise(undefined, zone);\n if (sunrise == null) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const text = formatHoursToTime(sunrise);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Sunrise', { value: text });\n return createElement('sunrise', label || text, '', true, 'fa-sun', tooltip);\n}\n\n/**\n * Sunset time.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichSunset(config, label) {\n const { calendar } = resolveCalendar(config);\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const sunset = calendar?.sunset(undefined, zone);\n if (sunset == null) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const text = formatHoursToTime(sunset);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Sunset', { value: text });\n return createElement('sunset', label || text, '', true, 'fa-sun', tooltip);\n}\n\n/**\n * Hours of daylight.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichDaylight(config, label) {\n const { calendar } = resolveCalendar(config);\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const hours = calendar?.daylightHours(undefined, zone);\n if (hours == null) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const text = `${hours.toFixed(1)}h`;\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DaylightHours', { value: text });\n return createElement('daylight', label || text, '', true, 'fa-sun', tooltip);\n}\n\n/**\n * Daytime or nighttime.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichIsDaytime(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const sunrise = calendar.sunrise(undefined, zone);\n const sunset = calendar.sunset(undefined, zone);\n let isDaytime = true;\n if (sunrise != null && sunset != null) {\n const minutesPerHour = calendar.days?.minutesPerHour ?? 60;\n const currentHour = components.hour + components.minute / minutesPerHour;\n isDaytime = currentHour >= sunrise && currentHour < sunset;\n }\n const key = isDaytime ? 'CALENDARIA.Enricher.Label.Daytime' : 'CALENDARIA.Enricher.Label.Nighttime';\n const text = _loc(key);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.TimeOfDay', { value: text });\n return createElement('isdaytime', label || text, '', true, 'fa-sun', tooltip);\n}\n\n/**\n * Day progress percentage.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichDayProgress(config, label) {\n const { calendar } = resolveCalendar(config);\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const progress = calendar?.progressDay(undefined, zone);\n if (progress == null) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const text = formatPercent(Math.min(1, Math.max(0, progress)));\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DayProgress', { value: text });\n return createElement('dayprogress', label || text, '', true, 'fa-sun', tooltip);\n}\n\n/**\n * Night progress percentage.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichNightProgress(config, label) {\n const { calendar } = resolveCalendar(config);\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const progress = calendar?.progressNight(undefined, zone);\n if (progress == null) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const text = formatPercent(Math.min(1, Math.max(0, progress)));\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NightProgress', { value: text });\n return createElement('nightprogress', label || text, '', true, 'fa-moon', tooltip);\n}\n\n/**\n * Time until sunrise.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichUntilSunrise(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const result = resolveTimeUntilTarget('sunrise', calendar, components);\n if (!result) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const text = formatDuration(result);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.TimeUntilSunrise', { value: text });\n return createElement('untilsunrise', label || text, '', true, 'fa-sun', tooltip);\n}\n\n/**\n * Time until sunset.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichUntilSunset(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const result = resolveTimeUntilTarget('sunset', calendar, components);\n if (!result) return createErrorElement('CALENDARIA.Enricher.Error.NoSunData');\n const text = formatDuration(result);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.TimeUntilSunset', { value: text });\n return createElement('untilsunset', label || text, '', true, 'fa-sun', tooltip);\n}\n\n/**\n * Moon phase enricher.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichMoon(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const moonIndex = config.values.length > 0 && !isNaN(config.values[0]) ? parseInt(config.values[0]) : 0;\n const targetDef = calendar?.moonsArray?.[moonIndex];\n if (targetDef && !isMoonVisible(targetDef)) return createErrorElement('CALENDARIA.Enricher.Error.MoonNotFound', { index: moonIndex });\n if (config.position) {\n const moonsArr = calendar?.moonsArray || [];\n if (!moonsArr.length) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const moon = moonsArr[moonIndex];\n if (!moon) return createErrorElement('CALENDARIA.Enricher.Error.MoonNotFound', { index: moonIndex });\n const position = getMoonPhasePosition(moon, components);\n const text = formatPercent(position);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.MoonCyclePosition', { value: text });\n return createElement('moon', label || text, config.raw || '', true, 'fa-moon', tooltip);\n }\n if (config.cycleday) {\n const moon = calendar?.getMoonPhase?.(moonIndex);\n if (!moon) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const text = String(moon.dayInCycle ?? '');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.MoonCycleDay', { value: text });\n return createElement('moon', label || text, config.raw || '', true, 'fa-moon', tooltip);\n }\n if (config.isfull) {\n const moonsArr = calendar?.moonsArray || [];\n if (!moonsArr.length) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const moon = moonsArr[moonIndex];\n if (!moon) return createErrorElement('CALENDARIA.Enricher.Error.MoonNotFound', { index: moonIndex });\n const isFull = isMoonFull(moon, components);\n const key = isFull ? 'CALENDARIA.MoonPhase.FullMoon' : 'CALENDARIA.Enricher.Label.NotFullMoon';\n const text = _loc(key);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.FullMoon', { value: text });\n return createElement('moon', label || text, config.raw || '', true, 'fa-moon', tooltip);\n }\n const moon = calendar?.getMoonPhase?.(moonIndex);\n if (!moon) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const moonDef = calendar?.moonsArray?.[moonIndex];\n const phaseName = _loc(moon.name);\n const span = createElement('moon', '', config.raw || '', true);\n span.classList.add('calendaria-enricher--badge');\n appendMoonIcon(span, moon.icon, moonDef?.color);\n if (!config.icon) span.append(label || phaseName);\n else if (label) span.append(label);\n span.dataset.tooltip = _loc('CALENDARIA.Enricher.Tooltip.MoonPhase', { value: phaseName });\n return span;\n}\n\n/**\n * All moon phases.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichMoons(config, label) {\n const { calendar } = resolveCalendar(config);\n const moons = calendar?.getAllMoonPhases?.();\n if (!moons?.length) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const text = moons\n .filter((m, i) => m && isMoonVisible(calendar?.moonsArray?.[m.moonIndex ?? i]))\n .map((m) => {\n const moonName = m.moonName ? _loc(m.moonName) : m.name ? _loc(m.name) : '';\n const phaseName = m.phaseName ? _loc(m.phaseName) : m.name ? _loc(m.name) : '';\n return moonName !== phaseName ? `${moonName}: ${phaseName}` : phaseName;\n })\n .join(', ');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.MoonPhases', { value: text });\n return createElement('moons', label || text, '', true, 'fa-moon', tooltip);\n}\n\n/**\n * Next full moon.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichNextFullMoon(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const moonsArr = calendar?.moonsArray || [];\n if (!moonsArr.length) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const isCountdown = config.values.some((v) => v.toLowerCase() === 'countdown');\n const moonIdxVal = config.values.find((v) => !isNaN(v));\n const moonIndex = moonIdxVal != null ? parseInt(moonIdxVal) : 0;\n const targetMoon = moonsArr[moonIndex];\n if (!targetMoon || !isMoonVisible(targetMoon)) return createErrorElement('CALENDARIA.Enricher.Error.MoonNotFound', { index: moonIndex });\n const nextFull = getNextFullMoon(targetMoon, components);\n if (!nextFull) return createErrorElement('CALENDARIA.Common.NoConvergence');\n const nextDate = internalToPublic(nextFull, calendar);\n if (isCountdown) {\n const current = getCurrentDateTime(calendar, components);\n const days = daysBetween(toInternal(current), toInternal(nextDate));\n const text = formatCountdown(days);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextFullMoon', { value: text });\n return createElement('nextfullmoon', label || text, config.raw, true, 'fa-moon', tooltip);\n }\n const dateText = formatDate(nextDate, 'dateLong', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextFullMoon', { value: dateText });\n return createContentLink('nextfullmoon', label || dateText, { calYear: nextDate.year, calMonth: nextDate.month, calDay: nextDate.day }, 'fa-moon', tooltip, config.raw);\n}\n\n/**\n * Next moon convergence.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichConvergence(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const moonsArr = (calendar?.moonsArray || []).filter(isMoonVisible);\n if (moonsArr.length < 2) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const result = getNextConvergence(moonsArr, components);\n const moonNames = moonsArr.map((m) => _loc(m.name)).join(', ');\n if (!result) {\n const noText = _loc('CALENDARIA.Common.NoConvergence');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextConvergence', { moons: moonNames, phase: '', date: noText });\n return createElement('convergence', label || noText, '', true, 'fa-moon', tooltip);\n }\n const date = internalToPublic(result, calendar);\n const dateText = formatDate(date, 'dateLong', calendar);\n const fullMoonLabel = _loc('CALENDARIA.MoonPhase.FullMoon');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextConvergence', { moons: moonNames, phase: fullMoonLabel, date: dateText });\n return createContentLink('convergence', label || dateText, { calYear: date.year, calMonth: date.month, calDay: date.day }, 'fa-moon', tooltip, '');\n}\n\n/**\n * Current eclipse status.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichEclipse(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const moonsArr = calendar?.moonsArray || [];\n if (!moonsArr.length) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const moonIdxVal = config.values.find((v) => !isNaN(v));\n const moonIndex = moonIdxVal != null ? parseInt(moonIdxVal) : 0;\n const targetMoon = moonsArr[moonIndex];\n if (!targetMoon || !isMoonVisible(targetMoon)) return createErrorElement('CALENDARIA.Enricher.Error.MoonNotFound', { index: moonIndex });\n const result = getEclipseAtDate(targetMoon, components, calendar);\n const text = result.type ? _loc(ECLIPSE_TYPE_LABELS[result.type] ?? result.type) : _loc('CALENDARIA.Eclipse.None');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Eclipse', { value: text });\n return createElement('eclipse', label || text, config.raw, true, 'fa-sun', tooltip);\n}\n\n/**\n * Next eclipse date.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichNextEclipse(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const moonsArr = calendar?.moonsArray || [];\n if (!moonsArr.length) return createErrorElement('CALENDARIA.Enricher.Error.NoMoons');\n const isCountdown = config.values.some((v) => v.toLowerCase() === 'countdown');\n const moonIdxVal = config.values.find((v) => !isNaN(v));\n const moonIndex = moonIdxVal != null ? parseInt(moonIdxVal) : 0;\n const targetMoon = moonsArr[moonIndex];\n if (!targetMoon || !isMoonVisible(targetMoon)) return createErrorElement('CALENDARIA.Enricher.Error.MoonNotFound', { index: moonIndex });\n const result = getNextEclipse(targetMoon, components, { calendar });\n if (!result) return createErrorElement('CALENDARIA.Enricher.Error.NoEclipseFound');\n const nextDate = internalToPublic(result.date, calendar);\n const typeLabel = _loc(ECLIPSE_TYPE_LABELS[result.type] ?? result.type);\n if (isCountdown) {\n const current = getCurrentDateTime(calendar, components);\n const days = daysBetween(toInternal(current), toInternal(nextDate));\n const text = formatCountdown(days);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextEclipse', { type: typeLabel, value: text });\n return createElement('nexteclipse', label || text, config.raw, true, 'fa-sun', tooltip);\n }\n const dateText = formatDate(nextDate, 'dateLong', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextEclipse', { type: typeLabel, value: dateText });\n return createContentLink('nexteclipse', label || dateText, { calYear: nextDate.year, calMonth: nextDate.month, calDay: nextDate.day }, 'fa-sun', tooltip, config.raw);\n}\n\n/**\n * Current weather.\n * @param {object} _config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichWeather(_config, label) {\n const weather = WeatherManager.getCurrentWeather();\n if (!weather) return createErrorElement('CALENDARIA.Enricher.Error.NoWeather');\n const weatherLabel = _loc(weather.label);\n const span = createElement('weather', '', '', true);\n span.classList.add('calendaria-enricher--badge');\n appendWeatherIcon(span, weather.icon);\n span.append(label || weatherLabel);\n span.dataset.tooltip = _loc('CALENDARIA.Enricher.Tooltip.Weather', { value: weatherLabel });\n return span;\n}\n\n/**\n * Current temperature.\n * @param {object} _config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichTemperature(_config, label) {\n const temp = WeatherManager.getTemperature();\n if (temp == null) return createErrorElement('CALENDARIA.Enricher.Error.NoWeather');\n const text = WeatherManager.formatTemperature(temp);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Temperature', { value: text });\n return createElement('temperature', label || text, '', true, 'fa-temperature-half', tooltip);\n}\n\n/**\n * Current wind.\n * @param {object} _config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichWind(_config, label) {\n const weather = WeatherManager.getCurrentWeather();\n if (!weather) return createErrorElement('CALENDARIA.Enricher.Error.NoWeather');\n const speedLabel = getWindLabel(weather.wind?.speed ?? 0);\n const dir = weather.wind?.direction;\n const dirLabel = dir != null ? ` ${degreesToCompass(dir)}` : '';\n const text = `${speedLabel}${dirLabel}`;\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Wind', { value: text });\n return createElement('wind', label || text, '', true, 'fa-wind', tooltip);\n}\n\n/**\n * Current precipitation.\n * @param {object} _config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichPrecipitation(_config, label) {\n const weather = WeatherManager.getCurrentWeather();\n if (!weather) return createErrorElement('CALENDARIA.Enricher.Error.NoWeather');\n const precipType = weather.precipitation?.type;\n const text = precipType ? precipType.charAt(0).toUpperCase() + precipType.slice(1) : _loc('CALENDARIA.Common.None');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Precipitation', { value: text });\n return createElement('precipitation', label || text, '', true, 'fa-cloud-rain', tooltip);\n}\n\n/**\n * Weather icon only.\n * @param {object} _config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichWeatherIcon(_config, label) {\n const weather = WeatherManager.getCurrentWeather();\n if (!weather) return createErrorElement('CALENDARIA.Enricher.Error.NoWeather');\n const span = createElement('weathericon', '', '', true);\n span.classList.add('calendaria-enricher--badge');\n appendWeatherIcon(span, weather.icon);\n if (label) span.append(label);\n const weatherLabel = _loc(weather.label);\n span.dataset.tooltip = _loc('CALENDARIA.Enricher.Tooltip.Weather', { value: weatherLabel });\n return span;\n}\n\n/**\n * Climate zone name.\n * @param {object} _config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichZone(_config, label) {\n const zone = WeatherManager.getActiveZone(null, game.scenes?.active);\n const name = zone?.name ? _loc(zone.name) : zone?.id || '';\n if (!name) return createErrorElement('CALENDARIA.Enricher.Error.NoZone');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.ClimateZone', { value: name });\n return createElement('zone', label || name, null, false, 'fa-globe', tooltip);\n}\n\n/**\n * Weather forecast.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichForecast(config, label) {\n const { calendar } = resolveCalendar(config);\n const days = config.values.length > 0 && !isNaN(config.values[0]) ? parseInt(config.values[0]) : 3;\n const forecast = WeatherManager.getForecast({ days });\n if (!forecast?.length) return createErrorElement('CALENDARIA.Enricher.Error.NoWeather');\n if (label) return createElement('forecast', label, '', true);\n const currentWeather = WeatherManager.getCurrentWeather();\n if (currentWeather && forecast.length > 0) {\n const today = game.time.components;\n const first = forecast[0];\n if (first.year === today.year && first.month === today.month && first.dayOfMonth === today.dayOfMonth) {\n forecast[0] = { ...first, preset: currentWeather.preset ?? first.preset, temperature: currentWeather.temperature ?? first.temperature, wind: currentWeather.wind ?? first.wind };\n }\n }\n const container = document.createElement('span');\n container.classList.add('calendaria-enricher', 'calendaria-enricher--almanac', 'calendaria-enricher--live');\n container.dataset.calType = 'forecast';\n container.dataset.calConfig = config.raw || '';\n const lines = forecast.map((entry) => {\n const date = { year: entry.year, month: (entry.month ?? 0) + 1, day: (entry.dayOfMonth ?? 0) + 1 };\n const dateStr = formatDate(date, 'dateShort', calendar);\n const weatherLabel = entry.preset?.label ? _loc(entry.preset.label) : '';\n const temp = entry.temperature != null ? `, ${WeatherManager.formatTemperature(entry.temperature)}` : '';\n let windStr = '';\n if (entry.wind?.speed != null) {\n const dir = entry.wind.direction != null ? ` ${degreesToCompass(entry.wind.direction)}` : '';\n windStr = `, ${getWindLabel(entry.wind.speed)}${dir}`;\n }\n return `${dateStr}: ${weatherLabel}${temp}${windStr}`;\n });\n container.innerHTML = lines.map((l) => foundry.utils.escapeHTML(l)).join('
    ');\n container.dataset.tooltip = _loc('CALENDARIA.Enricher.Tooltip.Forecast', { days: String(forecast.length) });\n return container;\n}\n\n/**\n * Parse an `HH:MM` token using the active calendar's day/hour/minute lengths.\n * @param {string} token - Time string\n * @param {object} calendar - Active calendar\n * @returns {{ targetSecondsOfDay: number, label: string }|null} Parsed result or null when invalid\n */\nfunction parseClockToken(token, calendar) {\n if (typeof token !== 'string') return null;\n const match = /^(\\d+):(\\d+)$/.exec(token);\n if (!match) return null;\n const hour = parseInt(match[1], 10);\n const minute = parseInt(match[2], 10);\n const hpd = calendar?.days?.hoursPerDay ?? 24;\n const mph = calendar?.days?.minutesPerHour ?? 60;\n const spm = calendar?.days?.secondsPerMinute ?? 60;\n if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;\n if (hour < 0 || hour >= hpd || minute < 0 || minute >= mph) return null;\n return { targetSecondsOfDay: hour * mph * spm + minute * spm, label: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` };\n}\n\n/**\n * Advance time to a clock target (HH:MM).\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichAdvanceToTime(config, label) {\n const { calendar } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Error.NoActiveCalendar');\n const token = config.values?.[0] ?? config.time;\n const parsed = parseClockToken(token, calendar);\n if (!parsed) return createErrorElement('CALENDARIA.Enricher.Error.InvalidTime', { value: String(token ?? '') });\n const direction = config.direction === 'nearest' ? 'nearest' : 'forward';\n const text = label || _loc('CALENDARIA.Enricher.Label.AdvanceToTime', { time: parsed.label });\n const tooltipKey = direction === 'nearest' ? 'CALENDARIA.Enricher.Tooltip.AdvanceToTimeNearest' : 'CALENDARIA.Enricher.Tooltip.AdvanceToTime';\n const tooltip = _loc(tooltipKey, { time: parsed.label });\n return createContentLink('advanceToTime', text, { calAction: 'advanceToTime', calTargetSeconds: String(parsed.targetSecondsOfDay), calDirection: direction }, 'fa-forward', tooltip);\n}\n\n/**\n * Advance time by a fixed interval composed of calendar-aware units.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichAdvanceInterval(config, label) {\n const { calendar } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Error.NoActiveCalendar');\n const increments = getTimeIncrements();\n let totalSeconds = 0;\n const parts = [];\n for (const [unit, secs] of Object.entries(increments)) {\n const raw = config[unit];\n if (raw == null) continue;\n const count = Number(raw);\n if (!Number.isFinite(count) || count === 0) continue;\n totalSeconds += secs * count;\n parts.push(`${count}${INTERVAL_UNIT_LABELS[unit] ?? unit}`);\n }\n if (totalSeconds === 0) return createErrorElement('CALENDARIA.Enricher.Error.InvalidInterval');\n const intervalLabel = parts.join(' ');\n const text = label || _loc('CALENDARIA.Enricher.Label.AdvanceInterval', { interval: intervalLabel });\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.AdvanceInterval', { interval: intervalLabel });\n return createContentLink('advanceInterval', text, { calAction: 'advanceInterval', calDeltaSeconds: String(totalSeconds) }, 'fa-forward', tooltip);\n}\n\n/**\n * Advance time to a named preset (sunrise/midday/noon/sunset/midnight).\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichAdvanceToPreset(config, label) {\n const preset = String(config.values?.[0] ?? config.preset ?? '').toLowerCase();\n if (!ADVANCE_PRESETS.includes(preset)) return createErrorElement('CALENDARIA.Enricher.Error.InvalidPreset', { value: preset });\n const presetLabel = _loc(`CALENDARIA.Enricher.Preset.${preset}`);\n const text = label || _loc('CALENDARIA.Enricher.Label.AdvanceToPreset', { preset: presetLabel });\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.AdvanceToPreset', { preset: presetLabel });\n return createContentLink('advanceToPreset', text, { calAction: 'advanceToPreset', calPreset: preset }, 'fa-forward', tooltip);\n}\n\n/**\n * Snap the clock to a specific time of day (HH:MM) without changing the date.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichSetTime(config, label) {\n const { calendar } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Error.NoActiveCalendar');\n const token = config.values?.[0] ?? config.time;\n const parsed = parseClockToken(token, calendar);\n if (!parsed) return createErrorElement('CALENDARIA.Enricher.Error.InvalidTime', { value: String(token ?? '') });\n const text = label || _loc('CALENDARIA.Enricher.Label.SetTime', { time: parsed.label });\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.SetTime', { time: parsed.label });\n const mph = calendar?.days?.minutesPerHour ?? 60;\n const spm = calendar?.days?.secondsPerMinute ?? 60;\n const hour = Math.floor(parsed.targetSecondsOfDay / (mph * spm));\n const minute = Math.floor((parsed.targetSecondsOfDay % (mph * spm)) / spm);\n return createContentLink('setTime', text, { calAction: 'setTime', calHour: String(hour), calMinute: String(minute) }, 'fa-clock', tooltip);\n}\n\n/**\n * Jump to a specific date, preserving the current time of day.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichJumpToDate(config, label) {\n const { calendar } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Error.NoActiveCalendar');\n const date = parseDateFromValues(config.values);\n if (!date) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n const dateText = formatDate(date, 'dateLong', calendar);\n const text = label || _loc('CALENDARIA.Enricher.Label.JumpToDate', { date: dateText });\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.JumpToDate', { date: dateText });\n return createContentLink('jumpToDate', text, { calAction: 'jumpToDate', calYear: String(date.year), calMonth: String(date.month), calDay: String(date.day) }, 'fa-calendar-day', tooltip);\n}\n\n/**\n * Toggle the real-time TimeClock on/off.\n * @param {object} _config - Parsed enricher config (unused)\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichToggleClock(_config, label) {\n const text = label || _loc('CALENDARIA.Enricher.Label.ToggleClock');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.ToggleClock');\n return createContentLink('toggleClock', text, { calAction: 'toggleClock' }, 'fa-play', tooltip);\n}\n\n/**\n * Set the current weather to a preset, optionally overriding temperature/period/zone.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichSetWeather(config, label) {\n const presetId = config.presetId ?? config.values?.[0];\n if (!presetId || typeof presetId !== 'string') return createErrorElement('CALENDARIA.Enricher.Error.InvalidPreset', { value: String(presetId ?? '') });\n const customPresets = WeatherManager.getCustomPresets?.() ?? [];\n const preset = getPreset(presetId, customPresets);\n if (!preset) return createErrorElement('CALENDARIA.Enricher.Error.InvalidPreset', { value: presetId });\n const options = {};\n if (config.temperature != null) options.temperature = Number(config.temperature);\n if (config.period != null) options.period = String(config.period);\n if (config.zoneId != null) options.zoneId = String(config.zoneId);\n if (config.allPeriods != null) options.allPeriods = config.allPeriods === true || config.allPeriods === 'true';\n const presetLabel = preset.label ? _loc(preset.label) : presetId;\n const text = label || _loc('CALENDARIA.Enricher.Label.SetWeather', { preset: presetLabel });\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.SetWeather', { preset: presetLabel });\n return createContentLink('setWeather', text, { calAction: 'setWeather', calPresetId: presetId, calOptions: JSON.stringify(options) }, 'fa-cloud', tooltip);\n}\n\n/**\n * Named note link.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichEvent(config, label) {\n const searchTerm = config.raw;\n if (!searchTerm) return createErrorElement('CALENDARIA.Enricher.Error.InvalidConfig');\n const notes = searchNotes(searchTerm);\n if (!notes.length) {\n const el = createElement('event', label || searchTerm, null, false);\n el.classList.add('calendaria-enricher--dimmed');\n return el;\n }\n const note = notes[0];\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Event', { value: note.name });\n return createContentLink('event', label || note.name, { calPageId: note.id }, 'fa-calendar-day', tooltip);\n}\n\n/**\n * Notes for a date.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichNotes(config, label) {\n const { calendar, components } = resolveCalendar(config);\n if (config.count) {\n const current = getCurrentDateTime(calendar, components);\n const scope = config.scope?.toLowerCase();\n let count;\n if (scope === 'month') {\n const yearZero = calendar?.years?.yearZero ?? 0;\n const internalMonth = current.month - 1;\n const daysInMonth = calendar?.getDaysInMonth(internalMonth, current.year - yearZero) ?? 30;\n count = NoteManager.getNotesInRange({ year: current.year, month: internalMonth, dayOfMonth: 0 }, { year: current.year, month: internalMonth, dayOfMonth: daysInMonth - 1 }).length;\n } else count = NoteManager.getNotesForDate(current.year, current.month - 1, current.day - 1).length;\n let text;\n if (count === 0) text = _loc('CALENDARIA.Enricher.Label.NoNotes');\n else if (count === 1) text = _loc('CALENDARIA.Enricher.Label.NoteCountOne');\n else text = _loc('CALENDARIA.Enricher.Label.NoteCount', { count });\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NoteCount', { value: text });\n const configStr = scope ? `count=true scope=${scope}` : 'count=true';\n if (label) return createElement('notes', label, configStr, true, 'fa-note-sticky', tooltip);\n return createElement('notes', text, configStr, true, 'fa-note-sticky', tooltip);\n }\n let notes;\n let isLive = false;\n if (config.values.length > 0) {\n const date = parseDateFromValues(config.values);\n if (!date) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n notes = NoteManager.getNotesForDate(date.year, date.month - 1, date.day - 1);\n } else {\n const current = getCurrentDateTime(calendar, components);\n notes = NoteManager.getNotesForDate(current.year, current.month - 1, current.day - 1);\n isLive = true;\n }\n if (!notes.length) {\n const noText = _loc('CALENDARIA.Enricher.Label.NoNotes');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Notes', { value: noText });\n return createElement('notes', label || noText, isLive ? '' : null, isLive, 'fa-note-sticky', tooltip);\n }\n const text = notes.map((n) => n.name).join(', ');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Notes', { value: text });\n return createElement('notes', label || text, isLive ? '' : null, isLive, 'fa-note-sticky', tooltip);\n}\n\n/**\n * Next note occurrence.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichNext(config, label) {\n const { calendar, components } = resolveCalendar(config);\n let searchTerm = config.raw;\n let countdown = false;\n if (config.values.length > 1 && config.values[config.values.length - 1].toLowerCase() === 'countdown') {\n countdown = true;\n searchTerm = config.values.slice(0, -1).join(' ');\n }\n const notes = searchNotes(searchTerm);\n if (!notes.length) return createErrorElement('CALENDARIA.Enricher.Error.NoteNotFound', { name: searchTerm });\n const note = notes[0];\n const stub = NoteManager.getNote(note.id);\n const current = getCurrentDateTime(calendar, components);\n const fromDate = { year: current.year, month: current.month - 1, dayOfMonth: current.day - 1 };\n const nextOccs = stub ? getNextOccurrences(stub.flagData, fromDate, 1).map(toPublic) : [];\n if (!nextOccs?.length) return createElement('next', label || note.name, config.raw, true, 'fa-calendar-day');\n const nextDate = nextOccs[0];\n if (countdown) {\n const days = daysBetween(toInternal(current), toInternal(nextDate));\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextOccurrenceCountdown', { name: note.name, value: formatCountdown(days) });\n return createElement('next', label || formatCountdown(days), config.raw, true, 'fa-hourglass-half', tooltip);\n }\n const dateText = formatDate(nextDate, 'dateLong', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.NextOccurrence', { name: note.name, date: dateText });\n return createContentLink('event', label || dateText, { calPageId: note.id }, 'fa-calendar-day', tooltip);\n}\n\n/**\n * Notes by category.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichCategory(config, label) {\n const presetId = config.raw;\n if (!presetId) return createErrorElement('CALENDARIA.Enricher.Error.InvalidConfig');\n const presets = NoteManager.getPresetDefinitions();\n const preset = presets.find((p) => p.id === presetId);\n const iconClass = preset?.icon?.replace('fas ', '') || 'fa-tag';\n const presetLabel = preset?.label || presetId;\n const notes = NoteManager.getNotesByPreset(presetId);\n if (!notes.length) {\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Category', { preset: presetLabel, count: '0' });\n return createElement('category', label || _loc('CALENDARIA.Enricher.Label.NoNotes'), null, false, iconClass, tooltip);\n }\n const text = notes.map((n) => n.name).join(', ');\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Category', { preset: presetLabel, count: String(notes.length) });\n return createElement('category', label || text, null, false, iconClass, tooltip);\n}\n\n/**\n * Chronicle range link — opens Chronicle locked to the date range on click.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichChronicle(config, label) {\n const { calendar } = resolveCalendar(config);\n if (!calendar) return createErrorElement('CALENDARIA.Enricher.Error.NoCalendar');\n const dates = parseTwoDates(config.values);\n if (!dates) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n const { date1: startPublic, date2: endPublic } = dates;\n const startText = formatDate(startPublic, 'dateLong', calendar);\n const endText = formatDate(endPublic, 'dateLong', calendar);\n const text = label || `${startText} - ${endText}`;\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.Chronicle', { start: startText, end: endText });\n const dataset = {\n calChronicleFromYear: startPublic.year,\n calChronicleFromMonth: startPublic.month - 1,\n calChronicleFromDay: startPublic.day - 1,\n calChronicleToYear: endPublic.year,\n calChronicleToMonth: endPublic.month - 1,\n calChronicleToDay: endPublic.day - 1\n };\n if (config.cal) dataset.calChronicleCalendarId = config.cal;\n if (config.categories) {\n const cats = String(config.categories)\n .split(',')\n .map((c) => c.trim())\n .filter((c) => c.length > 0);\n if (cats.length > 0) dataset.calChronicleCategories = cats.join(',');\n }\n return createContentLink('chronicle', text, dataset, 'fa-scroll', tooltip);\n}\n\n/**\n * Calendar summary.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichSummary(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const parts = [];\n parts.push(formatDate(null, 'dateLong', calendar));\n const weather = WeatherManager.getCurrentWeather();\n if (weather?.label) parts.push(_loc(weather.label));\n const moon = calendar?.getCurrentMoonPhase?.(0);\n if (moon) parts.push(_loc(moon.name));\n const text = label || parts.join(' · ');\n const current = getCurrentDateTime(calendar, components);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.CalendarSummary');\n return createContentLink('summary', text, { calYear: current.year, calMonth: current.month, calDay: current.day }, 'fa-calendar-days', tooltip, '');\n}\n\n/**\n * Daily almanac.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichAlmanac(config, label) {\n const { calendar } = resolveCalendar(config);\n if (label) return createElement('almanac', label, '', true);\n const container = document.createElement('span');\n container.classList.add('calendaria-enricher', 'calendaria-enricher--almanac', 'calendaria-enricher--live');\n container.dataset.calType = 'almanac';\n container.dataset.calConfig = '';\n const lines = [];\n lines.push(formatDate(null, 'dateFull', calendar));\n const season = calendar?.getCurrentSeason?.();\n if (season) lines.push(`${_loc('CALENDARIA.Common.Season')}: ${_loc(season.name)}`);\n const weather = WeatherManager.getCurrentWeather();\n if (weather) {\n const weatherLabel = _loc(weather.label);\n const temp = WeatherManager.getTemperature();\n const tempStr = temp != null ? ` ${WeatherManager.formatTemperature(temp)}` : '';\n lines.push(`${_loc('CALENDARIA.Common.Weather')}: ${weatherLabel}${tempStr}`);\n const windSpeed = weather.wind?.speed;\n if (windSpeed != null) {\n const windLabel = getWindLabel(windSpeed);\n const windDir = weather.wind?.direction;\n const dirStr = windDir != null ? ` ${degreesToCompass(windDir)}` : '';\n lines.push(`${_loc('CALENDARIA.Common.Wind')}: ${windLabel}${dirStr}`);\n }\n }\n const moon = calendar?.getCurrentMoonPhase?.(0);\n if (moon) {\n const phaseName = _loc(moon.name);\n lines.push(`${_loc('CALENDARIA.Common.Moon')}: ${phaseName}`);\n }\n const zone = WeatherManager.getActiveZone?.(null, game.scenes?.active);\n const sunrise = calendar?.sunrise(undefined, zone);\n const sunset = calendar?.sunset(undefined, zone);\n if (sunrise != null && sunset != null) {\n const srLabel = _loc('CALENDARIA.Common.Sunrise');\n const ssLabel = _loc('CALENDARIA.Common.Sunset');\n lines.push(`${srLabel}: ${formatHoursToTime(sunrise)} | ${ssLabel}: ${formatHoursToTime(sunset)}`);\n }\n container.innerHTML = lines.map((l) => foundry.utils.escapeHTML(l)).join('
    ');\n container.dataset.tooltip = _loc('CALENDARIA.Enricher.Tooltip.DailyAlmanac');\n return container;\n}\n\n/**\n * Custom format string.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichFormat(config, label) {\n const { calendar } = resolveCalendar(config);\n const text = formatDate(null, config.raw, calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.FormattedDate', { value: text });\n return createElement('format', label || text, config.raw, true, 'fa-calendar', tooltip);\n}\n\n/**\n * Date comparison.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichCompare(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const target = parseDateFromValues(config.values);\n if (!target) return createErrorElement('CALENDARIA.Enricher.Error.InvalidDate');\n const yearZero = calendar?.years?.yearZero ?? 0;\n const timeSinceText = timeSince({ year: target.year - yearZero, month: target.month - 1, dayOfMonth: target.day - 1 }, components);\n const days = Math.abs(daysBetween(toInternal(target), toInternal(getCurrentDateTime(calendar, components))));\n const unit = days === 1 ? _loc('CALENDARIA.Common.UnitDay') : _loc('CALENDARIA.Common.UnitDays');\n const text = `${timeSinceText} (${days} ${unit})`;\n const targetStr = formatDate(target, 'dateLong', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DateComparison', { value: targetStr });\n return createContentLink('compare', label || text, { calYear: target.year, calMonth: target.month, calDay: target.day }, 'fa-scale-balanced', tooltip, config.raw);\n}\n\n/**\n * Date preview.\n * @param {object} config - Parsed enricher config\n * @param {string|null} label - Custom label override\n * @returns {HTMLElement} Enricher element\n */\nfunction enrichPeek(config, label) {\n const { calendar, components } = resolveCalendar(config);\n const ops = parseDateMath(config.raw);\n if (!ops.length) return createErrorElement('CALENDARIA.Enricher.Error.InvalidMath', { input: config.raw });\n let date = getCurrentDateTime(calendar, components);\n for (const op of ops) {\n switch (op.unit) {\n case 'd':\n date = toPublic(addDays(toInternal(date), op.amount));\n break;\n case 'm':\n date = toPublic(addMonths(toInternal(date), op.amount));\n break;\n case 'y':\n date = toPublic(addYears(toInternal(date), op.amount));\n break;\n }\n }\n const text = formatDate(date, 'dateLong', calendar);\n const tooltip = _loc('CALENDARIA.Enricher.Tooltip.DatePreview', { expr: config.raw, value: text });\n return createContentLink('peek', label || text, { calYear: date.year, calMonth: date.month, calDay: date.day }, 'fa-calendar', tooltip);\n}\n\n/**\n * Search notes.\n * @param {string} term - Search term\n * @returns {object[]} Matching notes\n */\nfunction searchNotes(term) {\n const lower = term.toLowerCase();\n return NoteManager.getAllNotes().filter((note) => {\n if (note.name.toLowerCase().includes(lower)) return true;\n if (note.content?.toLowerCase().includes(lower)) return true;\n return false;\n });\n}\n\n/** Batch refresh manager for live enricher elements. */\nclass LiveUpdateManager {\n static #debounceTimer = null;\n static #refreshFn = null;\n\n /**\n * Set up hooks for live refresh.\n * @param {Function} refreshFn - Callback to refresh a single element\n */\n static initialize(refreshFn) {\n this.#refreshFn = refreshFn;\n Hooks.on('updateWorldTime', () => this.scheduleRefresh());\n Hooks.on(HOOKS.CALENDAR_SWITCHED, () => this.scheduleRefresh());\n Hooks.on(HOOKS.WEATHER_CHANGE, () => this.scheduleRefresh());\n }\n\n /** Schedule debounced refresh. */\n static scheduleRefresh() {\n if (this.#debounceTimer) clearTimeout(this.#debounceTimer);\n this.#debounceTimer = setTimeout(() => this.#batchRefresh(), 100);\n }\n\n /** Process batch in animation frames. */\n static #batchRefresh() {\n const elements = document.querySelectorAll('.calendaria-enricher--live');\n if (!elements.length) return;\n const batch = Array.from(elements);\n let index = 0;\n const process = () => {\n const end = Math.min(index + 20, batch.length);\n for (; index < end; index++) if (batch[index].isConnected) this.#refreshFn(batch[index]);\n if (index < batch.length) requestAnimationFrame(process);\n };\n requestAnimationFrame(process);\n }\n}\n\n/**\n * Enricher dispatcher.\n * @param {object} match - Regex match result\n * @param {object} options - Enrichment options from Foundry; `options.relativeTo` is the source document when available\n * @returns {Promise} Element to insert, or null to leave raw text\n */\nasync function enrichCalendaria(match, options) {\n const { type, config: configStr, label } = match.groups;\n const typeLower = type.toLowerCase();\n const handler = handlers[typeLower];\n if (!handler) {\n ATLAS.log(2, `Unknown enricher type: ${type}`);\n return null;\n }\n if (!CalendarManager.getActiveCalendar()) return createElement(typeLower, label || '', configStr?.trim() || '', true, 'fa-spinner fa-spin');\n const config = parseConfig(configStr);\n config._options = options;\n const result = await handler(config, label);\n return label ? applyLabel(result, label) : result;\n}\n\n/**\n * Strip live attributes for custom labels.\n * @param {HTMLElement} element - Enricher element to modify\n * @param {string} _label - Custom label (unused)\n * @returns {HTMLElement|null} Modified element or null\n */\nfunction applyLabel(element, _label) {\n if (!element) return null;\n element.classList.remove('calendaria-enricher--live');\n delete element.dataset.calType;\n delete element.dataset.calConfig;\n return element;\n}\n\n/**\n * Refresh a live enricher element.\n * @param {HTMLElement} el - The element to refresh\n */\nfunction refreshElement(el) {\n if (!CalendarManager.getActiveCalendar()) return;\n const type = el.dataset.calType;\n const configStr = el.dataset.calConfig || '';\n const handler = handlers[type];\n if (!handler) return;\n const config = parseConfig(configStr);\n const newEl = handler(config, null);\n if (!newEl) return;\n if (!newEl.classList.contains('calendaria-enricher--live')) newEl.classList.add('calendaria-enricher--live');\n newEl.dataset.calType = type;\n newEl.dataset.calConfig = configStr;\n el.replaceWith(newEl);\n}\n\n/**\n * Dispatch a click on an action enricher button (advance time / set weather).\n * @param {string} action - Action name from `data-cal-action`\n * @param {DOMStringMap} dataset - Element dataset carrying action params\n */\nasync function dispatchEnricherAction(action, dataset) {\n const api = CALENDARIA.api;\n switch (action) {\n case 'advanceToTime': {\n const target = Number(dataset.calTargetSeconds);\n if (!Number.isFinite(target)) return;\n const direction = dataset.calDirection || 'forward';\n const calendar = CalendarManager.getActiveCalendar();\n const components = game.time.components;\n const mph = calendar?.days?.minutesPerHour ?? 60;\n const spm = calendar?.days?.secondsPerMinute ?? 60;\n const hpd = calendar?.days?.hoursPerDay ?? 24;\n const secsPerDay = hpd * mph * spm;\n const currentSecondsOfDay = (components.hour ?? 0) * mph * spm + (components.minute ?? 0) * spm + (components.second ?? 0);\n let delta = target - currentSecondsOfDay;\n if (direction === 'nearest') {\n if (delta > secsPerDay / 2) delta -= secsPerDay;\n else if (delta < -secsPerDay / 2) delta += secsPerDay;\n } else if (delta <= 0) delta += secsPerDay;\n await api.advanceTime(delta);\n return;\n }\n case 'advanceInterval': {\n const delta = Number(dataset.calDeltaSeconds);\n if (!Number.isFinite(delta) || delta === 0) return;\n await api.advanceTime(delta);\n return;\n }\n case 'advanceToPreset': {\n const preset = dataset.calPreset;\n if (!preset) return;\n await api.advanceTimeToPreset(preset);\n return;\n }\n case 'setTime': {\n const hour = Number(dataset.calHour);\n const minute = Number(dataset.calMinute);\n if (!Number.isFinite(hour) || !Number.isFinite(minute)) return;\n await api.setDateTime({ hour, minute, second: 0 });\n return;\n }\n case 'jumpToDate': {\n const year = Number(dataset.calYear);\n const month = Number(dataset.calMonth);\n const day = Number(dataset.calDay);\n if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return;\n await api.jumpToDate({ year, month, day });\n return;\n }\n case 'toggleClock': {\n TimeClock.toggle();\n return;\n }\n case 'setWeather': {\n const presetId = dataset.calPresetId;\n if (!presetId) return;\n let options = {};\n try {\n options = JSON.parse(dataset.calOptions || '{}');\n } catch {\n options = {};\n }\n await WeatherManager.setWeather(presetId, options);\n return;\n }\n }\n}\n\n/**\n * Navigate calendar to a date.\n * @param {number} year - Display year\n * @param {number} month - Month (1-indexed)\n * @param {number} day - Day (1-indexed)\n */\nasync function navigateToDate(year, month, day) {\n const dateObj = { year, month: month - 1, dayOfMonth: day - 1 };\n const pref = game.settings.get(MODULE.ID, SETTINGS.ENRICHER_CLICK_TARGET);\n const useMiniCal = pref === 'minical' || (pref === 'auto' && MiniCal.instance?.rendered && !BigCal.instance?.rendered);\n if (useMiniCal) {\n const mini = MiniCal.show();\n if (mini) {\n mini.selectDate(dateObj);\n await mini.render({ force: true });\n }\n } else {\n const instance = BigCal.show();\n if (instance) {\n instance.selectDate(dateObj);\n await instance.render({ force: true });\n }\n }\n}\n\n/** Register enrichers with Foundry. */\nexport function registerEnrichers() {\n CONFIG.TextEditor.enrichers.push({\n pattern: /\\[\\[cal\\.(?\\w+)(?:\\s+(?[^\\]]*?))?]](?:\\{(?