Skip to content

Commit 1cc1fa2

Browse files
authored
Merge pull request #191 from IamMrCupp/feat/rpg-world-events
feat(idlerpg): seasonal world events (#190)
2 parents 49ffde5 + d053551 commit 1cc1fa2

6 files changed

Lines changed: 245 additions & 10 deletions

File tree

docs/idlerpg.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ 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+
- **World events.** Now and then the whole realm shifts for a while: a **Blood
135+
Moon** (monsters prowl far more often), the **Harvest Festival** (every coin is
136+
worth half again), or a **Storm of Fate** (the gods meddle far more often).
137+
Announced when it falls and when it passes; `!rpg info` and the dashboard show
138+
the active one and its time left.
134139
- **The wandering merchant.** Sometimes a passing trader finds you and gifts a
135140
purse of gold, a healing draught, or a shortcut toward your next level — a little
136141
kindness amid the monsters.

internal/idlerpg/idlerpg.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ type Manager struct {
7171
bmu sync.Mutex
7272
boss *worldBoss // the active world-boss raid, nil when none
7373

74+
emu sync.Mutex
75+
wevent *worldEvent // the active realm-wide world event, nil when none
76+
7477
rmu sync.Mutex
7578
rng *rand.Rand
7679
}
@@ -104,6 +107,7 @@ func New(store state.Store, out engine.Sender, resolve Resolver, interval, baseT
104107
}
105108
m.loadQuest(context.Background())
106109
m.loadBoss(context.Background())
110+
m.loadWorldEvent(context.Background())
107111
return m
108112
}
109113

@@ -475,6 +479,13 @@ func (m *Manager) info() string {
475479
}
476480
m.qmu.Unlock()
477481

482+
wev := ""
483+
m.emu.Lock()
484+
if e := m.wevent; e != nil {
485+
wev = fmt.Sprintf(" · 🌕 %s — %s (%s left)", e.Name, e.Desc, dur(e.Deadline-m.now().Unix()))
486+
}
487+
m.emu.Unlock()
488+
478489
raid := ""
479490
m.bmu.Lock()
480491
if b := m.boss; b != nil {
@@ -486,7 +497,7 @@ func (m *Manager) info() string {
486497
}
487498
m.bmu.Unlock()
488499

489-
return fmt.Sprintf("the realm: %d idling now · top idler %s · %s%s.", online, lead, quest, raid)
500+
return fmt.Sprintf("the realm: %d idling now · top idler %s · %s%s%s.", online, lead, quest, wev, raid)
490501
}
491502

