The root weather package is the GoMud plugin entry point. It wires the full
module lifecycle: plugin registration, config, geography-graph management,
weather-simulation tick, per-room refinement, ambient emotes, admin/player
commands, and the exported API. It imports internal/* for plugin
infrastructure (plugins/events/users/mudlog/util/rooms); engine-world calls
live in engine/; pure algorithms live in sim/crawler; data-file parsing
lives in content/. All fields of weatherModule are touched only from the
single game-loop goroutine — no synchronization needed.
- weather.go: the
filesembed.FS (//go:embed files/*— the active-defaults config overlay plusdatafiles/mutator specs, buff specs, and emote tables; the engine loadsmutators/*andbuffs/*from it via the plugin registry,contentloaders read the rest).weatherModulestruct (plug, cfg, graph, started, simReady, simCfg, climate, tables, state, nextTick, nextEmote; plustracks seasons.Tracks,seasonsOn bool,zoneSeasons map[sim.ZoneId]seasons.ZoneSeason, andseasonalTables content.SeasonalTables(the standalone seasonal-ambience emote tables) for the seasons layer; andlastAdminAction stringcarrying the most recent admin-page action result for the snapshot).init()→plugins.New+AttachFileSystem+SetOnLoad, then registers theweathercommand as a player command (not admin-only; admin subcommands are gated in-handler), the exports, and the admin web surface (registerAdminWeb()). Command, export, and web registration MUST happen ininit(), notonLoad:plugins.Load()harvests the plugin's command map and admin web surface into the engine registry BEFORE invokingonLoad, so anything registered inonLoadis lost. Behavior is gated oncfg.Enabled/simReadyin-handler instead.onLoad: runshealConfigClobberFIRST (the boot self-heal for the engine's overlay clobber — see weather_config.go below; ordering matters, the config read would otherwise adopt defaults over wiped operator values), then loads config, then (when enabled) registersSetOnSave, theNewRoundlistener, theRoomChangelistener (onRoomChange, defined in weather_tick.go — refine-on-entry foroccupiedmode), and the two admin event listeners (WeatherAdminAction,WeatherConfigChanged).onNewRound: one-time startup (loadOrBuildGraph + startSim, followed by the entry point's singlepublishSnapshot), the jittered ambient-emote pass (engine.EmitAmbient(m.state.Weather, m.zoneSeasons, m.tables, m.seasonalTables, util.Rand)— the single arbiter; passes nil season maps harmlessly when seasons are off), and the coarse weather tick.loadOrBuildGraph/rebuildGraph: cache-or-crawl;rebuildGraphreadscfg.ExcludeZonePatternsinto the crawler options, and also callsstartSim,applyWeather(the mode-aware re-assert), and (whenseasonsOn) recomputesm.zoneSeasonsand callsengine.ReconcileSeasons(post-rebuild heal — prevents stale-zone seasons surviving a graph rebuild). NeitherstartSimnorrebuildGraphpublishes a snapshot — they are helpers, not entry points (single-publish rule; see weather_admin.go).sendLineis the SOLEuser.SendTextcall site. - weather_events.go: exports
WeatherSeasonChanged{Zone, Track, From, To}— queued on the engine event bus when a zone's resolved season flips. Never emitted on the first (baseline) resolution after boot, so reboots do not replay a flood of events. Other modules listen by importing this type:events.RegisterListener(weather.WeatherSeasonChanged{}, handler). Also defines the two internal admin bridges:WeatherAdminAction{Action, Weather, Zone, Intensity}— queued by HTTP handlers, executed on the game loop through the same paths as the in-game admin commands (spawn/clear/rebuild); andWeatherConfigChanged{Key}— queued after a config write is persisted, causing the game loop to re-read the config and run the changed key's live applier. All three implementevents.EventviaType() string. - weather_tick.go:
startSim(idempotent; graceful degradation — logs once and stays idle when no graph exists; order: simConfig → loadContent → loadSeasons → applyBuffConfig → loadOrInitState → applyWeather → schedule tick/emote).applyBuffConfig— the boot-time buff phase, in this order and NEVER the reverse:engine.ApplyBuffOverrides(cfg.BuffOverrides)first (when any are set), thenengine.StripBuffs()whenBuffsEnabled: false— so disabling buffs always wins, overrides included. Both are seamed (applyBuffOverridesFn/stripBuffsFn) because they mutate the global spec registry, which is empty undergo test; an ordering test swaps the seams.applyWeather— the single switch between zone-scoped and room-scoped weather application; every path that asserts weather mutators (startSim, tick, rebuildGraph, spawn/clear commands, exports, admin actions, the PerRoomRefinement live-apply) funnels through it.RefineOff→engine.Reconcile(m.state.Weather)(the v1 zone-wide path); the room modes firstengine.StripZoneWeather(m.graph)(rooms become the only carriers), thenRefineAllwalksZoneRoomIdsper zone callingengine.RefineRoom(force-loads by design — the documented cost of "all"), whileRefineOccupied(default) callsengine.RefineOccupiedRooms. Seasons are always zone-wide and untouched here.onRoomChange— keepsoccupiedmode current between ticks: ignores mob moves (UserId == 0) and no-ops unlesssimReady && PerRoomRefinement == occupied; always refines the destination room (logins fireRoomChangewith From==To; RefineRoom is idempotent, steady state is zero mutator ops) and strips the departed room once it has no players. Deliberately does NOT publishSnapshot — RefinedRooms lags until the next tick by design.loadContent(climate overrides, weather emote tables, and the seasonal-ambience tables —content.LoadSeasonalEmotesintom.seasonalTables— from the embedded FS, all fail-soft).loadSeasons— fail-soft ladder:SeasonsEnabled: false→ skip; no usable calendar → skip; no/invalid tracks → skip; each rejection leavesseasonsOn = falseso weather runs exactly as v1. On success setsm.seasonsOn = true, stores tracks, and callsseasons.ZoneSeasonsto establish the baselinezoneSeasonsmap, immediately followed byengine.ReconcileSeasons(m.graph, m.zoneSeasons)(boot assert — re-assertsseason-*mutators after a reboot since zone mutators do not survive reboots; no events emitted).loadOrInitState(restore fromengine.DecodeState, orsim.NewState/sim.DeriveSeedon a fresh start).tick— whenseasonsOn, callsseasons.EffectiveClimateto produce the climate input forsim.Step(the seasonsOn gate); then Step →applyWeather(reconcile-style rather than a bare diff-apply, so engine-sidedecayratedrift self-corrects within one tick); thenresolveSeasonsifseasonsOn; ends with the entry point's onepublishSnapshot.resolveSeasons— re-resolves all zone seasons and queues aWeatherSeasonChangedevent for each flip since the previous tick; callsengine.ReconcileSeasons(m.graph, zs)after storing the new map (per-tick assert — keepsseason-*mutators live against the specs'decayratesafety net); cross-track-change suppression: season-change events are only emitted whenprev.Track == cur.Track && prev.Season != cur.Season, so a zone whose biome was reassigned by an admin rebuild emits nothing (listeners may assumeFrom/Toare seasons of the same track).persistState(cheap; called per-tick, from onSave, and from every command/export mutation path).onSave(plugins.Save hook).scheduleEmote(±25% jitter so ambience doesn't metronome). - weather_commands.go: bare
weathershows local conditions (player view; includes the dominant front viasim.Covering; whenseasonsOnalso prints the zone's current season). Subcommandszones,fronts,spawn <type> <zone> [intensity],clear [zone],graph [zone],rebuild,status,seasonsare admin/mod-gated viaHasRolePermission("weather", true).spawnandclearcallsim.ForceSpawn/sim.ClearZonesthenapplyWeather+persistState+publishSnapshot(command handlers are entry points).seasons(printSeasons) lists every loaded track with its current season and blend percentage when inside a transition window; reports "off" whenseasonsOnis false. - weather_api.go:
registerExportsexposesGetWeather,GetFronts,SpawnFront,GetSeasonviaplugin.ExportFunction. All four guardsimReadyso callers during boot get empty-but-valid answers. The MainWorker-goroutine guarantee applies to mutating exports (same as commands).SpawnFrontcallsapplyWeather+persistState+publishSnapshot.GetSeason(zone) map[string]anyreturns{"track": string, "season": string, "blend": float64}fromm.zoneSeasons; empty strings when seasons are off, the zone is unknown, or its biome is unbound. - weather_admin.go: the read-side bridge between the game loop and the HTTP
layer.
AdminSnapshotstruct — an immutable deep-copy of module state (SimReady,SeasonsOn,Round,NextTickRound, graph summary,RefinementMode(the livePerRoomRefinementvalue) andRefinedRooms(engine.OccupiedRoomCount()whenever a room mode is active — the OCCUPIED-room count even inallmode, and labeled "occupied rooms" on the page; 0 whenoff),Fronts,Zones,Configrows,LastAction) serialized to JSON for the status endpoint. Package-leveladminSnapshot atomic.Pointer[AdminSnapshot]— written only from the game loop; HTTP handlers callloadSnapshot()to read it and never touch live module fields. Single-publish rule — the canonical statement lives onpublishSnapshot's doc comment: helpers that mutate state on a caller's behalf (rebuildGraph,startSim, …) never publish; every game-loop ENTRY POINT that mutates snapshot-visible state (onNewRoundstartup,tick, command handlers, exports,applyAdminAction,applyConfigChange) publishes exactly once, at its end, after anylastAdminActionattribution is in place — so success and failure alike surface exactly once, correctly attributed (TestAdminRebuildPublishesOncepins the rebuild path).configKeyMeta map[string]configKeyApplier— the single source of truth for every public config key: badge text, inputKind(bool/int/float/enum/text) withOptionsfor enums,ReadOnlyfor synthetic rows (the oneBuffOverrides.*summary row — set in the world's config-overrides.yaml, never via the API), aValidatefunc, and an optionalLiveApplyfunc (run on the game loop when the key changes). Validators mirrorbuildConfig's coercion rules but REJECT what the loader would silently default or clamp (floors are the sharedmin*constants in weather_config.go), returning a normalized string to persist (canonical bool, lowercased enum). Notable live appliers:PerRoomRefinementcallsengine.StripOccupiedRoomWeather()when leaving a room mode foroff(unoccupied strays are left to the specs' decayrate safety net) and thenapplyWeather();BuffsEnabledlive-strips on true→false only (no restore path — the badge says reboot to re-enable);SeasonsEnabledtears down or re-runsloadSeasons.configRows()readsconfigKeyMetato build theConfigslice in the snapshot — everyValuemust be a scalar (slice/map config likeExcludeZonePatternsandBuffOverridesis rendered to fresh strings, the latter viabuffOverridesSummary) so snapshots stay isolated.applyAdminAction(WeatherAdminAction)— executes spawn / clear / rebuild on the game loop, mirrors the in-game command paths, then publishes.applyConfigChange(Config, key)— adopts a freshly re-read config, runs the key'sLiveApplyif present and the sim is ready, then publishes.onAdminAction/onConfigChanged— the listener glue wiring the two event types to the appliers; both registered inonLoad, both run on the game loop. - weather_admin_api.go: the HTTP registration and handler layer.
registerAdminWeb()— called frominit()(the harvest rule: the engine harvests admin web surface atplugins.Load(), beforeonLoad, same as commands). Wires:AdminPage("Weather", "weather", "html/admin/weather.html", ...)— thehtmlFileargument is relative todatafiles/(NOTdatafiles/html/admin/as the engine doc comment implies; the loader reads the plugin-FS key verbatim — seeplugins.go:535); the page is served at/admin/weather. Three endpoints:GET weather/status(open to any admin session),POST weather/config(requiresweather.write),POST weather/action(requiresweather.write).RegisterPermissions(weather.write).handleAdminConfigrejects with 400 any unknown key, any write to aReadOnlyrow, and any value itsValidatefunc refuses (the error message names the key); what it persists is the validator's NORMALIZED value, so the overrides file never accumulates values the next boot would quietly rewrite. Persistence goes through thepersistConfigFnseam (Set + read-back), and the handler then verifies the write actually took: the engine'sPluginConfig.SetDISCARDSconfigs.SetVal's error (internal/plugins/pluginconfig.go:13), so a rejected write (e.g. an unregistered key) looks identical to success — the read-back guard comparesGetagainst the normalized value (configValuesEqual) and answers 500 ("the engine rejected this write") without queueing the changed event.handleAdminActionshape-validates (spawn needs type + zone) and queues. Handlers are strictly limited to three touches: (1)loadSnapshot()on the atomic pointer (read-only); (2)m.plug.Config.Set/GetviapersistConfigFninhandleAdminConfig(the engine config layer, which is internally locked); (3)events.AddToQueuein both write handlers (the event queue is thread-safe). Handlers never access any otherweatherModulefield. - weather_config.go:
Configstruct (Enabled, IncludeSecretExits, RebuildGraphOnBoot, Seed, TickEveryGameHours, MaxActiveFronts, SpawnRateScale, EmoteMode, EmoteEveryRounds, BuffsEnabled, Persist,SeasonsEnabled,PerRoomRefinement,BuffOverrides map[string][]int,ExcludeZonePatterns []string). Keys are flat because plugin config lookup reads flattened scalar leaves.SeasonsEnableddefaultstrue; setting it tofalsecausesloadSeasonsto return immediately, leavingseasonsOn = falseand weather running exactly as v1.PerRoomRefinementis one of theRefineOccupied/RefineAll/RefineOffconstants (defaultoccupied; anything else falls back to the default).BuffOverridesis read bybuffOverrides(get), which probes the flat keyBuffOverrides.<type>for every entry ofsim.KnownWeatherTypes("clear" included — it has no mutator, so an override for it warns at apply time);parseBuffIdsparses one value (comma-separated positive ints; an empty string = explicit strip = empty list; bad tokens warn-once viawarnConfigOnceand are skipped; a value with NO usable ids is dropped entirely — fail-soft to the shipped buffs).ExcludeZonePatternsis one comma-separated flat key parsed byexcludePatterns; absent/empty falls back tocrawler.DefaultOptions().ExcludeZonePatterns(there is no "exclude nothing" sentinel). Themin*floor constants (minSeed, minTickEveryGameHours, minMaxActiveFronts, minEmoteEveryRounds, minSpawnRateScale) are shared bybuildConfig's clamps and the admin validators so loader and write-side validation can never drift apart.buildConfig(getter)(testable, applies defaults and sanity clamps) carries the code defaults —Enableddefaults TRUE when absent (OOBE) — and the shipped data overlay (files/data-overlays/config.yaml) restates the SAME values as ACTIVE keys (pinned identical byTestOverlayMatchesCodeDefaults): active overlay keys are the only thing that registersModules.weather.*with the engine's config layer, without whichconfigs.SetVal— the admin page's write path — rejects every write ("invalid property name", an errorPluginConfig.Setsilently discards). The flip side is the engine's overlay clobber:configs.AddOverlayOverridesREPLACES the liveModules.weatherblock (instead of merging) whenever the overlay carries a key the operator'sconfig-overrides.yamlblock lacks — i.e. the first boot after a module update once the admin page ever wrote the block.healConfigClobber(called fromonLoadBEFORE the config read) is the boot self-heal:healClobberedConfig(testable core; seamsreadOverridesFn/infoConfig, plus the injected get/set) reads the operator'sconfig-overrides.yaml(path mirrors the engine'soverridePathNoLock:CONFIG_PATHenv var, elseFilePaths.DataFiles), extracts theModules.weatherblock (case-insensitive), flattens it to dotted keys, and compares each file value against the live config (configValuesEqual— canonical-string compare, the two sides never share a type system). Any mismatch means the clobber fired; ONEplug.Config.Setof a registered key restores everything, because the engine'sSetValre-applies its ENTIRE in-memory overrides union (which still holds the operator's file values) and writes the completed union back to the file, so the clobber never fires again. The heal re-verifies via Get, logs one Info on success ("Weather: restored operator config after engine overlay clobber"), warns and falls through on any IO/parse/verify failure (never blocks boot; code defaults keep the module functional).registeredConfigKey/writableConfigKeysderive the registered set fromconfigKeyMeta.simConfig()maps module config ontosim.Config.loadConfig(*plugins.Plugin).
internal/plugins, events, users, mudlog, util, rooms, configs(engine, plugin infra;configsonly for the overrides-file path inhealConfigClobber).modules/weather/{sim,crawler,engine,content,seasons};gopkg.in/yaml.v2(parsing config-overrides.yaml in the heal — same yaml the engine uses).
GoMud runs a single game-loop goroutine (MainWorker) for both event listeners
(NewRound, RoomChange, the admin bridges) and command handlers, so
weatherModule fields need no synchronization. Exported functions are invoked
on the same goroutine. Designed exception surface — HTTP handlers:
handleAdminStatus, handleAdminConfig, and handleAdminAction in
weather_admin_api.go run on web goroutines outside MainWorker. They are
permitted to touch exactly three things: the adminSnapshot atomic pointer
(read-only via loadSnapshot()), the engine config layer (via
m.plug.Config.Set/Get through the persistConfigFn seam — internally
locked), and the engine event queue
(via events.AddToQueue, which is thread-safe). Any access to other
weatherModule fields from a handler is a concurrency bug.
Compiles only inside a GoMud checkout (imports internal/*).
weather_config_test.go covers buildConfig (defaults, coercion, clamps, the
refinement-mode and BuffOverrides/ExcludeZonePatterns parsing), the
applyBuffConfig ordering (overrides before strip, via the seams), the
overlay/code-defaults pin (TestOverlayMatchesCodeDefaults), and
healClobberedConfig (the TestHeal* family: no file, no block, steady
state, partial clobber, nested BuffOverrides, unregistered-only mismatch,
parse garbage, rejected write, case-insensitive block lookup — all through
fabricated YAML and fake get/set; the real proof is the boot smokes).
weather_admin_test.go covers the snapshot builder (incl. the refinement
fields and isolation), configKeyMeta completeness, validators and
normalization, the handlers' 400 paths, the read-back guard
(TestConfigHandlerReadBackGuard: accepted/rejected/silently-unchanged
writes via the persistConfigFn seam), the live appliers (refinement-mode
transitions, seasons toggle), and the single-publish rule on the rebuild path.
The registration/command/tick/export paths are verified by the in-checkout
build and a boot smoke test (first-round build → state persist → reload →
tick).
Only user.SendText differs (DOGMud takes a message category). It is isolated in
sendLine — a one-line change to backport. See CONTRIBUTING.md.