|
| 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 | +} |
0 commit comments