492503
// questStatus describes the active quest, or says there isn't one.
@@ -871,6 +882,7 @@ func (m *Manager) Tick() {
871882
if step < 1 {
872883
step = 1
873884
}
885+
m.worldEventTick(context.Background()) // begin/end a realm-wide modifier
874886
m.maybeEvent(context.Background())
875887
m.maybeMonster(context.Background())
876888
m.questTick(context.Background())
@@ -976,7 +988,11 @@ const eventOdds = 6 // ~1-in-N chance an event fires each tick
976988
// maybeEvent occasionally visits luck (good or bad) on a random online player —
977989
// idlerpg.net's godsends, calamities, and the Hand of God.
978990
func (m *Manager) maybeEvent(ctx context.Context) {
979-
if m.roll(eventOdds) != 0 {
991+
odds := eventOdds
992+
if m.eventKind() == "tempest" { // a Storm of Fate: the gods meddle far more
993+
odds = 3
994+
}
995+
if m.roll(odds) != 0 {
980996
return
981997
}
982998
p, ok := m.randomOnline()

internal/idlerpg/monsters.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ func (m *Manager) pickMonster(level int64, biome string) monster {
120120

121121
// maybeMonster occasionally throws a monster at a random online idler.
122122
func (m *Manager) maybeMonster(ctx context.Context) {
123-
if m.roll(monsterOdds) != 0 {
123+
odds := monsterOdds
124+
if m.eventKind() == "bloodmoon" { // a Blood Moon draws the monsters out
125+
odds = 2
126+
}
127+
if m.roll(odds) != 0 {
124128
return
125129
}
126130
if p, ok := m.randomOnline(); ok {
@@ -226,14 +230,15 @@ func (m *Manager) resolveFight(ctx context.Context, p player, sheet map[string]i
226230
if usedAbility && cm.ability != "" {
227231
flourish = " with " + cm.ability
228232
}
233+
gold := m.harvestGold(mon.Gold) // the Harvest Festival fattens every purse
229234
if mon.Boss {
230235
reward := m.pctOfTTL(ctx, p.key, 22, 35)
231236
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "ttl", -reward)
232-
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "gold", mon.Gold)
237+
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "gold", gold)
233238
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "kills", bossKills)
234239
m.drama(p.network, p.channel, fmt.Sprintf(
235240
"🏆 %s has slain %s%s! a legend is born — +%dg, %d kills, and %ds toward glory.",
236-
p.nick, mon.Name, flourish, mon.Gold, bossKills, reward))
241+
p.nick, mon.Name, flourish, gold, bossKills, reward))
237242
// guaranteed top-tier spoils: two drops rolled as if far higher level.
238243
m.findItem(ctx, p, sheet["level"]+30)
239244
m.findItem(ctx, p, sheet["level"]+30)
@@ -245,22 +250,22 @@ func (m *Manager) resolveFight(ctx context.Context, p player, sheet map[string]i
245250
m.questKillCredit(ctx, p.key)
246251
m.bumpStat("kills", 1)
247252
m.bumpStat("bosses", 1)
248-
m.bumpStat("gold", mon.Gold)
253+
m.bumpStat("gold", gold)
249254
return
250255
}
251256
reward := m.pctOfTTL(ctx, p.key, 8, 14)
252257
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "ttl", -reward)
253-
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "gold", mon.Gold)
258+
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "gold", gold)
254259
_, _ = m.store.HIncr(ctx, sheetKey(p.key), "kills", 1)
255260
m.drama(p.network, p.channel, fmt.Sprintf(
256-
"⚔️ %s slew %s%s — +%dg, %ds closer to the next level.", p.nick, mon.Name, flourish, mon.Gold, reward))
261+
"⚔️ %s slew %s%s — +%dg, %ds closer to the next level.", p.nick, mon.Name, flourish, gold, reward))
257262
if m.roll(3) == 0 {
258263
m.findItem(ctx, p, sheet["level"])
259264
}
260265
m.checkCombatFeats(ctx, p, false)
261266
m.questKillCredit(ctx, p.key)
262267
m.bumpStat("kills", 1)
263-
m.bumpStat("gold", mon.Gold)
268+
m.bumpStat("gold", gold)
264269
return
265270
}
266271

