-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathweather_tick.go
More file actions
269 lines (253 loc) · 9.78 KB
/
Copy pathweather_tick.go
File metadata and controls
269 lines (253 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
package weather
import (
"github.com/GoMudEngine/GoMud/internal/events"
"github.com/GoMudEngine/GoMud/internal/mudlog"
"github.com/GoMudEngine/GoMud/internal/util"
"github.com/GoMudEngine/GoMud/modules/weather/content"
"github.com/GoMudEngine/GoMud/modules/weather/engine"
"github.com/GoMudEngine/GoMud/modules/weather/seasons"
"github.com/GoMudEngine/GoMud/modules/weather/sim"
)
// startSim initializes the simulation once a geography graph exists: load
// content, restore-or-seed state, reconcile the world's mutators to it, and
// schedule the first tick/emote. Safe to call again (no-ops when ready); a
// later successful 'weather rebuild' can start a sim that failed at boot.
// Degrades gracefully: with no graph the module logs once and stays idle
// (spec §2.3.5 / §10 "graceful degradation").
func (m *weatherModule) startSim(round uint64) {
if m.simReady {
return
}
if m.graph == nil {
mudlog.Warn("Weather: no geography graph; simulation idle (fix the world and run 'weather rebuild')")
return // entry point publishes the "idle" snapshot (single-publish rule: see publishSnapshot)
}
m.simCfg = m.cfg.simConfig()
m.loadContent()
m.loadSeasons()
m.applyBuffConfig()
m.loadOrInitState(round)
m.applyWeather()
m.nextTick = engine.NextTickRound(engine.TickPeriod(m.cfg.TickEveryGameHours))
m.scheduleEmote(round)
m.simReady = true
// No publishSnapshot here: startSim is a helper, not an entry point
// (single-publish rule: see publishSnapshot).
}
// Buff-phase seams: the real engine calls mutate the global mutator-spec
// registry (empty under `go test`); the ordering test swaps these to record
// the call sequence. Logging lives in the defaults so the test doubles don't
// touch the engine logger.
var (
applyBuffOverridesFn = func(overrides map[string][]int) int {
n := engine.ApplyBuffOverrides(overrides)
mudlog.Info("Weather: buff overrides applied", "configured", len(overrides), "specsChanged", n)
return n
}
stripBuffsFn = func() int {
n := engine.StripBuffs()
mudlog.Info("Weather: buffs disabled by config", "specsStripped", n)
return n
}
)
// applyBuffConfig is the boot-time buff phase: per-type overrides first, then
// the BuffsEnabled=false strip — NEVER the reverse, so disabling buffs always
// wins, overrides included (spec §3). Both are spec mutations with no restore
// path short of a reboot (the admin badges say so).
func (m *weatherModule) applyBuffConfig() {
if len(m.cfg.BuffOverrides) > 0 {
applyBuffOverridesFn(m.cfg.BuffOverrides)
}
if !m.cfg.BuffsEnabled {
stripBuffsFn()
}
}
// applyWeather is the single switch between zone-scoped and room-scoped
// weather application (spec §2.1) — every path that asserts weather mutators
// funnels through here. Seasons are always zone-wide and untouched here.
// Game loop only.
func (m *weatherModule) applyWeather() {
if m.cfg.PerRoomRefinement == RefineOff {
engine.Reconcile(m.state.Weather)
return
}
// Room-scoped modes own the weather footprint: clear the zone-level
// mutators first so rooms are the only carriers.
engine.StripZoneWeather(m.graph)
switch m.cfg.PerRoomRefinement {
case RefineAll:
// Refining every room force-loads unloaded rooms by design — the
// documented cost of "all"; "occupied" never force-loads.
for _, zone := range m.graph.Zones() {
for _, roomId := range engine.ZoneRoomIds(zone) {
engine.RefineRoom(roomId, m.state.Weather)
}
}
default: // RefineOccupied
engine.RefineOccupiedRooms(m.state.Weather)
}
}
// onRoomChange keeps "occupied" mode current between ticks: refine the room a
// player enters, strip the one they left once it empties. Runs on the game
// loop; the event is queued after the engine completes the move, so room
// player counts are post-move here. Modes "all"/"off" need no entry hook
// (every room is already covered / weather is zone-scoped).
// Deliberately does NOT publishSnapshot: RefinedRooms lags until the next tick
// by design — do not "fix" this with a per-move publish.
func (m *weatherModule) onRoomChange(e events.Event) events.ListenerReturn {
evt, ok := e.(events.RoomChange)
if !ok || evt.UserId == 0 { // mobs don't need refinement on the move
return events.Continue
}
if !m.simReady || m.cfg.PerRoomRefinement != RefineOccupied {
return events.Continue
}
// Always refine the destination — logins fire RoomChange with From==To
// (world.go enterWorld → MoveToRoom into the saved room), and that room
// just became occupied. RefineRoom is idempotent, so this is cheap.
engine.RefineRoom(evt.ToRoomId, m.state.Weather)
if evt.FromRoomId > 0 && evt.FromRoomId != evt.ToRoomId && !engine.RoomHasPlayers(evt.FromRoomId) {
engine.StripRoomWeather(evt.FromRoomId)
}
return events.Continue
}
// loadContent loads climate overrides and emote tables from the module's
// embedded files. Both fail soft: defaults / silence plus a warning.
func (m *weatherModule) loadContent() {
climate, err := content.LoadClimate(files, "files/datafiles/climate")
if err != nil {
mudlog.Warn("Weather: climate overrides failed to load; using defaults", "error", err)
}
m.climate = climate
tables, err := content.LoadEmotes(files, "files/datafiles/emotes")
if err != nil {
mudlog.Warn("Weather: emote tables failed to load", "error", err)
}
m.tables = tables
seasonalTables, err := content.LoadSeasonalEmotes(files, "files/datafiles/emotes/seasons")
if err != nil {
mudlog.Warn("Weather: seasonal emote tables failed to load", "error", err)
}
m.seasonalTables = seasonalTables
}
// loadSeasons loads season tracks and establishes the baseline per-zone
// season map. Fail-soft ladder (design spec §7): disabled by config, no
// usable calendar, no/invalid track files => m.seasonsOn stays false and
// weather runs exactly as v1.
func (m *weatherModule) loadSeasons() {
m.seasonsOn = false
if !m.cfg.SeasonsEnabled {
return
}
months, days := engine.CalendarShape()
if months < 1 || days < 1 {
mudlog.Warn("Weather: no usable calendar; seasons disabled")
return
}
tracks, errs := seasons.Load(files, "files/datafiles/seasons", months, days)
for _, err := range errs {
mudlog.Warn("Weather: season track rejected", "error", err)
}
if len(tracks) == 0 {
mudlog.Warn("Weather: no season tracks loaded; seasons disabled")
return
}
m.tracks = tracks
m.seasonsOn = true
// Baseline resolution: establishes zoneSeasons WITHOUT emitting events,
// so reboots never replay a flood of season changes.
m.zoneSeasons = seasons.ZoneSeasons(m.graph, m.climate, m.tracks, engine.CalendarNow())
engine.ReconcileSeasons(m.graph, m.zoneSeasons) // assert season mutators at boot
mudlog.Info("Weather: seasons active", "tracks", len(tracks),
"seasonalZones", len(m.zoneSeasons))
}
// loadOrInitState restores persisted simulation state, or seeds a fresh run
// (configured Seed, else derived stably from the world's zone names).
func (m *weatherModule) loadOrInitState(round uint64) {
if m.cfg.Persist {
if b, err := m.plug.ReadBytes(engine.StateIdentifier); err == nil {
if s, ok := engine.DecodeState(b); ok {
m.state = s
mudlog.Info("Weather: restored simulation state",
"fronts", len(s.Fronts), "savedRound", s.Round)
return
}
}
}
seed := m.cfg.Seed
if seed == 0 {
seed = sim.DeriveSeed(m.graph)
}
m.state = sim.NewState(seed)
mudlog.Info("Weather: fresh simulation state", "seed", seed, "currentRound", round)
}
// tick advances the simulation one coarse step. Reconcile (rather than a bare
// diff-apply) re-asserts any mutator the specs' decayrate safety net dropped
// between ticks, so engine-side decay drift self-corrects within one tick.
func (m *weatherModule) tick(round uint64) {
climate := m.climate
if m.seasonsOn {
climate = seasons.EffectiveClimate(m.climate, m.tracks, engine.CalendarNow())
}
next, diff := sim.Step(m.state, m.graph, climate, m.simCfg, sim.Clock{Round: round})
m.state = next
_ = diff // per-zone changes are implied by the reconcile below
m.applyWeather()
if m.seasonsOn {
m.resolveSeasons()
}
m.persistState()
m.nextTick = engine.NextTickRound(engine.TickPeriod(m.cfg.TickEveryGameHours))
m.publishSnapshot() // single-publish rule: see publishSnapshot
}
// resolveSeasons re-resolves every zone's season and queues a
// WeatherSeasonChanged event for each flip since the previous tick. Cross-
// track changes (a zone's biome reassigned by an admin rebuild) are not
// calendar flips and emit nothing — listeners may assume From/To are seasons
// of the same track.
func (m *weatherModule) resolveSeasons() {
zs := seasons.ZoneSeasons(m.graph, m.climate, m.tracks, engine.CalendarNow())
for zone, cur := range zs {
if prev, ok := m.zoneSeasons[zone]; ok && prev.Track == cur.Track && prev.Season != cur.Season {
events.AddToQueue(WeatherSeasonChanged{
Zone: zone, Track: cur.Track, From: prev.Season, To: cur.Season,
})
}
}
m.zoneSeasons = zs
engine.ReconcileSeasons(m.graph, zs)
}
// persistState writes the current state to plugin storage (cheap: a few KB
// once per game hour). Also invoked from the engine's save callback.
func (m *weatherModule) persistState() {
if !m.cfg.Persist {
return
}
b, err := engine.EncodeState(m.state)
if err != nil {
mudlog.Error("Weather: state encode failed", "error", err)
return
}
if err := m.plug.WriteBytes(engine.StateIdentifier, b); err != nil {
mudlog.Error("Weather: state save failed", "error", err)
}
}
// onSave is the plugins.Save() hook (autosave, shutdown, copyover).
func (m *weatherModule) onSave() {
if m.simReady {
m.persistState()
}
}
// scheduleEmote picks the next ambient-emote round: the configured cadence
// jittered by ±25% so ambiance doesn't metronome.
func (m *weatherModule) scheduleEmote(round uint64) {
every := m.cfg.EmoteEveryRounds
delta := every
if jitter := every / 4; jitter > 0 {
delta += util.Rand(2*jitter+1) - jitter
}
if delta < 1 {
delta = 1
}
m.nextEmote = round + uint64(delta)
}