diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 764e420..66fa45b 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -263,6 +263,11 @@ func (p *AdventurePlugin) Init() error { // Revive any characters whose DeadUntil has expired p.catchUpRespawns(chars) + // Seed the boredom idle clock before the ticker that reads it can wake: + // an unseeded column reads as "idle since character creation" for every + // pre-existing player. + bootstrapBoredomClock() + // Start schedulers — single shared stopCh allows graceful shutdown. if p.stopCh == nil { p.stopCh = make(chan struct{}) diff --git a/internal/plugin/bootstrap_boredom_clock.go b/internal/plugin/bootstrap_boredom_clock.go new file mode 100644 index 0000000..085bb5d --- /dev/null +++ b/internal/plugin/bootstrap_boredom_clock.go @@ -0,0 +1,47 @@ +package plugin + +import ( + "log/slog" + + "gogobee/internal/db" +) + +// bootstrapBoredomClock seeds player_meta.last_player_action_at for rows that +// predate the column (gogobee_boredom_plan.md §1). +// +// Without this, the column is NULL for every existing player on the deploy that +// adds it, and loadBoredomCandidates falls back to created_at — which is when +// the character was *made*, not when it was last played. Every alive character +// on the server is therefore months past the idle threshold on the first tick +// after the deploy, and the whole player base gets marched into a dungeon at +// once, coins debited, thirty minutes after restart. A player who typed a +// command a minute before the deploy is no exception: the clock has never heard +// of them. +// +// last_active_at is the best proxy we have for "the character did something +// recently" and is exactly right at deploy time: recent for anyone still +// playing, stale for anyone who isn't. It is unusable as the clock itself (the +// autopilot bumps it through saveAdvCharacter, so a bored character would keep +// refreshing its own idle clock — see markPlayerAction), which is why this is a +// one-time seed and not a fallback in the query. +// +// Re-running on every boot is safe and intentionally a no-op: +// - a row seeded here, or stamped by a real action, is no longer NULL; +// - a character the boredom ticker has already sent out (last_boredom_at set) +// is skipped, so a restart mid-boredom can't hand it a fresh idle clock off +// the autopilot's own writes. +func bootstrapBoredomClock() { + res, err := db.Get().Exec(` + UPDATE player_meta + SET last_player_action_at = COALESCE(last_active_at, created_at) + WHERE last_player_action_at IS NULL + AND last_boredom_at IS NULL + AND COALESCE(last_active_at, created_at) IS NOT NULL`) + if err != nil { + slog.Error("bootstrap: boredom clock seed failed", "err", err) + return + } + if n, _ := res.RowsAffected(); n > 0 { + slog.Warn("bootstrap: seeded boredom idle clock from last_active_at", "rows", n) + } +} diff --git a/internal/plugin/expedition_boredom.go b/internal/plugin/expedition_boredom.go index cc7503a..0c23633 100644 --- a/internal/plugin/expedition_boredom.go +++ b/internal/plugin/expedition_boredom.go @@ -100,14 +100,33 @@ var advActionCommands = map[string]bool{ // something to their adventurer — an Adventure command, or a reply to a prompt // we're holding open for them (shop, blacksmith, pet, treasure). Both count: // answering "2" to a shop menu is as much an action as typing `!shop`. +// +// This runs on every message the bot sees, in every room, so the command test +// is a single tokenise-and-look-up rather than 50 passes of IsCommand over the +// body. It matches util.IsCommand's rule exactly: trimmed, lowercased, the +// command is the whole body or is followed by a space. +// +// The pending-prompt check honours ExpiresAt. Nothing sweeps p.pending, so an +// abandoned prompt (offered, never answered) sits there forever — without the +// expiry test, every idle remark that player ever makes in any room would read +// as tending their adventurer, and the clock would never run down. func (p *AdventurePlugin) isPlayerAdventureAction(ctx MessageContext) bool { - for name := range advActionCommands { - if p.IsCommand(ctx.Body, name) { + body := strings.ToLower(strings.TrimSpace(ctx.Body)) + if strings.HasPrefix(body, p.Prefix) { + word := strings.TrimPrefix(body, p.Prefix) + if i := strings.IndexByte(word, ' '); i >= 0 { + word = word[:i] + } + if advActionCommands[word] { return true } } - if _, pending := p.pending.Load(string(ctx.Sender)); pending { - return true + if v, ok := p.pending.Load(string(ctx.Sender)); ok { + pi, isPrompt := v.(*advPendingInteraction) + // A zero ExpiresAt is a prompt with no deadline, not an expired one. + if isPrompt && (pi.ExpiresAt.IsZero() || time.Now().Before(pi.ExpiresAt)) { + return true + } } return false } diff --git a/internal/plugin/expedition_boredom_clock_test.go b/internal/plugin/expedition_boredom_clock_test.go index aea5ae3..4a90d30 100644 --- a/internal/plugin/expedition_boredom_clock_test.go +++ b/internal/plugin/expedition_boredom_clock_test.go @@ -189,3 +189,73 @@ func TestLastExpeditionByZoneScans(t *testing.T) { "the picker relies on this to rotate zones", got[ZoneGoblinWarrens], got[ZoneCryptValdris]) } } + +// The deploy this column ships in: last_player_action_at is NULL for everyone +// alive, and created_at is when the character was *made* — months ago for the +// regulars. Without a seed, the very first tick after restart reads the whole +// server as idle-since-creation and marches all of them into a dungeon, coins +// debited, including the player who typed a command a minute before the deploy. +func TestBootstrapSeedsClockSoADeployDoesNotEmptyTheTown(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + longAgo := now.Add(-90 * 24 * time.Hour) + recent := now.Add(-30 * time.Minute) + + // A regular: made months ago, played half an hour before the deploy. + seedBoredomPlayerActive(t, "@regular:test", longAgo, &recent, nil) + // Genuinely abandoned: made months ago, last touched the game months ago. + seedBoredomPlayerActive(t, "@lapsed:test", longAgo, &longAgo, nil) + + bootstrapBoredomClock() + + uids, err := loadBoredomCandidates(now) + if err != nil { + t.Fatalf("loadBoredomCandidates: %v", err) + } + got := map[id.UserID]bool{} + for _, u := range uids { + got[u] = true + } + if got["@regular:test"] { + t.Error("a player who acted 30 minutes ago was sent into a dungeon by the deploy") + } + if !got["@lapsed:test"] { + t.Error("the abandoned character is exactly who this feature is for, and it skipped them") + } + if playerIsIdle("@regular:test", now) { + t.Error("playerIsIdle still reads an active player as idle — Robbie would go silent for them") + } +} + +// Same seed, but the character is already out on a bored expedition +// (last_boredom_at set). Its own autopilot bumps last_active_at every day, so +// re-seeding from it on each restart would hand the character a fresh idle +// clock and boredom would never fire again. +func TestBootstrapLeavesAlreadyBoredCharactersAlone(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + longAgo := now.Add(-90 * 24 * time.Hour) + autopilotWrite := now.Add(-10 * time.Minute) + wentOut := now.Add(-48 * time.Hour) + + seedBoredomPlayerActive(t, "@bored:test", longAgo, &autopilotWrite, &wentOut) + bootstrapBoredomClock() + + if !playerIsIdle("@bored:test", now) { + t.Fatal("restart re-seeded a bored character's clock off its own autopilot writes; it would never get bored again") + } +} + +// seedBoredomPlayerActive seeds a row that predates the boredom column: +// last_player_action_at NULL, with a last_active_at the autopilot/saves have +// been keeping current. +func seedBoredomPlayerActive(t *testing.T, uid id.UserID, created time.Time, lastActive, lastBoredom *time.Time) { + t.Helper() + _, err := db.Get().Exec( + `INSERT INTO player_meta (user_id, display_name, alive, created_at, last_active_at, last_player_action_at, last_boredom_at) + VALUES (?, ?, 1, ?, ?, NULL, ?)`, + string(uid), "Test", created, lastActive, lastBoredom) + if err != nil { + t.Fatalf("seed player_meta: %v", err) + } +}