11# sim Package Context
22
33## Overview
4- ` sim ` is the pure, engine-independent core of the weather module. It holds the
5- geography ` Graph ` that the crawler produces and the weather simulation will
6- consume. Nothing here imports the GoMud engine (` internal/* ` ) — a rule enforced
7- by ` arch_test.go ` — so the simulation stays unit-testable without a running
8- server and portable across GoMud and DOGMud.
4+ ` sim ` is the pure, engine-independent core of the weather module: the geography
5+ ` Graph ` (produced by ` crawler ` , consumed everywhere) and the deterministic
6+ weather simulation that runs on it. Nothing here imports the GoMud engine
7+ (` internal/* ` ) — enforced by ` arch_test.go ` — and the package is stdlib-only,
8+ so everything is unit-testable without a server and portable across GoMud and
9+ DOGMud. All randomness flows through a serializable PRNG carried in ` State ` ,
10+ making every run exactly reproducible from a seed.
911
1012## Key Components
1113### Core Files
12- - ** graph.go** : the ` Graph ` , ` ZoneNode ` , and ` Edge ` types; the ` GraphVersion `
13- constant; JSON cache serialization (` ToJSON ` / ` FromJSON ` ); ` Zones() ` ;
14- ` Neighbors ` (lazy adjacency index); and ` FindZone ` (case-insensitive lookup).
15- - ** state.go** : ` NewState ` / ` DeriveSeed ` (FNV-1a over sorted zone names); and
16- the persistence codec ` ToJSON ` / ` StateFromJSON ` .
17- - ** query.go** : ` Coverage ` and ` Covering ` — front-projection query that mirrors
18- ` resolveWeather ` 's coverage rule.
19- - ** mutate.go** : ` ForceSpawn ` and ` ClearZones ` — pure admin-action mutators (no
20- RNG consumed).
21- - ** arch_test.go** : architecture guardrail — fails if any file in this package
22- imports a ` GoMudEngine/GoMud/internal ` path.
14+ - ** graph.go** : ` Graph ` , ` ZoneNode ` , ` Edge ` ; ` GraphVersion ` ; JSON cache codec
15+ (` ToJSON ` /` FromJSON ` ); ` Zones() ` (sorted, for deterministic iteration);
16+ ` Neighbors ` (lazy adjacency index); ` FindZone ` (case-insensitive lookup).
17+ - ** rng.go** : ` RNG ` — serializable splitmix64 PRNG; its entire state is one
18+ ` uint64 ` cursor (` State.RNGState ` ).
19+ - ** weather.go** : core value types — ` WeatherType ` (open, data-driven; ` Clear `
20+ is the calm baseline), ` ZoneId ` (= ` string ` ), ` FrontId ` , ` Front ` , ` State ` ,
21+ ` Clock ` , ` ZoneChange ` , ` StateDiff ` ; ` clamp01 ` .
22+ - ** climate.go** : ` ClimateProfile ` /` WeatherInfluence ` /` Climate ` (biome →
23+ weather weights, terrain influence, spawn weight); ` Climate.For ` (falls back
24+ to the ` "default" ` profile); ` DefaultClimate() ` (built-in profiles for the
25+ standard biomes); ` Config ` /` DefaultConfig() ` (front budget, spawn chance,
26+ history length, hard age cap, coverage falloff/threshold/radius).
27+ - ** tick.go** : ` Step ` — the simulation tick — and its helpers (` ageAndFeedback ` ,
28+ ` moveFronts ` , ` evolveTypes ` , ` removeDead ` , ` spawnFronts ` , ` resolveWeather ` ,
29+ ` zonesWithin ` , ` diffWeather ` , weighted-pick helpers).
30+ - ** state.go** : ` NewState ` /` DeriveSeed ` plus the ` State ` persistence codec
31+ (` ToJSON ` /` StateFromJSON ` ).
32+ - ** query.go** : ` Coverage ` /` Covering ` — read-only front-projection query that
33+ mirrors ` resolveWeather ` 's coverage rule exactly (a consistency test pins
34+ them together).
35+ - ** mutate.go** : ` ForceSpawn ` /` ClearZones ` — pure admin-action mutations.
36+ - ** arch_test.go** : purity guardrail — fails if any file imports a
37+ ` GoMudEngine/GoMud/internal ` path.
2338
2439### Key Structures
2540``` go
@@ -38,98 +53,120 @@ type Graph struct {
3853 Nodes map [string ]ZoneNode
3954 Edges []Edge
4055 Components int // connected-component count
41- // adj is a lazy adjacency index; nil after FromJSON, rebuilt on first Neighbors call.
56+ // + unexported adj: lazy adjacency index; nil after FromJSON,
57+ // rebuilt on the first Neighbors call (not serialized).
58+ }
59+ type Front struct { // one traveling weather system
60+ Id FrontId
61+ Type WeatherType
62+ Zone ZoneId // center
63+ Intensity float64 // 0..1; <= 0 means death
64+ Moisture float64 // 0..1
65+ Age , MaxAge int // soft cap; past MaxAge decay accelerates
66+ History []ZoneId // bounded recent path (no immediate backtrack)
67+ }
68+ type State struct { // full sim state — serializable
69+ Round uint64
70+ RNGState uint64 // PRNG cursor
71+ NextID FrontId
72+ Fronts []Front
73+ Weather map [ZoneId]WeatherType // resolved per-zone weather
4274}
4375type Coverage struct {
4476 Front Front
4577 Effective float64 // projected intensity at the queried zone
46- Hops int // graph distance from the front's centre
78+ Hops int // graph distance from the front's center
4779}
4880```
4981
5082## Core Functions
51- - ` (*Graph) ToJSON() ([]byte, error) ` / ` FromJSON([]byte) (*Graph, error) ` —
52- the on-disk cache format (indented JSON). ` GraphVersion ` lets a loader detect
53- a stale cache and rebuild.
54- - ` (*Graph) Zones() []string ` — sorted zone names for deterministic iteration.
55- - ` (*Graph) Neighbors(zone string) []Edge ` — adjacent zones, each Edge oriented
56- from the queried zone (` Edge.A == zone ` ). Returns a ** shared slice** from a
57- lazily-built index; callers MUST NOT mutate it — copy before sorting. Returns
58- nil for unknown or isolated zones. The adjacency index is rebuilt lazily after
59- ` FromJSON ` (the ` adj ` field is not serialized).
60- - ` (*Graph) FindZone(name string) (string, bool) ` — case-insensitive zone
61- lookup; exact match wins over a fold-equal match.
62- - ` NewState(seed uint64) State ` — initial simulation state for a fresh run.
63- - ` DeriveSeed(g *Graph) uint64 ` — stable default seed from sorted zone names
64- (FNV-1a); used when the configured ` Seed ` is 0 so two distinct worlds differ.
65- - ` (State) ToJSON() ([]byte, error) ` / ` StateFromJSON([]byte) (State, error) ` —
66- persistence codec for the full simulation state.
67- - ` Covering(g, fronts, cfg, zone) []Coverage ` — fronts whose area projection
68- reaches ` zone ` , strongest effective intensity first (ties by front id). Mirrors
69- ` resolveWeather ` 's coverage rule exactly.
70- - ` ForceSpawn(prev, g, cfg, wtype, zone, intensity, clock) (State, StateDiff, bool) `
71- — inject a front bypassing budget and spawn chance. ` intensity <= 0 ` defaults
72- to 0.6. Returns ok=false for an unknown zone. Pure: input state unchanged, no
73- RNG consumed (admin actions must not perturb the deterministic trace).
74- - ` ClearZones(prev, g, cfg, zones, clock) (State, StateDiff) ` — remove fronts
75- and re-resolve weather. No zones = clear all. With zones, uses ` Covering ` so a
76- named zone clears even when its front is centred elsewhere. Pure; no RNG
77- consumed.
83+ ### Graph
84+ - ` (*Graph) ToJSON / FromJSON ` — on-disk cache format (indented JSON);
85+ ` GraphVersion ` lets a loader detect a stale cache and rebuild.
86+ - ` (*Graph) Neighbors(zone) []Edge ` — adjacent zones, each Edge oriented from
87+ the queried zone (` Edge.A == zone ` ). Returns a ** shared slice** from the
88+ lazily-built index: callers MUST NOT mutate it (copy before sorting). Nil
89+ for unknown or isolated zones.
90+ - ` (*Graph) FindZone(name) (string, bool) ` — case-insensitive resolution to
91+ the canonical zone key; exact match wins.
92+
93+ ### Simulation
94+ - ` Step(prev State, g *Graph, climate Climate, cfg Config, now Clock) (State, StateDiff) `
95+ — one coarse tick, a pure function of its inputs. In order: age fronts and
96+ apply the occupied biome's influence (the weather ← biome feedback half);
97+ move fronts along edges (chance damped by ` MovementResistance ` , destination
98+ weighted by edge weight, no immediate backtrack via ` History ` ); re-roll a
99+ * moved* front's type from the destination climate (biased to keep its
100+ current type, so changes read naturally); drop dead fronts (intensity ≤ 0 or
101+ past ` FrontHardAge ` ); maybe spawn one front under ` MaxActiveFronts ` (origin
102+ weighted by climate ` SpawnWeight ` , type from the origin's weights); resolve
103+ per-zone weather with ** intensity-scaled area coverage** — each front
104+ projects onto zones within ` MaxFrontRadius ` hops at
105+ ` Intensity × CoverageFalloff^hops ` , covering while ` >= MinProjected ` ; per
106+ zone the highest effective intensity wins (ties → lowest front id);
107+ frontless zones are ` Clear ` — then emit the ` StateDiff ` of changed zones
108+ (sorted; a ` From ` of ` "" ` means "previously unset", not "was clear").
109+ - ` NewState(seed) State ` — fresh run (NextID 1, empty non-nil Weather map).
110+ - ` DeriveSeed(g) uint64 ` — stable default seed: FNV-1a over sorted zone names,
111+ so each world keeps its seed across boots but two worlds differ. Used when
112+ the configured seed is 0.
113+ - ` (State) ToJSON / StateFromJSON ` — persistence codec (the engine layer wraps
114+ it in a versioned envelope; see ` engine/state.go ` ).
115+
116+ ### Queries & admin mutations
117+ - ` Covering(g, fronts, cfg, zone) []Coverage ` — every front whose projection
118+ reaches ` zone ` , strongest first. Powers the player ` weather ` view and the
119+ exported ` GetWeather ` intensity.
120+ - ` ForceSpawn(prev, g, cfg, wtype, zone, intensity, now) (State, StateDiff, bool) `
121+ — inject a front, bypassing budget and spawn chance but flowing through the
122+ same resolve+diff path as ` Step ` . ` intensity <= 0 ` defaults to 0.6; fixed
123+ ` MaxAge ` 24; ok=false for an unknown zone. Pure — input state unmodified.
124+ - ` ClearZones(prev, g, cfg, zones, now) (State, StateDiff) ` — nil/empty zones
125+ removes every front; named zones remove every front whose * coverage* reaches
126+ one of them (so the zone actually clears even when the front is centered
127+ elsewhere). Pure. Kept fronts share ` History ` backing arrays with the input;
128+ any later mutation must go through ` cloneFronts ` (` Step ` does).
129+ - ** Neither admin mutation consumes RNG** — admin actions must not perturb the
130+ deterministic trace.
131+
132+ ## Determinism
133+ All randomness comes from the ` RNG ` built from ` State.RNGState ` ; the advanced
134+ cursor is written back into the next ` State ` . Same seed + graph + tick count ⇒
135+ byte-identical ` State ` (asserted by ` TestStep_Deterministic ` via
136+ ` reflect.DeepEqual ` and by a golden-trace test). Anything that would consume
137+ randomness outside ` Step ` (emote selection, admin spawns) deliberately does
138+ not touch this RNG.
78139
79140## Dependencies
80- - Standard library only (` encoding/json ` , ` sort ` , ` strings ` ). No engine imports (enforced).
141+ Standard library only (` encoding/json ` , ` sort ` , ` strings ` ). No engine imports
142+ (enforced), no third-party imports.
81143
82144## Consumers
83- - ` crawler.Build ` returns a ` *Graph ` .
84- - ` engine.Apply ` /` engine.Reconcile ` consume ` StateDiff ` and ` State.Weather ` .
85- - The module root calls ` Covering ` , ` ForceSpawn ` , ` ClearZones ` , ` NewState ` , and
86- ` DeriveSeed ` from command handlers, the tick path, and the exported API.
145+ - ` crawler.Build ` produces the ` *Graph ` ; ` engine.DecodeCache ` round-trips it.
146+ - The module root drives ` Step ` from the weather tick and feeds
147+ ` State.Weather ` to ` engine.Reconcile ` ; commands/exports call ` Covering ` ,
148+ ` ForceSpawn ` , ` ClearZones ` , ` NewState ` , ` DeriveSeed ` , ` FindZone ` .
149+ - ` content.LoadClimate ` returns a ` Climate ` ; ` engine.EmitAmbient ` reads
150+ ` State.Weather ` .
87151
88152## Testing
89- - ` graph_test.go ` : JSON round-trip, ` Neighbors ` , ` FindZone ` .
90- - ` state_test.go ` : ` NewState ` , ` DeriveSeed ` , ` ToJSON ` /` StateFromJSON ` round-trip.
91- - ` query_test.go ` : ` Covering ` projection and ordering.
92- - ` mutate_test.go ` : ` ForceSpawn ` and ` ClearZones ` (coverage-based clear).
153+ - ` graph_test.go ` : JSON round-trip, ` Neighbors ` (stability, orientation,
154+ unknown/isolated zones, post-decode rebuild), ` FindZone ` .
155+ - ` rng_test.go ` : determinism, cursor round-trip, ranges.
156+ - ` weather_test.go ` : ` clamp01 ` .
157+ - ` climate_test.go ` : profile fallback, defaults.
158+ - ` tick_test.go ` : per-behavior tests (feedback, movement resistance, type
159+ evolution, death, budget, coverage), golden trace, full-state determinism,
160+ storm-dies-crossing-mountains feedback scenario.
161+ - ` state_test.go ` : ` NewState ` , ` DeriveSeed ` , state JSON round-trip.
162+ - ` query_test.go ` : ` Covering ` projection/ordering + consistency with
163+ ` resolveWeather ` .
164+ - ` mutate_test.go ` : ` ForceSpawn ` /` ClearZones ` incl. purity assertions.
93165- ` arch_test.go ` : purity guardrail.
94166
95- ## Weather simulation (M2)
96-
97- Beyond the geography ` Graph ` , ` sim ` now contains the pure, deterministic weather
98- simulation. It consumes a ` *Graph ` as its read-only world and produces weather
99- state as plain data — no engine imports.
100-
101- ### Key files
102- - ** rng.go** : ` RNG ` , a serializable splitmix64 PRNG (cursor = one ` uint64 ` ).
103- - ** weather.go** : ` WeatherType ` (+ ` Clear ` ), ` Front ` , ` State ` , ` StateDiff ` ,
104- ` ZoneChange ` , ` Clock ` , and ` clamp01 ` .
105- - ** climate.go** : ` ClimateProfile ` / ` WeatherInfluence ` / ` Climate ` (biome →
106- weather weights + influence + spawn weight), ` Climate.For ` (default fallback),
107- ` DefaultClimate ` ; plus ` Config ` / ` DefaultConfig ` (front budget, spawn chance,
108- history length, hard age cap).
109- - ** tick.go** : ` Step(prev, graph, climate, cfg, clock) -> (next, diff) ` and its
110- helpers (` ageAndFeedback ` , ` moveFronts ` , ` evolveTypes ` , ` removeDead ` ,
111- ` spawnFronts ` , ` resolveWeather ` , ` diffWeather ` ).
112- - ** state.go** : ` State.ToJSON ` / ` StateFromJSON ` (persistence codec).
113-
114- ### The tick
115- Each ` Step ` : age fronts and apply the current zone's biome influence (the
116- weather ← biome feedback), move fronts along edges (damped by ` MovementResistance ` ,
117- no immediate backtrack), evolve a moved front's type from the new zone's climate,
118- drop dead fronts (intensity ≤ 0 or past the hard age cap), maybe spawn one front
119- under budget (origin weighted by ` SpawnWeight ` ), resolve per-zone weather with
120- ** intensity-scaled area coverage** (a front projects onto zones within
121- ` MaxFrontRadius ` hops at ` Intensity × CoverageFalloff^hops ` , covered while
122- ` >= MinProjected ` ; per zone the highest * effective* intensity wins; frontless
123- zones = ` Clear ` ), and emit the diff.
124-
125- ### Determinism
126- All randomness flows through the ` RNG ` built from ` State.RNGState ` ; the advanced
127- cursor is written back into the next ` State ` . Same seed + graph + tick count ⇒
128- identical fronts and per-zone weather (see ` TestStep_Deterministic ` ).
129-
130- ### Deferred (later milestones)
131- - ** Prevailing-wind direction** (a MUD owner biasing where storms originate/move,
132- e.g. west→east) is a planned later chunk: it needs directional edge metadata,
133- which a future crawler pass can derive from each exit's ` MapDirection ` .
134- - Calm-zone variety (occasional light fog/overcast in frontless zones) and an
135- explicit windward "orographic" precip spike are noted enrichments, not built.
167+ ## Deferred (later milestones)
168+ - ** Prevailing-wind direction** (biasing where fronts originate/travel, e.g.
169+ west→east) needs directional edge metadata, derivable by a future crawler
170+ pass from each exit's ` MapDirection ` .
171+ - Calm-zone variety (occasional light fog/overcast in frontless zones) and a
172+ windward "orographic" precipitation spike are noted enrichments, not built.
0 commit comments