boredom: the idle clock has to be seeded, or the deploy empties the town

last_player_action_at is NULL for every existing row on the deploy that
adds it, and loadBoredomCandidates COALESCEs the NULL onto created_at.
created_at is account age, not an idle clock: for anyone who has been
playing for a month it is a month old. So the first tick after restart
would read the entire server as idle-since-creation and march all of
them into a dungeon thirty minutes later, coins debited, including the
player who typed a command a minute before the deploy.

Seed the column once from last_active_at instead — the best "did
something recently" proxy that exists at deploy time, recent for the
regulars and stale for the lapsed. It stays unusable as the clock itself
(the autopilot bumps it), which is why this is a one-time seed and not a
fallback in the query. Re-running on every boot is a no-op, and it skips
rows that already have last_boredom_at set, so a restart mid-boredom
can't hand a bored character a fresh idle clock off its own autopilot
writes and un-bore it permanently.

Two smaller ones in the same clock:

- a pending prompt counted as an action regardless of ExpiresAt, and
  nothing sweeps p.pending. One offered-and-ignored treasure prompt and
  every idle remark that player ever made would read as tending their
  adventurer, forever.
- the command test ran 50 IsCommand passes over the body on every
  message the bot sees, in every room. One tokenise-and-look-up does the
  same job.
This commit is contained in:
prosolis
2026-07-12 19:08:22 -07:00
parent 7c379b298c
commit e377e0c85c
4 changed files with 145 additions and 4 deletions

View File

@@ -263,6 +263,11 @@ func (p *AdventurePlugin) Init() error {
// Revive any characters whose DeadUntil has expired // Revive any characters whose DeadUntil has expired
p.catchUpRespawns(chars) 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. // Start schedulers — single shared stopCh allows graceful shutdown.
if p.stopCh == nil { if p.stopCh == nil {
p.stopCh = make(chan struct{}) p.stopCh = make(chan struct{})

View File

@@ -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)
}
}

View File

@@ -100,14 +100,33 @@ var advActionCommands = map[string]bool{
// something to their adventurer — an Adventure command, or a reply to a prompt // 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: // 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`. // 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 { func (p *AdventurePlugin) isPlayerAdventureAction(ctx MessageContext) bool {
for name := range advActionCommands { body := strings.ToLower(strings.TrimSpace(ctx.Body))
if p.IsCommand(ctx.Body, name) { 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 return true
} }
} }
if _, pending := p.pending.Load(string(ctx.Sender)); pending { if v, ok := p.pending.Load(string(ctx.Sender)); ok {
return true 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 return false
} }

View File

@@ -189,3 +189,73 @@ func TestLastExpeditionByZoneScans(t *testing.T) {
"the picker relies on this to rotate zones", got[ZoneGoblinWarrens], got[ZoneCryptValdris]) "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)
}
}