internal/idlerpg/worldevent.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package idlerpg
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/IamMrCupp/annoybots/internal/state"
9+
)
10+
11+
// World events are periodic realm-wide modifiers — a Blood Moon, a Harvest
12+
// Festival, a Storm of Fate — that change the rules for everyone for a while.
13+
// They add rhythm to the long idle loop. Persisted like a quest or a raid, so
14+
// they survive a restart, and surfaced in !rpg info and on the dashboard.
15+
16+
const (
17+
worldEventOdds = 400 // ~1-in-N chance per tick one begins (when none is active)
18+
worldEventDuration = 1800 // seconds a world event lasts
19+
)
20+
21+
func eventKey() string { return "rpg:wevent" }
22+
23+
// worldEvent is the active realm modifier. Exported fields round-trip through JSON.
24+
type worldEvent struct {
25+
Kind string `json:"kind"` // "bloodmoon" | "harvest" | "tempest"
26+
Name string `json:"name"`
27+
Desc string `json:"desc"`
28+
Deadline int64 `json:"deadline"` // unix seconds
29+
Network string `json:"net"`
30+
Channel string `json:"chan"`
31+
}
32+
33+
// worldEventKinds are the modifiers a world event draws from.
34+
var worldEventKinds = []worldEvent{
35+
{Kind: "bloodmoon", Name: "a Blood Moon", Desc: "monsters prowl in far greater numbers"},
36+
{Kind: "harvest", Name: "the Harvest Festival", Desc: "every coin earned is worth half again"},
37+
{Kind: "tempest", Name: "a Storm of Fate", Desc: "the gods meddle far more often"},
38+
}
39+
40+
// WorldEventView is the dashboard's read-only view of an active world event.
41+
type WorldEventView struct {
42+
Name string
43+
Desc string
44+
Left int64 // seconds remaining
45+
}
46+
47+
// ReadWorldEvent returns the active world event, or nil if none.
48+
func ReadWorldEvent(ctx context.Context, store state.Store, now int64) (*WorldEventView, error) {
49+
blob, err := store.GetStr(ctx, eventKey())
50+
if err != nil || blob == "" {
51+
return nil, err
52+
}
53+
var e worldEvent
54+
if json.Unmarshal([]byte(blob), &e) != nil {
55+
return nil, nil
56+
}
57+
left := e.Deadline - now
58+
if left < 0 {
59+
left = 0
60+
}
61+
return &WorldEventView{Name: e.Name, Desc: e.Desc, Left: left}, nil
62+
}
63+
64+
func (m *Manager) loadWorldEvent(ctx context.Context) {
65+
blob, err := m.store.GetStr(ctx, eventKey())
66+
if err != nil || blob == "" {
67+
return
68+
}
69+
var e worldEvent
70+
if json.Unmarshal([]byte(blob), &e) != nil {
71+
return
72+
}
73+
m.emu.Lock()
74+
m.wevent = &e
75+
m.emu.Unlock()
76+
}
77+
78+
func (m *Manager) saveWorldEvent(ctx context.Context, e *worldEvent) {
79+
if blob, err := json.Marshal(e); err == nil {
80+
_ = m.store.SetStr(ctx, eventKey(), string(blob))
81+
}
82+
}
83+
84+
func (m *Manager) clearWorldEvent(ctx context.Context) {
85+
m.emu.Lock()
86+
m.wevent = nil
87+
m.emu.Unlock()
88+
_ = m.store.Del(ctx, eventKey())
89+
}
90+
91+
// eventKind returns the active world event's kind, or "" when none is running.
92+
func (m *Manager) eventKind() string {
93+
m.emu.Lock()
94+
defer m.emu.Unlock()
95+
if m.wevent == nil {
96+
return ""
97+
}
98+
return m.wevent.Kind
99+
}
100+
101+
// worldEventTick ends an expired world event, or rarely begins a new one.
102+
func (m *Manager) worldEventTick(ctx context.Context) {
103+
m.emu.Lock()
104+
e := m.wevent
105+
m.emu.Unlock()
106+
if e != nil {
107+
if m.now().Unix() >= e.Deadline {
108+
m.drama(e.Network, e.Channel, fmt.Sprintf("🌙 %s passes. the realm returns to its usual cruelty.", e.Name))
109+
m.clearWorldEvent(ctx)
110+
}
111+
return
112+
}
113+
if m.roll(worldEventOdds) != 0 {
114+
return
115+
}
116+
p, ok := m.randomOnline()
117+
if !ok {
118+
return
119+
}
120+
pick := worldEventKinds[m.roll(len(worldEventKinds))]
121+
ev := &worldEvent{
122+
Kind: pick.Kind, Name: pick.Name, Desc: pick.Desc,
123+
Deadline: m.now().Unix() + worldEventDuration,
124+
Network: p.network, Channel: p.channel,
125+
}
126+
m.emu.Lock()
127+
m.wevent = ev
128+
m.emu.Unlock()
129+
m.saveWorldEvent(ctx, ev)
130+
m.drama(ev.Network, ev.Channel, fmt.Sprintf("🌕 %s falls over the realm — %s! (for the next %s)", ev.Name, ev.Desc, dur(worldEventDuration)))
131+
}
132+
133+
// harvestGold applies the Harvest Festival bonus to a gold reward.
134+
func (m *Manager) harvestGold(g int64) int64 {
135+
if m.eventKind() == "harvest" {
136+
return g * 3 / 2
137+
}
138+
return g
139+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package idlerpg
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestWorldEventStartsAndExpires(t *testing.T) {
11+
m, r, st := newMgr()
12+
ctx := context.Background()
13+
base := time.Unix(1000, 0)
14+
m.now = func() time.Time { return base }
15+
m.Handle(chanMsg("alice", "!rpg"))
16+
17+
// force one to start by rolling until it fires (worldEventOdds is rare)
18+
for i := 0; i < 5000 && m.eventKind() == ""; i++ {
19+
m.worldEventTick(ctx)
20+
}
21+
if m.eventKind() == "" {
22+
t.Fatal("a world event should eventually begin")
23+
}
24+
if !r.has("falls over the realm") {
25+
t.Fatalf("world event should announce, got %v", r.lines)
26+
}
27+
if wv, _ := ReadWorldEvent(ctx, st, base.Unix()); wv == nil || wv.Name == "" {
28+
t.Fatal("the dashboard should see the active world event")
29+
}
30+
// expire it
31+
m.now = func() time.Time { return base.Add(2 * time.Hour) }
32+
m.worldEventTick(ctx)
33+
if m.eventKind() != "" {
34+
t.Fatal("an expired world event should be cleared")
35+
}
36+
if !r.has("passes. the realm returns") {
37+
t.Fatalf("expiry should announce, got %v", r.lines)
38+
}
39+
}
40+
41+
func TestHarvestGoldBonus(t *testing.T) {
42+
m, _, _ := newMgr()
43+
if got := m.harvestGold(100); got != 100 {
44+
t.Fatalf("no event → gold unchanged, got %d", got)
45+
}
46+
m.emu.Lock()
47+
m.wevent = &worldEvent{Kind: "harvest"}
48+
m.emu.Unlock()
49+
if got := m.harvestGold(100); got != 150 {
50+
t.Fatalf("harvest → +50%%, got %d", got)
51+
}
52+
}
53+
54+
func TestWorldEventShowsInInfo(t *testing.T) {
55+
m, _, _ := newMgr()
56+
m.Handle(chanMsg("alice", "!rpg"))
57+
m.emu.Lock()
58+
m.wevent = &worldEvent{Kind: "bloodmoon", Name: "a Blood Moon", Desc: "monsters prowl", Deadline: m.now().Unix() + 600}
59+
m.emu.Unlock()
60+
if got := m.info(); !strings.Contains(got, "Blood Moon") {
61+
t.Fatalf("info should mention the active world event, got %q", got)
62+
}
63+
}

internal/rpgweb/rpgweb.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ type pageData struct {
7979
Feed []feedRow
8080
Stats idlerpg.RealmStats
8181
Boss *idlerpg.WorldBossView
82+
WEvent *idlerpg.WorldEventView
8283
}
8384

8485
// feedRow is one rendered activity-feed line: the text plus a relative timestamp.
@@ -106,8 +107,9 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
106107
events, _ := idlerpg.ReadFeed(ctx, s.store, feedSize)
107108
stats, _ := idlerpg.ReadStats(ctx, s.store)
108109
boss, _ := idlerpg.ReadWorldBoss(ctx, s.store)
110+
wev, _ := idlerpg.ReadWorldEvent(ctx, s.store, s.now().Unix())
109111

110-
data := pageData{Board: board, Quest: quest, Stats: stats, Boss: boss}
112+
data := pageData{Board: board, Quest: quest, Stats: stats, Boss: boss, WEvent: wev}
111113
if quest != nil && quest.Kind != "map" {
112114
data.QuestLeft = humanLeft(quest.Deadline - s.now().Unix())
113115
}
@@ -282,6 +284,8 @@ const indexTmpl = `<!doctype html>
282284
.stat { flex:1 1 7rem; min-width:7rem; background:#15171d; border:1px solid #20232b; border-radius:8px; padding:.6rem .8rem; }
283285
.stat .num { display:block; font-size:1.5rem; color:#e9b949; line-height:1.1; }
284286
.stat .lab { font-size:.75rem; color:#7c8290; text-transform:uppercase; letter-spacing:.05em; }
287+
.wevent { max-width:760px; background:#12161d; border:1px solid #2c3b52; border-radius:8px; padding:.7rem 1rem; margin:0 0 1rem; }
288+
.wevent strong { color:#8aa0c6; }
285289
.boss { max-width:760px; background:#1d1212; border:1px solid #5a2b2b; border-radius:8px; padding:.8rem 1rem; margin:0 0 1.5rem; }
286290
.boss strong { color:#e06c75; }
287291
.bar { height:14px; background:#2a1414; border-radius:7px; overflow:hidden; margin-top:.5rem; }
@@ -305,6 +309,9 @@ const indexTmpl = `<!doctype html>
305309
<div class="stat"><span class="num">{{.Stats.Gold}}</span><span class="lab">gold minted</span></div>
306310
<div class="stat"><span class="num">{{.Stats.Legendaries}}</span><span class="lab">legendaries</span></div>
307311
</div>
312+
{{if .WEvent}}
313+
<div class="wevent"><strong>🌕 {{.WEvent.Name}}</strong> — {{.WEvent.Desc}} <span class="muted">({{dur .WEvent.Left}} left)</span></div>
314+
{{end}}
308315
{{if .Boss}}
309316
<div class="boss">
310317
<strong>🐲 WORLD BOSS — {{.Boss.Name}}</strong>

0 commit comments

Comments
 (0)