mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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.
48 lines
2.0 KiB
Go
48 lines
2.0 KiB
Go
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)
|
|
}
|
|
}
|