Skip to content

Commit 5cd23c3

Browse files
authored
Merge pull request #195 from IamMrCupp/feat/rpg-weather
feat(idlerpg): per-biome weather (#192)
2 parents b07fba3 + 152d925 commit 5cd23c3

8 files changed

Lines changed: 267 additions & 1 deletion

File tree

docs/idlerpg.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ one-line summary (and your title).
131131
- **Events.** At random the gods intervene — a **godsend** (time forward), a
132132
**calamity** (time back, or an item loses its luster), or the **Hand of God**
133133
(a big swing either way).
134+
- **Weather.** Each biome has its own sky, rotating every so often: **fog** makes
135+
blows harder to land there (-attack), a **storm** steadies your foes (+their
136+
attack), and **rain** or **snow** slow the road through it. `!rpg weather` shows
137+
the sky everywhere; the dashboard map carries a legend.
134138
- **World events.** Now and then the whole realm shifts for a while: a **Blood
135139
Moon** (monsters prowl far more often), the **Harvest Festival** (every coin is
136140
worth half again), or a **Storm of Fate** (the gods meddle far more often).

internal/idlerpg/help.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func CommandHelp() []HelpGroup {
3333
{"!rpg stash [slot] / equip <#>", "Bank an equipped item, list your stash, or equip a stashed one."},
3434
{"!rpg info", "Realm summary: idlers online, the top player, the active quest."},
3535
{"!rpg who (online)", "Who's idling right now, highest level first."},
36+
{"!rpg weather (sky)", "The current weather over every biome — fog, storms, snow."},
3637
{"!rpg top [kills|gold|duels]", "Leaderboards — by level (default), or kills/gold/duels."},
3738
{"!rpg feats [name]", "One-time achievements you've earned."},
3839
{"!rpg rebirth", "At level 50+, reset to level 0 for permanent prestige (★) and faster leveling — you keep gold, gear & glory."},

internal/idlerpg/idlerpg.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ type Manager struct {
7474
emu sync.Mutex
7575
wevent *worldEvent // the active realm-wide world event, nil when none
7676

77+
wmu sync.Mutex
78+
weather *weatherState // per-biome sky, rotated periodically
79+
7780
rmu sync.Mutex
7881
rng *rand.Rand
7982
}
@@ -108,6 +111,7 @@ func New(store state.Store, out engine.Sender, resolve Resolver, interval, baseT
108111
m.loadQuest(context.Background())
109112
m.loadBoss(context.Background())
110113
m.loadWorldEvent(context.Background())
114+
m.loadWeather(context.Background())
111115
return m
112116
}
113117

@@ -238,6 +242,9 @@ func (m *Manager) command(msg engine.Message, fields []string) {
238242
case "who", "online":
239243
m.out.Say(msg.Network, msg.Channel, m.who(msg))
240244
return
245+
case "weather", "sky":
246+
m.out.Say(msg.Network, msg.Channel, m.weatherStatus())
247+
return
241248
case "quest":
242249
m.out.Say(msg.Network, msg.Channel, m.questStatus())
243250
return
@@ -882,6 +889,7 @@ func (m *Manager) Tick() {
882889
if step < 1 {
883890
step = 1
884891
}
892+
m.weatherTick(context.Background()) // roll a fresh sky when the old one blows out
885893
m.worldEventTick(context.Background()) // begin/end a realm-wide modifier
886894
m.maybeEvent(context.Background())
887895
m.maybeMonster(context.Background())

internal/idlerpg/monsters.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ func (m *Manager) resolveFight(ctx context.Context, p player, sheet map[string]i
161161
case 2:
162162
pAtk++ // chaotic: +attack
163163
}
164+
// the local sky leans on the fight: fog blinds you, a storm steadies your foe.
165+
switch m.weatherAt(biomeOf(sheet["mx"], sheet["my"])) {
166+
case "fog":
167+
pAtk -= fogAtkPenalty
168+
case "storm":
169+
mon.Atk += stormAtkBonus
170+
}
164171
pDmgBonus := classAttackMod(sheet, class)
165172
if pDmgBonus < 0 {
166173
pDmgBonus = 0

internal/idlerpg/weather.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package idlerpg
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"sort"
8+
"strings"
9+
10+
"github.com/IamMrCupp/annoybots/internal/state"
11+
)
12+
13+
// Weather is ambient and per-biome — the coast turns stormy, the peaks snowbound,
14+
// the marsh foggy — rotating every so often. It's lighter than a realm-wide world
15+
// event: a mild thumb on the scale wherever you happen to be standing.
16+
//
17+
// fog — harder to land blows there (-2 attack)
18+
// storm — the foes there strike more surely (+2 to their attack)
19+
// rain — the going is slow (travel step reduced)
20+
// snow — slower still
21+
// clear — nothing at all
22+
const (
23+
weatherRotate = 1200 // seconds between rotations
24+
fogAtkPenalty = 2
25+
stormAtkBonus = 2
26+
rainSlow = 4 // travel step reduction
27+
snowSlow = 7
28+
)
29+
30+
func weatherKey() string { return "rpg:weather" }
31+
32+
// weatherState is the realm's current sky, per biome.
33+
type weatherState struct {
34+
Biomes map[string]string `json:"biomes"` // biome -> weather kind
35+
Deadline int64 `json:"deadline"` // unix seconds until it rotates
36+
}
37+
38+
// biomeWeather constrains what each biome can get — snow in the peaks, storms at sea.
39+
var biomeWeather = map[string][]string{
40+
"coast": {"clear", "rain", "storm", "fog"},
41+
"mountain": {"clear", "snow", "storm", "fog"},
42+
"forest": {"clear", "rain", "fog"},
43+
"swamp": {"clear", "rain", "fog", "storm"},
44+
"plains": {"clear", "rain", "storm"},
45+
}
46+
47+
// WeatherView is the dashboard's read-only view of the sky.
48+
type WeatherView struct {
49+
Biome string
50+
Kind string
51+
}
52+
53+
// ReadWeather returns the current per-biome weather, sorted by biome.
54+
func ReadWeather(ctx context.Context, store state.Store) ([]WeatherView, error) {
55+
blob, err := store.GetStr(ctx, weatherKey())
56+
if err != nil || blob == "" {
57+
return nil, err
58+
}
59+
var w weatherState
60+
if json.Unmarshal([]byte(blob), &w) != nil {
61+
return nil, nil
62+
}
63+
out := make([]WeatherView, 0, len(w.Biomes))
64+
for b, k := range w.Biomes {
65+
out = append(out, WeatherView{Biome: b, Kind: k})
66+
}
67+
sort.Slice(out, func(i, j int) bool { return out[i].Biome < out[j].Biome })
68+
return out, nil
69+
}
70+
71+
func (m *Manager) loadWeather(ctx context.Context) {
72+
blob, err := m.store.GetStr(ctx, weatherKey())
73+
if err != nil || blob == "" {
74+
return
75+
}
76+
var w weatherState
77+
if json.Unmarshal([]byte(blob), &w) != nil {
78+
return
79+
}
80+
m.wmu.Lock()
81+
m.weather = &w
82+
m.wmu.Unlock()
83+
}
84+
85+
func (m *Manager) saveWeather(ctx context.Context, w *weatherState) {
86+
if blob, err := json.Marshal(w); err == nil {
87+
_ = m.store.SetStr(ctx, weatherKey(), string(blob))
88+
}
89+
}
90+
91+
// weatherAt reports the current weather in a biome ("clear" when unknown).
92+
func (m *Manager) weatherAt(biome string) string {
93+
m.wmu.Lock()
94+
defer m.wmu.Unlock()
95+
if m.weather == nil {
96+
return "clear"
97+
}
98+
if k, ok := m.weather.Biomes[biome]; ok {
99+
return k
100+
}
101+
return "clear"
102+
}
103+
104+
// weatherTick rolls a fresh sky for every biome when the current one has run its
105+
// course (and seeds the very first one).
106+
func (m *Manager) weatherTick(ctx context.Context) {
107+
m.wmu.Lock()
108+
w := m.weather
109+
m.wmu.Unlock()
110+
if w != nil && m.now().Unix() < w.Deadline {
111+
return
112+
}
113+
next := &weatherState{Biomes: map[string]string{}, Deadline: m.now().Unix() + weatherRotate}
114+
for biome, kinds := range biomeWeather {
115+
next.Biomes[biome] = kinds[m.roll(len(kinds))]
116+
}
117+
m.wmu.Lock()
118+
m.weather = next
119+
m.wmu.Unlock()
120+
m.saveWeather(ctx, next)
121+
}
122+
123+
// travelSlow returns how much a biome's weather shortens a travel step.
124+
func travelSlow(kind string) int {
125+
switch kind {
126+
case "rain":
127+
return rainSlow
128+
case "snow":
129+
return snowSlow
130+
}
131+
return 0
132+
}
133+
134+
// weatherStatus answers !rpg weather — the sky over every biome.
135+
func (m *Manager) weatherStatus() string {
136+
m.wmu.Lock()
137+
w := m.weather
138+
m.wmu.Unlock()
139+
if w == nil || len(w.Biomes) == 0 {
140+
return "the sky is clear everywhere."
141+
}
142+
biomes := make([]string, 0, len(w.Biomes))
143+
for b := range w.Biomes {
144+
biomes = append(biomes, b)
145+
}
146+
sort.Strings(biomes)
147+
parts := make([]string, len(biomes))
148+
for i, b := range biomes {
149+
parts[i] = fmt.Sprintf("%s %s: %s", weatherIcon(w.Biomes[b]), b, w.Biomes[b])
150+
}
151+
return "🌤 the sky — " + strings.Join(parts, " · ")
152+
}
153+
154+
func weatherIcon(kind string) string {
155+
switch kind {
156+
case "rain":
157+
return "🌧"
158+
case "storm":
159+
return "⛈"
160+
case "fog":
161+
return "🌫"
162+
case "snow":
163+
return "❄️"
164+
}
165+
return "☀️"
166+
}

internal/idlerpg/weather_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package idlerpg
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestWeatherRotatesAndPersists(t *testing.T) {
10+
m, _, st := newMgr()
11+
ctx := context.Background()
12+
base := time.Unix(1000, 0)
13+
m.now = func() time.Time { return base }
14+
15+
m.weatherTick(ctx) // seeds the first sky
16+
got, err := ReadWeather(ctx, st)
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
if len(got) != len(biomeWeather) {
21+
t.Fatalf("every biome should have weather, got %d", len(got))
22+
}
23+
for _, w := range got {
24+
allowed := biomeWeather[w.Biome]
25+
ok := false
26+
for _, k := range allowed {
27+
if k == w.Kind {
28+
ok = true
29+
}
30+
}
31+
if !ok {
32+
t.Fatalf("biome %q got weather %q not in its allowed set %v", w.Biome, w.Kind, allowed)
33+
}
34+
}
35+
// before the deadline it doesn't re-roll
36+
first := m.weatherAt("coast")
37+
m.weatherTick(ctx)
38+
if m.weatherAt("coast") != first {
39+
t.Fatal("weather should not rotate before its deadline")
40+
}
41+
// after the deadline it re-rolls (kind may repeat, but the deadline must advance)
42+
m.now = func() time.Time { return base.Add(2 * time.Hour) }
43+
m.weatherTick(ctx)
44+
m.wmu.Lock()
45+
dl := m.weather.Deadline
46+
m.wmu.Unlock()
47+
if dl <= base.Unix()+weatherRotate {
48+
t.Fatal("rotating should push the deadline forward")
49+
}
50+
}
51+
52+
func TestTravelSlow(t *testing.T) {
53+
if travelSlow("clear") != 0 || travelSlow("rain") != rainSlow || travelSlow("snow") != snowSlow {
54+
t.Fatal("travelSlow wrong")
55+
}
56+
}
57+
58+
func TestWeatherStatus(t *testing.T) {
59+
m, _, _ := newMgr()
60+
if got := m.weatherStatus(); got != "the sky is clear everywhere." {
61+
t.Fatalf("no weather yet → %q", got)
62+
}
63+
m.weatherTick(context.Background())
64+
if got := m.weatherStatus(); got == "" || !contains(got, "the sky") || !contains(got, "coast") {
65+
t.Fatalf("weather status should list biomes, got %q", got)
66+
}
67+
}

internal/idlerpg/world.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ func (m *Manager) moveOnMap(ctx context.Context, p player) {
6565
if m.mountOf(ctx, p.key) != "" {
6666
step += mountBonus
6767
}
68+
step -= travelSlow(m.weatherAt(biomeOf(x, y))) // rain and snow slow the road
69+
if step < 2 {
70+
step = 2
71+
}
6872
nx, ny, reached := stepToward(int(x), int(y), t.X, t.Y, step)
6973
_ = m.store.HSet(ctx, sheetKey(p.key), "mx", int64(nx))
7074
_ = m.store.HSet(ctx, sheetKey(p.key), "my", int64(ny))

internal/rpgweb/rpgweb.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ func humanAgo(secs int64) string {
137137
}
138138
}
139139

140+
// mapData is the map page view model: the world plus the current per-biome sky.
141+
type mapData struct {
142+
idlerpg.WorldView
143+
Weather []idlerpg.WeatherView
144+
}
145+
140146
// worldMap renders the persistent world map: every placed player + the towns.
141147
func (s *Server) worldMap(w http.ResponseWriter, r *http.Request) {
142148
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -146,8 +152,9 @@ func (s *Server) worldMap(w http.ResponseWriter, r *http.Request) {
146152
http.Error(w, "the realm is unreachable right now.", http.StatusServiceUnavailable)
147153
return
148154
}
155+
sky, _ := idlerpg.ReadWeather(ctx, s.store)
149156
w.Header().Set("Content-Type", "text/html; charset=utf-8")
150-
_ = s.mapTmpl.Execute(w, world)
157+
_ = s.mapTmpl.Execute(w, mapData{WorldView: world, Weather: sky})
151158
}
152159

153160
// helpData is the /help page view model: the public command groups plus the
@@ -480,6 +487,7 @@ const mapTmpl = `<!doctype html>
480487
.sea-l { fill:#6e8a85; font-style:italic; font-size:13px; }
481488
.region-l { fill:#9a8255; font-style:italic; font-size:11px; }
482489
.frame { fill:none; stroke:#6b563b; }
490+
.sky { color:#6b5a3a; font-style:italic; margin:0 0 1rem; }
483491
.town-l { fill:#5a3a22; font-size:12px; font-variant:small-caps; }
484492
.biome-l { fill:#9a8255; font-size:8.5px; font-style:italic; }
485493
.dot { fill:#2f4a78; stroke:#e7d9b5; stroke-width:1.2; }
@@ -492,6 +500,7 @@ const mapTmpl = `<!doctype html>
492500
<body>
493501
<nav class="nav"><a href="/">⚔ realm</a><a href="/map">🗺 map</a><a href="/hall">🏆 hall of fame</a><a href="/help">📖 how to play</a></nav>
494502
<h1>🗺 the realm map</h1>
503+
{{if .Weather}}<p class="sky">the sky — {{range $i, $w := .Weather}}{{if $i}} · {{end}}{{$w.Biome}}: {{$w.Kind}}{{end}}</p>{{end}}
495504
<p class="sub">{{len .Players}} souls abroad in the realm</p>
496505
<svg class="world" viewBox="0 0 {{.Size}} {{.Size}}" role="img" aria-label="fantasy map of the realm with towns and wandering players">
497506
<rect x="0" y="0" width="{{.Size}}" height="{{.Size}}" fill="#e7d9b5"/>

0 commit comments

Comments
 (0)