Skip to content

Commit e2fc675

Browse files
authored
Merge pull request #44 from IamMrCupp/feat/rpg-quests-timed
feat(idlerpg): timed quests (parity #7)
2 parents 16de217 + 32251b1 commit e2fc675

5 files changed

Lines changed: 362 additions & 7 deletions

File tree

cmd/annoybot/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ func main() {
9292
// IdleRPG — off by default; persists to the shared state store, keyed by account.
9393
var rpgMgr *idlerpg.Manager
9494
if cfg.IdleRPG.Enabled {
95-
rpgMgr = idlerpg.New(store, router, acctMgr.Resolve, cfg.IdleRPG.Interval.D(), cfg.IdleRPG.BaseTTL.D(), log)
95+
rpgMgr = idlerpg.New(store, router, acctMgr.Resolve, cfg.IdleRPG.Interval.D(), cfg.IdleRPG.BaseTTL.D(),
96+
cfg.IdleRPG.QuestInterval.D(), cfg.IdleRPG.QuestDuration.D(), log)
9697
}
9798

9899
// Optional inter-bot bus + skit coordinator (the "botnet").

internal/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ type IdleRPG struct {
3939
Enabled bool `yaml:"enabled"`
4040
Interval engine.Duration `yaml:"interval"`
4141
BaseTTL engine.Duration `yaml:"base_ttl"`
42+
43+
// Quests (idlerpg.net-style). The gods periodically draft idle players onto a
44+
// timed quest; finishing it (without anyone talking/leaving) speeds everyone's
45+
// clock, breaking it shoves them back. Zero values fall back to sane defaults.
46+
QuestInterval engine.Duration `yaml:"quest_interval"` // avg time between quests (default 6h)
47+
QuestDuration engine.Duration `yaml:"quest_duration"` // how long a quest runs (default 1h)
4248
}
4349

4450
// ChanKeep configures eggdrop-style channel keeping (IRC only). Off by default:

internal/idlerpg/idlerpg.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,31 +53,49 @@ type Manager struct {
5353
interval time.Duration
5454
baseTTL time.Duration
5555

56+
questInterval time.Duration
57+
questDuration time.Duration
58+
now func() time.Time // injectable clock (quest deadlines); time.Now in prod
59+
5660
mu sync.Mutex
5761
online map[string]player // network|nick -> online player
5862

63+
qmu sync.Mutex
64+
quest *quest // the active quest, nil when none
65+
5966
rmu sync.Mutex
6067
rng *rand.Rand
6168
}
6269

6370
// New builds a Manager. interval is the tick period; baseTTL is the level 0→1 time.
71+
// questInterval/questDuration tune the quest cadence (zero → sane defaults).
6472
// resolve maps senders to canonical player keys (cross-network when linked).
65-
func New(store state.Store, out engine.Sender, resolve Resolver, interval, baseTTL time.Duration, log *slog.Logger) *Manager {
73+
func New(store state.Store, out engine.Sender, resolve Resolver, interval, baseTTL, questInterval, questDuration time.Duration, log *slog.Logger) *Manager {
6674
if interval <= 0 {
6775
interval = 30 * time.Second
6876
}
6977
if baseTTL <= 0 {
7078
baseTTL = 10 * time.Minute
7179
}
80+
if questInterval <= 0 {
81+
questInterval = defaultQuestInterval
82+
}
83+
if questDuration <= 0 {
84+
questDuration = defaultQuestDuration
85+
}
7286
if resolve == nil {
7387
resolve = func(network, _, nick string) string { return strings.ToLower(network) + "|" + strings.ToLower(nick) }
7488
}
75-
return &Manager{
89+
m := &Manager{
7690
store: store, out: out, resolve: resolve, log: log,
7791
interval: interval, baseTTL: baseTTL,
92+
questInterval: questInterval, questDuration: questDuration,
93+
now: time.Now,
7894
online: map[string]player{},
7995
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
8096
}
97+
m.loadQuest(context.Background())
98+
return m
8199
}
82100

83101
// roll returns a value in [0,n). Guarded so battles/events can roll concurrently.
@@ -111,13 +129,15 @@ func (m *Manager) Handle(msg engine.Message) bool {
111129
m.command(msg, fields)
112130
return true
113131
}
114-
// Talking is the cardinal sin — penalize online players.
132+
// Talking is the cardinal sin — penalize online players, and if they're on a
133+
// quest, talking blows the whole quest.
115134
if p, ok := m.onlinePlayer(msg.Network, msg.Nick); ok {
116135
pen := int64(len(msg.Text))
117136
if pen > talkCapSec {
118137
pen = talkCapSec
119138
}
120139
_, _ = m.store.HIncr(context.Background(), sheetKey(p.key), "ttl", pen)
140+
m.questViolation(context.Background(), p.key, p.nick, "spoke up")
121141
}
122142
return false
123143
}
@@ -302,34 +322,46 @@ func (m *Manager) penalizeOnline(network, nick string, secs int64) {
302322
}
303323
}
304324

305-
// OnPart / OnQuit / OnKick penalize then take the player offline.
325+
// OnPart / OnQuit / OnKick penalize, break any quest they were on, then take the
326+
// player offline.
306327
func (m *Manager) OnPart(ev event.Event) {
307328
if ev.Kind == event.Part {
308329
m.penalizeOnline(ev.Network, ev.Nick, partPenalty)
330+
m.failQuestBy(ev.Network, ev.Nick, "abandoned the party")
309331
m.OnLeave(ev)
310332
}
311333
}
312334

313335
func (m *Manager) OnQuit(ev event.Event) {
314336
if ev.Kind == event.Quit {
315337
m.penalizeOnline(ev.Network, ev.Nick, quitPenalty)
338+
m.failQuestBy(ev.Network, ev.Nick, "vanished from the realm")
316339
m.OnLeave(ev)
317340
}
318341
}
319342

320343
func (m *Manager) OnKick(ev event.Event) {
321344
if ev.Kind == event.Kick {
322345
m.penalizeOnline(ev.Network, ev.Nick, kickPenalty)
346+
m.failQuestBy(ev.Network, ev.Nick, "was hurled from the channel")
323347
m.OnLeave(ev)
324348
}
325349
}
326350

327-
// OnNick penalizes a nick change and follows the player to their new nick.
351+
// failQuestBy ruins the active quest if (network, nick) resolves to a quester.
352+
func (m *Manager) failQuestBy(network, nick, reason string) {
353+
key := m.resolve(network, "", nick)
354+
m.questViolation(context.Background(), key, nick, reason)
355+
}
356+
357+
// OnNick penalizes a nick change, breaks any quest, and follows the player to
358+
// their new nick.
328359
func (m *Manager) OnNick(ev event.Event) {
329360
if ev.Kind != event.Nick {
330361
return
331362
}
332363
m.penalizeOnline(ev.Network, ev.Nick, nickPenalty)
364+
m.failQuestBy(ev.Network, ev.Nick, "slipped away under a new name")
333365
m.mu.Lock()
334366
if p, ok := m.online[okey(ev.Network, ev.Nick)]; ok {
335367
delete(m.online, okey(ev.Network, ev.Nick))
@@ -353,6 +385,7 @@ func (m *Manager) Tick() {
353385
step = 1
354386
}
355387
m.maybeEvent(context.Background())
388+
m.questTick(context.Background())
356389
for _, p := range m.snapshot() {
357390
ctx := context.Background()
358391
key := sheetKey(p.key)

internal/idlerpg/idlerpg_test.go

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ func newMgr() (*Manager, *recorder, state.Store) {
4242
st := state.NewMem()
4343
log := slog.New(slog.NewTextHandler(io.Discard, nil))
4444
// 1s tick, 1s base ttl → one quiet tick levels you up. nil resolver = key by network|nick.
45-
m := New(st, r, nil, time.Second, time.Second, log)
45+
// Long quest interval so quests never start unless a test forces one.
46+
m := New(st, r, nil, time.Second, time.Second, time.Hour, time.Hour, log)
4647
m.rng = rand.New(rand.NewSource(1)) // deterministic for item rolls
4748
return m, r, st
4849
}
@@ -285,6 +286,116 @@ func TestPresenceIgnoresUnenrolled(t *testing.T) {
285286
}
286287
}
287288

289+
// enrollOnline enrolls a player and leaves them online (via the !rpg path).
290+
func enrollOnline(m *Manager, nick string) { m.Handle(chanMsg(nick, "!rpg")) }
291+
292+
func TestQuestStartAndComplete(t *testing.T) {
293+
m, r, st := newMgr()
294+
ctx := context.Background()
295+
base := time.Unix(1000, 0)
296+
m.now = func() time.Time { return base }
297+
enrollOnline(m, "alice")
298+
enrollOnline(m, "bob")
299+
300+
m.startQuest(ctx)
301+
if m.quest == nil {
302+
t.Fatal("a quest should have started with two idlers online")
303+
}
304+
if !r.has("a quest begins") {
305+
t.Fatalf("expected quest announcement, got %v", r.lines)
306+
}
307+
st.HSet(ctx, sheetKey("net|alice"), "ttl", 1000)
308+
st.HSet(ctx, sheetKey("net|bob"), "ttl", 1000)
309+
310+
// Roll the clock past the deadline; the tick completes the quest.
311+
m.now = func() time.Time { return base.Add(2 * time.Hour) }
312+
m.questTick(ctx)
313+
if m.quest != nil {
314+
t.Fatal("quest should be cleared after completion")
315+
}
316+
if !r.has("quest is complete") {
317+
t.Fatalf("expected completion announcement, got %v", r.lines)
318+
}
319+
if s, _ := st.HGetAll(ctx, sheetKey("net|alice")); s["ttl"] >= 1000 {
320+
t.Fatalf("completing a quest should lower ttl, got %d", s["ttl"])
321+
}
322+
}
323+
324+
func TestQuestFailsOnTalk(t *testing.T) {
325+
m, r, st := newMgr()
326+
ctx := context.Background()
327+
enrollOnline(m, "alice")
328+
enrollOnline(m, "bob")
329+
m.startQuest(ctx)
330+
st.HSet(ctx, sheetKey("net|bob"), "ttl", 1000)
331+
332+
if m.Handle(chanMsg("alice", "oops I talked")) {
333+
t.Fatal("chatter is not a consumed command")
334+
}
335+
if m.quest != nil {
336+
t.Fatal("talking during a quest must ruin it")
337+
}
338+
if !r.has("quest is RUINED") {
339+
t.Fatalf("expected ruin announcement, got %v", r.lines)
340+
}
341+
// The silent partner still eats the party-wide penalty.
342+
if s, _ := st.HGetAll(ctx, sheetKey("net|bob")); s["ttl"] <= 1000 {
343+
t.Fatalf("a ruined quest should push the whole party back, bob ttl=%d", s["ttl"])
344+
}
345+
}
346+
347+
func TestQuestFailsOnPart(t *testing.T) {
348+
m, _, _ := newMgr()
349+
ctx := context.Background()
350+
enrollOnline(m, "alice")
351+
enrollOnline(m, "bob")
352+
m.startQuest(ctx)
353+
m.OnPart(event.Event{Kind: event.Part, Network: "net", Nick: "alice"})
354+
if m.quest != nil {
355+
t.Fatal("a quester parting must ruin the quest")
356+
}
357+
}
358+
359+
func TestQuestNeedsAParty(t *testing.T) {
360+
m, _, _ := newMgr()
361+
enrollOnline(m, "alice") // only one idler online
362+
m.startQuest(context.Background())
363+
if m.quest != nil {
364+
t.Fatal("a quest should not start with fewer than two idlers")
365+
}
366+
}
367+
368+
func TestQuestTickStartsOnOdds(t *testing.T) {
369+
m, _, _ := newMgr()
370+
m.questInterval = m.interval // denom == 1 → a start is attempted every tick
371+
enrollOnline(m, "alice")
372+
enrollOnline(m, "bob")
373+
m.questTick(context.Background())
374+
if m.quest == nil {
375+
t.Fatal("questTick should start a quest when the odds guarantee it")
376+
}
377+
}
378+
379+
func TestQuestRehydratesAcrossRestart(t *testing.T) {
380+
m1, _, st := newMgr()
381+
ctx := context.Background()
382+
enrollOnline(m1, "alice")
383+
enrollOnline(m1, "bob")
384+
m1.startQuest(ctx)
385+
if m1.quest == nil {
386+
t.Fatal("setup: quest should be active")
387+
}
388+
// A fresh manager on the same store must recover the in-flight quest.
389+
log := slog.New(slog.NewTextHandler(io.Discard, nil))
390+
m2 := New(st, &recorder{}, nil, time.Second, time.Second, time.Hour, time.Hour, log)
391+
if m2.quest == nil {
392+
t.Fatal("quest should rehydrate from the store after a restart")
393+
}
394+
if len(m2.quest.Members) != 2 {
395+
t.Fatalf("recovered party size = %d; want 2", len(m2.quest.Members))
396+
}
397+
}
398+
288399
func TestLeaderboard(t *testing.T) {
289400
m, r, _ := newMgr()
290401
m.Handle(chanMsg("alice", "!rpg"))

0 commit comments

Comments
 (0)