mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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.
428 lines
15 KiB
Go
428 lines
15 KiB
Go
//go:build prodexercise
|
||
|
||
package plugin
|
||
|
||
// Headless end-to-end exercise of the bored-adventurer path against a COPY of
|
||
// the prod DB. This covers the one seam the unit tests can't reach: the
|
||
// tryBoredomStart → beginExpedition → autopilot chain, which needs a live
|
||
// EuroPlugin and a real character with real gear.
|
||
//
|
||
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||
// go test -tags prodexercise -run TestExerciseBoredomProd -v ./internal/plugin/
|
||
//
|
||
// As with TestExerciseNewFeaturesProd, the DB copy is re-copied into t.TempDir()
|
||
// before opening, so even the copy is left pristine and nothing can reach prod.
|
||
//
|
||
// What it has to prove:
|
||
// 1. The idle clock picks out exactly the idle players — a player who acted an
|
||
// hour ago must NOT be swept up. (playerIsIdle fails open, so a regression
|
||
// here marches the whole server into a dungeon at once.)
|
||
// 2. A bored run actually starts against a real character: real zone, lean
|
||
// supplies, coins debited.
|
||
// 3. It buys and equips nothing. That's the whole mechanic.
|
||
// 4. The expedition is flagged boredom=1, so the idle reaper won't let it
|
||
// shield an absent player's streak.
|
||
// 5. It re-entrantly refuses: a second tick inside the cooldown does nothing.
|
||
// 6. The autopilot will actually walk it.
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"testing"
|
||
"time"
|
||
|
||
"gogobee/internal/db"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
func TestExerciseBoredomProd(t *testing.T) {
|
||
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||
if srcDir == "" {
|
||
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||
}
|
||
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||
t.Skipf("no gogobee.db in %s", srcDir)
|
||
}
|
||
|
||
tmp := t.TempDir()
|
||
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||
}
|
||
db.Close()
|
||
if err := db.Init(tmp); err != nil {
|
||
t.Fatalf("db.Init: %v", err)
|
||
}
|
||
t.Cleanup(db.Close)
|
||
|
||
simOmenDisabled = true
|
||
|
||
euro := NewEuroPlugin(nil)
|
||
xp := NewXPPlugin(nil)
|
||
p := NewAdventurePlugin(nil, euro, xp)
|
||
sink := &captureSink{}
|
||
p.Sink = sink
|
||
mark := 0
|
||
drain := func() {
|
||
for _, m := range sink.msgs[mark:] {
|
||
who := string(m.ToUser)
|
||
if who == "" {
|
||
who = "room:" + string(m.ToRoom)
|
||
}
|
||
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||
}
|
||
mark = len(sink.msgs)
|
||
}
|
||
|
||
chars := runnableChars(t)
|
||
if len(chars) < 2 {
|
||
t.Fatalf("need 2+ runnable characters in the DB copy, got %d", len(chars))
|
||
}
|
||
|
||
// A pinned clock, 14:00 UTC today: outside the ambient quiet windows (06:00
|
||
// briefing / 21:00 recap) that fireExpeditionBoredom deliberately refuses to
|
||
// talk over, so the run is deterministic regardless of when we invoke it.
|
||
now := time.Now().UTC().Truncate(24 * time.Hour).Add(14 * time.Hour)
|
||
if inAmbientQuietWindow(now) {
|
||
t.Fatal("pinned clock landed in the quiet window — pick another hour")
|
||
}
|
||
|
||
// Clear the decks: nobody in the copy should be mid-expedition when we start,
|
||
// or the structural guards will (correctly) refuse and prove nothing.
|
||
for _, c := range chars {
|
||
clearExpeditionState(t, c.uid)
|
||
healToFull(t, c.uid)
|
||
euro.Credit(c.uid, 2000, "boredom exercise bankroll")
|
||
}
|
||
|
||
// The control arm. Without one, "everybody went" and "the clock works" look
|
||
// identical — and the failure mode we're hunting (playerIsIdle failing open)
|
||
// produces exactly the former.
|
||
active := chars[0]
|
||
idle := chars[1:]
|
||
setLastAction(t, active.uid, now.Add(-1*time.Hour))
|
||
for _, c := range idle {
|
||
setLastAction(t, c.uid, now.Add(-72*time.Hour))
|
||
}
|
||
// Everyone else in the DB is left alone but must not be dragged along either;
|
||
// snapshot who has an expedition now so we can diff at the end.
|
||
before := activeExpeditionOwners(t)
|
||
|
||
t.Logf("──────── clock ────────")
|
||
t.Logf(" control (acted 1h ago): %s L%d %s", active.uid, active.level, active.class)
|
||
for _, c := range idle {
|
||
t.Logf(" idle (72h silent): %s L%d %s", c.uid, c.level, c.class)
|
||
}
|
||
|
||
// ── 1. The candidate query ──────────────────────────────────────────────
|
||
cands, err := loadBoredomCandidates(now)
|
||
if err != nil {
|
||
t.Fatalf("loadBoredomCandidates: %v", err)
|
||
}
|
||
candSet := map[id.UserID]bool{}
|
||
for _, u := range cands {
|
||
candSet[u] = true
|
||
}
|
||
t.Logf(" loadBoredomCandidates → %d players", len(cands))
|
||
if candSet[active.uid] {
|
||
t.Errorf("control %s acted an hour ago but is a boredom candidate — the idle clock is not being read", active.uid)
|
||
}
|
||
for _, c := range idle {
|
||
if !candSet[c.uid] {
|
||
t.Errorf("idle %s has been silent 72h but is not a candidate", c.uid)
|
||
}
|
||
if playerIsIdle(active.uid, now) {
|
||
t.Fatalf("playerIsIdle says the control player is idle — it is failing open, ABORT (this is the bug that marches the whole server into a dungeon)")
|
||
}
|
||
if !playerIsIdle(c.uid, now) {
|
||
t.Errorf("playerIsIdle(%s) = false after 72h of silence", c.uid)
|
||
}
|
||
}
|
||
|
||
// ── 2/3/4. The run itself ───────────────────────────────────────────────
|
||
type snap struct {
|
||
balance float64
|
||
gear string
|
||
}
|
||
pre := map[id.UserID]snap{}
|
||
for _, c := range chars {
|
||
pre[c.uid] = snap{balance: euro.GetBalance(c.uid), gear: gearFingerprint(t, c.uid)}
|
||
}
|
||
|
||
t.Logf("──────── fireExpeditionBoredom ────────")
|
||
p.fireExpeditionBoredom(now)
|
||
drain()
|
||
|
||
for _, c := range idle {
|
||
exp, err := getActiveExpedition(c.uid)
|
||
if err != nil || exp == nil {
|
||
t.Errorf("%s (L%d): no expedition started (err=%v) — the boredom start path is broken", c.uid, c.level, err)
|
||
continue
|
||
}
|
||
zone, ok := getZone(exp.ZoneID)
|
||
if !ok {
|
||
t.Errorf("%s: started an unknown zone %q", c.uid, exp.ZoneID)
|
||
continue
|
||
}
|
||
spent := pre[c.uid].balance - euro.GetBalance(c.uid)
|
||
lean := loadoutPurchase(zone.Tier, LoadoutLean)
|
||
t.Logf(" %s L%d → %s (T%d) — spent %.0f coins (lean = %d)",
|
||
c.uid, c.level, zone.Display, int(zone.Tier), spent, lean.Cost())
|
||
|
||
// In band.
|
||
if c.level < zone.LevelMin || c.level > zone.LevelMax {
|
||
t.Errorf("%s L%d sent to %s, band %d–%d — out of band",
|
||
c.uid, c.level, zone.ID, zone.LevelMin, zone.LevelMax)
|
||
}
|
||
// Lean supplies, exactly. Anything else means it went shopping.
|
||
if int(spent) != lean.Cost() {
|
||
t.Errorf("%s spent %.0f coins, lean pack for T%d is %d — it bought something it shouldn't have",
|
||
c.uid, spent, int(zone.Tier), lean.Cost())
|
||
}
|
||
// Gear untouched. This is the mechanic, not a nicety.
|
||
if got := gearFingerprint(t, c.uid); got != pre[c.uid].gear {
|
||
t.Errorf("%s: equipment changed across a bored run.\n was: %s\n now: %s", c.uid, pre[c.uid].gear, got)
|
||
}
|
||
// Flagged, so the idle reaper won't let it hold their streak.
|
||
if !expeditionIsBoredom(t, exp.ID) {
|
||
t.Errorf("%s: expedition %s not flagged boredom=1 — it will shield an absent player's streak", c.uid, exp.ID)
|
||
}
|
||
if !isBoredomDriven(c.uid, now) {
|
||
t.Errorf("isBoredomDriven(%s) = false right after a bored start", c.uid)
|
||
}
|
||
// Raid only where there was no alternative.
|
||
if w := raidContentWarning(zone.ID); w != "" && c.level < 13 {
|
||
t.Errorf("%s L%d was sent into raid zone %s while non-raid zones were in band", c.uid, c.level, zone.ID)
|
||
}
|
||
}
|
||
|
||
// The control player must be exactly where we left them.
|
||
if exp, _ := getActiveExpedition(active.uid); exp != nil {
|
||
t.Errorf("control %s acted an hour ago and was still sent on an expedition (%s)", active.uid, exp.ZoneID)
|
||
}
|
||
if b := euro.GetBalance(active.uid); b != pre[active.uid].balance {
|
||
t.Errorf("control %s was charged %.0f coins for an expedition they didn't take",
|
||
active.uid, pre[active.uid].balance-b)
|
||
}
|
||
// The sweep reaches every idle player in the DB, not just the two we staged —
|
||
// that's the feature working, not a leak. The invariant that actually matters
|
||
// is that *everyone who left was idle*. (runnableChars only lists characters
|
||
// with the legacy adventure_characters row, so the DB holds idle players it
|
||
// doesn't return; they're still real characters and they're right to go.)
|
||
departed := diffOwners(activeExpeditionOwners(t), before)
|
||
for _, uid := range departed {
|
||
if !playerIsIdle(uid, now) {
|
||
t.Errorf("%s was sent on an expedition but is NOT idle — the clock is not gating the sweep", uid)
|
||
}
|
||
}
|
||
t.Logf(" departed: %d of %d candidates", len(departed), len(cands))
|
||
// The gap between candidates and departures is the structural guards doing
|
||
// their job (mid-run, resting, unfinished setup, broke). Name them, so a
|
||
// silent refusal can't hide here.
|
||
left := map[id.UserID]bool{}
|
||
for _, uid := range departed {
|
||
left[uid] = true
|
||
}
|
||
for _, uid := range cands {
|
||
if !left[uid] {
|
||
t.Logf(" stayed home: %-28s %s", uid, whyNoBoredomRun(uid))
|
||
}
|
||
}
|
||
|
||
// ── 5. The cooldown ─────────────────────────────────────────────────────
|
||
t.Logf("──────── second tick (must be a no-op) ────────")
|
||
balBefore := map[id.UserID]float64{}
|
||
for _, c := range idle {
|
||
balBefore[c.uid] = euro.GetBalance(c.uid)
|
||
}
|
||
p.fireExpeditionBoredom(now.Add(30 * time.Minute))
|
||
drain()
|
||
for _, c := range idle {
|
||
if b := euro.GetBalance(c.uid); b != balBefore[c.uid] {
|
||
t.Errorf("%s was charged %.0f more coins on a second tick inside the cooldown — the CAS claim isn't holding",
|
||
c.uid, balBefore[c.uid]-b)
|
||
}
|
||
}
|
||
|
||
// ── 6. Does it actually walk? ───────────────────────────────────────────
|
||
t.Logf("──────── autopilot walks the bored run ────────")
|
||
for _, c := range idle {
|
||
exp, _ := getActiveExpedition(c.uid)
|
||
if exp == nil {
|
||
continue
|
||
}
|
||
ctx := MessageContext{Sender: c.uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: ""}
|
||
// Several segments, as the ambient ticker would over a day. One call can
|
||
// legitimately stop early (stopHarvestCombat, a fork, a boss doorway); the
|
||
// question is whether the run keeps making progress when it's called again.
|
||
total := 0
|
||
for seg := 0; seg < 4; seg++ {
|
||
r := p.runAutopilotWalkDriven(ctx, 6)
|
||
if r.initErr != "" {
|
||
t.Errorf("%s: autopilot refused to walk the bored expedition: %s", c.uid, r.initErr)
|
||
break
|
||
}
|
||
total += r.rooms
|
||
t.Logf(" %s seg%d: +%d rooms (total %d), stopped: %s", c.uid, seg, r.rooms, total, stopName(r.reason))
|
||
if r.reason == stopComplete || r.reason == stopEnded {
|
||
break
|
||
}
|
||
}
|
||
if total == 0 {
|
||
t.Errorf("%s: the bored expedition never took a single step", c.uid)
|
||
}
|
||
}
|
||
drain()
|
||
}
|
||
|
||
// ---- helpers ---------------------------------------------------------------
|
||
|
||
// clearExpeditionState puts the character back on the doorstep: no expedition,
|
||
// no zone run, no combat session. The prod copy is a live snapshot, so some
|
||
// characters are mid-run — and the structural guards would (correctly) refuse
|
||
// to start a bored run on top of that, which would test nothing.
|
||
func clearExpeditionState(t *testing.T, uid id.UserID) {
|
||
t.Helper()
|
||
for _, q := range []string{
|
||
`UPDATE dnd_expedition SET status = 'complete' WHERE user_id = ? AND status IN ('active','extracted')`,
|
||
`DELETE FROM dnd_zone_run WHERE user_id = ?`,
|
||
`DELETE FROM combat_session WHERE user_id = ?`,
|
||
`UPDATE player_meta SET last_boredom_at = NULL WHERE user_id = ?`,
|
||
} {
|
||
if _, err := db.Get().Exec(q, string(uid)); err != nil {
|
||
t.Logf(" clearExpeditionState(%s): %v (continuing)", uid, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func setLastAction(t *testing.T, uid id.UserID, at time.Time) {
|
||
t.Helper()
|
||
if _, err := db.Get().Exec(
|
||
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
|
||
at, string(uid)); err != nil {
|
||
t.Fatalf("setLastAction(%s): %v", uid, err)
|
||
}
|
||
}
|
||
|
||
// gearFingerprint is what the bored run must not change.
|
||
func gearFingerprint(t *testing.T, uid id.UserID) string {
|
||
t.Helper()
|
||
eq, err := loadAdvEquipment(uid)
|
||
if err != nil {
|
||
return "ERR:" + err.Error()
|
||
}
|
||
var parts []string
|
||
for slot, e := range eq {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
parts = append(parts, fmt.Sprintf("%s=%s/T%d", slot, e.Name, e.Tier))
|
||
}
|
||
sort.Strings(parts)
|
||
return fmt.Sprint(parts)
|
||
}
|
||
|
||
func expeditionIsBoredom(t *testing.T, expID string) bool {
|
||
t.Helper()
|
||
var b bool
|
||
if err := db.Get().QueryRow(
|
||
`SELECT boredom FROM dnd_expedition WHERE expedition_id = ?`, expID).Scan(&b); err != nil {
|
||
t.Logf(" expeditionIsBoredom(%s): %v", expID, err)
|
||
return false
|
||
}
|
||
return b
|
||
}
|
||
|
||
func activeExpeditionOwners(t *testing.T) map[id.UserID]bool {
|
||
t.Helper()
|
||
out := map[id.UserID]bool{}
|
||
rows, err := db.Get().Query(`SELECT user_id FROM dnd_expedition WHERE status = 'active'`)
|
||
if err != nil {
|
||
t.Fatalf("activeExpeditionOwners: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var s string
|
||
if err := rows.Scan(&s); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out[id.UserID(s)] = true
|
||
}
|
||
return out
|
||
}
|
||
|
||
// whyNoBoredomRun re-runs tryBoredomStart's guards for a candidate who didn't
|
||
// leave, so the candidates-vs-departures gap is explained rather than assumed.
|
||
func whyNoBoredomRun(uid id.UserID) string {
|
||
c, err := LoadDnDCharacter(uid)
|
||
switch {
|
||
case err != nil:
|
||
return "character won't load: " + err.Error()
|
||
case c == nil:
|
||
return "no character"
|
||
case c.PendingSetup:
|
||
return "character creation never finished"
|
||
}
|
||
if restingLockoutRemaining(c) > 0 {
|
||
return "resting lockout"
|
||
}
|
||
if hasActiveCombatSession(uid) {
|
||
return "in combat"
|
||
}
|
||
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||
return "seated on someone else's party"
|
||
}
|
||
if active, _ := getActiveExpedition(uid); active != nil {
|
||
return "already on an expedition"
|
||
}
|
||
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||
return "extracted, waiting on !resume"
|
||
}
|
||
if run, _ := getActiveZoneRun(uid); run != nil {
|
||
return "mid zone run"
|
||
}
|
||
zone, ok := pickBoredomZone(uid, c.Level)
|
||
if !ok {
|
||
return fmt.Sprintf("no zone in band for L%d", c.Level)
|
||
}
|
||
return fmt.Sprintf("(no guard tripped — would have gone to %s; check cooldown/coins)", zone.ID)
|
||
}
|
||
|
||
func stopName(r stopReason) string {
|
||
switch r {
|
||
case stopOK:
|
||
return "ok"
|
||
case stopFork:
|
||
return "fork"
|
||
case stopElite:
|
||
return "elite doorway"
|
||
case stopBoss:
|
||
return "boss doorway"
|
||
case stopEnded:
|
||
return "died"
|
||
case stopComplete:
|
||
return "zone cleared"
|
||
case stopBlocked:
|
||
return "blocked by combat session"
|
||
case stopHarvestCombat:
|
||
return "harvest pulled a fight"
|
||
case stopPreflight:
|
||
return "preflight (low HP/SU)"
|
||
case stopBossSafety:
|
||
return "boss-safety gate"
|
||
}
|
||
return fmt.Sprintf("stopReason(%d)", int(r))
|
||
}
|
||
|
||
func diffOwners(after, before map[id.UserID]bool) []id.UserID {
|
||
var out []id.UserID
|
||
for uid := range after {
|
||
if !before[uid] {
|
||
out = append(out, uid)
|
||
}
|
||
}
|
||
return out
|
||
}
|