Files
gogobee/internal/plugin/expedition_boredom_clock_test.go
prosolis 7c379b298c adventure: bored adventurers go into dungeons on their own
A player who stops tending their adventurer doesn't stop having one. After
24h with no action against Adventure, the character gets restless and leaves
on an expedition by itself: the easiest zone its level band allows, the
cheapest supplies it can afford, and whatever gear was already on the rack.

Everything downstream of the start was already autonomous — the autopilot
walks rooms, drives elite and boss fights on the turn engine, camps, harvests
and picks forks. The only thing that ever needed a human was `!expedition
start`, so that's all this adds: a 30m ticker plus a clock.

It never buys or equips anything, and that is the whole mechanic: a neglected
adventurer grinds half-starved runs on rusting gear and comes home taxed. The
prodexercise killed an L4 mage four rooms in on its first run.

The clock is a new column. Every existing timestamp is unusable: last_active_at
is auto-bumped by saveAdvCharacter (the autopilot would refresh a bored
character's own idle clock), loadAdvDailyActivity counts the autopilot's own
expedition logs, and user_stats.updated_at is chat presence, not a game action.
last_player_action_at is written only by markPlayerAction, from a real player
action against Adventure — any interface, not just Matrix.

Raid zones (raidContentWarning: the party-tuned T5 bosses with a 0% solo clear)
are avoided while anything else is in band. At L13+ they're all that's left, and
the adventurer goes in anyway and loses. That's intended.

dnd_expedition.boredom + isBoredomDriven stop a run nobody asked for from
shielding an absent player from the idle reaper or holding their streak. A
manually-started expedition still holds it while the autopilot walks it.

Robbie visits and pays silently for idle players — he was going to file a daily
public bulletin about people who stopped playing weeks ago.

Note for anyone touching the time-scanning queries here: modernc.org/sqlite
rebuilds a time.Time from the column's declared type, and COALESCE()/MAX() erase
it. playerIsIdle fails open, so a broken scan there declares the whole server
idle. Both it and lastExpeditionByZone select declared columns and fold in Go,
and the tests seed real rows so the scan actually executes.
2026-07-12 18:57:50 -07:00

192 lines
6.3 KiB
Go

package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// These exercise the idle clock against real rows.
//
// The hazard they exist for: modernc.org/sqlite rebuilds a time.Time from the
// column's *declared* type, and both COALESCE() and MAX() erase it. A Scan that
// silently fails here is not a small bug — playerIsIdle fails open (a player it
// can't read counts as idle), so a broken scan would declare the entire server
// idle and march everyone into a dungeon at once.
func seedBoredomPlayer(t *testing.T, uid id.UserID, created, lastAction, lastBoredom *time.Time) {
t.Helper()
_, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at, last_boredom_at)
VALUES (?, ?, 1, ?, ?, ?)`,
string(uid), "Test", created, lastAction, lastBoredom)
if err != nil {
t.Fatalf("seed player_meta: %v", err)
}
}
func TestPlayerIsIdleReadsTheClock(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
recent := now.Add(-1 * time.Hour)
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
// Never acted: the COALESCE falls through to created_at.
seedBoredomPlayer(t, "@never:test", &old, nil, nil)
seedBoredomPlayer(t, "@fresh:test", &recent, nil, nil)
cases := []struct {
uid id.UserID
want bool
why string
}{
{"@idle:test", true, "last acted 72h ago"},
{"@active:test", false, "acted an hour ago"},
{"@never:test", true, "never acted, created 72h ago — COALESCE to created_at"},
{"@fresh:test", false, "never acted, but only just created — must not be yanked out on day one"},
}
for _, tc := range cases {
if got := playerIsIdle(tc.uid, now); got != tc.want {
t.Errorf("playerIsIdle(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
}
}
}
func TestLoadBoredomCandidatesRespectsClockAndCooldown(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
recent := now.Add(-1 * time.Hour)
seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
// Idle, but already sent out an hour ago — the cooldown holds them back.
seedBoredomPlayer(t, "@cooling:test", &old, &old, &recent)
// Idle, and their last outing was days ago — eligible again.
seedBoredomPlayer(t, "@ready:test", &old, &old, &old)
got, err := loadBoredomCandidates(now)
if err != nil {
t.Fatalf("loadBoredomCandidates: %v", err)
}
set := map[id.UserID]bool{}
for _, u := range got {
set[u] = true
}
for uid, want := range map[id.UserID]bool{
"@idle:test": true,
"@ready:test": true,
"@active:test": false,
"@cooling:test": false,
} {
if set[uid] != want {
t.Errorf("candidate %s: got %v, want %v", uid, set[uid], want)
}
}
}
func TestMarkPlayerActionResetsTheClock(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
seedBoredomPlayer(t, "@back:test", &old, &old, nil)
if !playerIsIdle("@back:test", now) {
t.Fatal("precondition: player should start idle")
}
markPlayerAction("@back:test")
if playerIsIdle("@back:test", now) {
t.Error("player acted against Adventure but is still counted idle — the clock isn't being stamped")
}
}
// TestIsBoredomDrivenNeedsBothHalves pins the rule the idle reaper depends on:
// only an expedition the player never asked for, from a player who still hasn't
// come back, loses its streak protection. A self-started expedition holds it.
func TestIsBoredomDrivenNeedsBothHalves(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-72 * time.Hour)
recent := now.Add(-1 * time.Hour)
seedExp := func(t *testing.T, uid id.UserID, boredom bool) {
t.Helper()
_, err := db.Get().Exec(
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date, boredom)
VALUES (?, ?, 'goblin_warrens', 'active', ?, ?)`,
string(uid)+"-exp", string(uid), old, boredom)
if err != nil {
t.Fatalf("seed expedition: %v", err)
}
}
seedBoredomPlayer(t, "@bored:test", &old, &old, nil)
seedExp(t, "@bored:test", true)
seedBoredomPlayer(t, "@selfstart:test", &old, &old, nil)
seedExp(t, "@selfstart:test", false)
seedBoredomPlayer(t, "@returned:test", &old, &recent, nil)
seedExp(t, "@returned:test", true)
seedBoredomPlayer(t, "@noexp:test", &old, &old, nil)
cases := []struct {
uid id.UserID
want bool
why string
}{
{"@bored:test", true, "absent player, expedition it started itself — reap"},
{"@selfstart:test", false, "absent, but they started this expedition themselves — the autopilot still holds their streak"},
{"@returned:test", false, "bored expedition, but the player came back — don't reap someone who's playing"},
{"@noexp:test", false, "no expedition history at all — don't reap on a guess"},
}
for _, tc := range cases {
if got := isBoredomDriven(tc.uid, now); got != tc.want {
t.Errorf("isBoredomDriven(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
}
}
}
// TestLastExpeditionByZoneScans covers the MAX(start_date) scan — the same
// type-affinity hazard as the COALESCE above, and the tie-breaker that stops a
// bored adventurer grinding one zone forever.
func TestLastExpeditionByZoneScans(t *testing.T) {
newBoredomTestDB(t)
uid := id.UserID("@hist:test")
older := time.Now().UTC().Add(-96 * time.Hour)
newer := time.Now().UTC().Add(-24 * time.Hour)
for i, e := range []struct {
zone string
when time.Time
}{
{"goblin_warrens", older},
{"goblin_warrens", newer},
{"crypt_valdris", older},
} {
if _, err := db.Get().Exec(
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date)
VALUES (?, ?, ?, 'complete', ?)`,
string(uid)+string(rune('a'+i)), string(uid), e.zone, e.when); err != nil {
t.Fatalf("seed: %v", err)
}
}
got, err := lastExpeditionByZone(uid)
if err != nil {
t.Fatalf("lastExpeditionByZone: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d zones, want 2 — the MAX(start_date) scan is dropping rows: %v", len(got), got)
}
if !got[ZoneGoblinWarrens].After(got[ZoneCryptValdris]) {
t.Errorf("goblin_warrens (%v) should be more recent than crypt_valdris (%v); "+
"the picker relies on this to rotate zones", got[ZoneGoblinWarrens], got[ZoneCryptValdris])
}
}