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.
This commit is contained in:
prosolis
2026-07-12 18:57:50 -07:00
parent a533e106c2
commit 7c379b298c
10 changed files with 1294 additions and 58 deletions

View File

@@ -306,6 +306,18 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`, `ALTER TABLE player_meta ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN treasures_locked INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
// Bored adventurers (gogobee_boredom_plan.md §1). The idle clock for
// autonomous expedition starts. Deliberately NOT last_active_at: that
// one is auto-bumped by saveAdvCharacter, so the autopilot's own writes
// would refresh a bored character's idle clock and it would never
// qualify again. Written only by markPlayerAction, from a real player
// action against Adventure (any interface, not just Matrix chatter).
`ALTER TABLE player_meta ADD COLUMN last_player_action_at DATETIME`,
`ALTER TABLE player_meta ADD COLUMN last_boredom_at DATETIME`,
// Marks an expedition the adventurer started by itself. The idle reaper
// reads it: an expedition nobody asked for must not shield its player
// from the shame DM or hold their streak (gogobee_boredom_plan.md §6).
`ALTER TABLE dnd_expedition ADD COLUMN boredom INTEGER NOT NULL DEFAULT 0`,
// Adv 2.0 Phase G1 — branching zone graph run-state columns. // Adv 2.0 Phase G1 — branching zone graph run-state columns.
// current_node / visited_nodes / node_choices live alongside the // current_node / visited_nodes / node_choices live alongside the
// legacy current_room / room_seq_json during the migration; the // legacy current_room / room_seq_json during the migration; the

View File

@@ -25,6 +25,25 @@ var ExpeditionStart = []string{
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.", "Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
} }
// ─────────────────────────────────────────────────────────────────────────────
// EXPEDITION START — BOREDOM (the adventurer left without you)
//
// Fired by the boredom ticker after a long silence. The player is not
// reading this live; it's a note left on the table. Deadpan, faintly
// reproachful, never cruel — and never pretending the gear got checked,
// because it didn't (gogobee_boredom_plan.md §5).
// ─────────────────────────────────────────────────────────────────────────────
var ExpeditionBoredomStart = []string{
"You didn't come. That's alright — it happens, and I'm not going to make it a thing. But the sword was getting heavy on the wall and I've packed what we had. Which was not much. Noted for the record, not as a complaint.",
"I waited. Then I waited past the point where waiting was the sensible option, and somewhere in there the waiting turned into leaving. We're going. Same kit as last time, because last time is when you last touched it.",
"Here's the situation: there's a dungeon, there's daylight, and there's nobody telling me not to. I've made a decision. I hope it was the one you'd have made, though I concede I have no way of checking.",
"Supplies: the cheapest available. Equipment: whatever was already on the rack. Plan: walk in, see what happens. I'm aware of how that sounds. I'm going anyway.",
"The gear hasn't moved since you left it. I checked. I checked twice, actually, in case the first check was wrong, and it wasn't. So we go as we are — which is to say, as we were.",
"Restlessness is not a stat I can show you on the sheet, but it accumulates, and it has. Off we go. Lightly provisioned and unimproved, but off.",
"I've done the arithmetic on standing still and it doesn't come out well. So: a dungeon, one supply pack, and the same armour that's been good enough up to now. 'Good enough' is doing a lot of work in that sentence.",
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// MORNING BRIEFINGS — Generic // MORNING BRIEFINGS — Generic
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

View File

@@ -281,6 +281,7 @@ func (p *AdventurePlugin) Init() error {
go p.expeditionAmbientTicker() go p.expeditionAmbientTicker()
go p.expeditionAutoRunTicker() go p.expeditionAutoRunTicker()
go p.expeditionExtractionSweepTicker() go p.expeditionExtractionSweepTicker()
go p.expeditionBoredomTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart // Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns() p.arenaCleanupStaleRuns()
@@ -321,6 +322,15 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
// expedition. // expedition.
p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC()) p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())
// Bored adventurers (gogobee_boredom_plan.md §1) — the idle clock that
// decides when a character gives up waiting and leaves on its own. Stamped
// only for a real action against Adventure: idle chatter in a room is not
// tending your adventurer, and no ticker or autopilot path may touch this,
// or a bored character would refresh its own clock and never qualify again.
if p.isPlayerAdventureAction(ctx) {
markPlayerAction(ctx.Sender)
}
// 0. D&D layer commands (Phase 1 — work in rooms and DMs) // 0. D&D layer commands (Phase 1 — work in rooms and DMs)
if p.IsCommand(ctx.Body, "setup") { if p.IsCommand(ctx.Body, "setup") {
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup")) return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))

View File

@@ -190,7 +190,17 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err) slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
} }
// Room announcement // Room announcement — but not for a player who isn't there.
//
// A bored adventurer (gogobee_boredom_plan.md) auto-harvests ore and junk
// into inventory on every run, and Robbie takes all of it, every day. Left
// alone, each abandoned character would file a public bulletin every single
// day, indefinitely, about somebody who stopped playing weeks ago. He still
// visits and still pays — that income is what keeps the neglected adventurer
// walking — he just doesn't announce a house with nobody in it.
if playerIsIdle(userID, time.Now().UTC()) {
return
}
gr := gamesRoom() gr := gamesRoom()
if gr != "" { if gr != "" {
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard) announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)

View File

@@ -405,8 +405,9 @@ func (p *AdventurePlugin) midnightReset() error {
return fmt.Errorf("load chars: %w", err) return fmt.Errorf("load chars: %w", err)
} }
today := time.Now().UTC().Format("2006-01-02") now := time.Now().UTC()
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") today := now.Format("2006-01-02")
yesterday := now.Add(-24 * time.Hour).Format("2006-01-02")
// Unified activity oracle — same source the daily report uses. Unions // Unified activity oracle — same source the daily report uses. Unions
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so // adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
@@ -457,25 +458,32 @@ func (p *AdventurePlugin) midnightReset() error {
continue continue
} }
// Activity happened on the player's behalf without an explicit tap — // Every hold below rests on one premise: the player engaged earlier to
// autopilot walked rooms, expedition log gained beats, a fight session // kick this off, so the autopilot finishing the job shouldn't cost them
// is still locked open. Hold the streak: no bump, no shame, no decay. // their streak. A boredom expedition has no such origin — nobody kicked
// The player engaged earlier to kick this off; autopilot is a feature, // it off, and its player still hasn't come back. It earns them nothing
// not a way to game streaks, and absence of taps shouldn't strip // and shields them from nothing (gogobee_boredom_plan.md §6).
// progress they earned through real play. if !isBoredomDriven(char.UserID, now) {
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 { // Activity happened on the player's behalf without an explicit tap —
continue // autopilot walked rooms, expedition log gained beats, a fight session
} // is still locked open. Hold the streak: no bump, no shame, no decay.
// Safety net for live state the activity logs don't reflect yet // The player engaged earlier to kick this off; autopilot is a feature,
// (e.g. an active expedition that's been quiet today, or a combat // not a way to game streaks, and absence of taps shouldn't strip
// session locked open across midnight without a log append). // progress they earned through real play.
if exp, err := getActiveExpedition(char.UserID); err != nil { if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err) continue
} else if exp != nil { }
continue // Safety net for live state the activity logs don't reflect yet
} // (e.g. an active expedition that's been quiet today, or a combat
if hasActiveCombatSession(char.UserID) { // session locked open across midnight without a log append).
continue if exp, err := getActiveExpedition(char.UserID); err != nil {
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
} else if exp != nil {
continue
}
if hasActiveCombatSession(char.UserID) {
continue
}
} }
// Truly idle — shame DM + streak halve. // Truly idle — shame DM + streak halve.

View File

@@ -383,44 +383,10 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
} }
zone := zoneForCaps zone := zoneForCaps
// Holiday perk: a complimentary standard pack is added to the supplies _, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
// snapshot without inflating the coin cost. Bypasses the per-tier cap
// on purpose — it's a freebie on top of whatever the player bought.
suppliesPurchase := purchase
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
suppliesPurchase.StandardPacks += pk
}
supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap).
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) {
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
}
exp, err := startExpedition(ctx.Sender, zoneID, "", supplies)
if err != nil { if err != nil {
// Refund on persistence failure. return p.SendDM(ctx.Sender, err.Error())
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
return p.SendDM(ctx.Sender, "Couldn't start expedition: "+err.Error())
} }
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
// harvest depends on this; the existing zone-run !advance/!retreat
// commands now operate on this run while the expedition is active.
if _, err := ensureRegionRun(exp, c.Level); err != nil {
// Refund and tear the expedition row back down — without a
// linked run, harvest and rooms can't function.
_ = abandonExpedition(ctx.Sender)
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund (run-spawn failed)")
return p.SendDM(ctx.Sender, "Couldn't outfit the first region: "+err.Error())
}
// Log the start with prewritten flavor.
startLine := flavor.Pick(flavor.ExpeditionStart)
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
markActedToday(ctx.Sender) markActedToday(ctx.Sender)
var b strings.Builder var b strings.Builder
@@ -442,6 +408,65 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }
// beginExpedition performs the non-interactive half of starting an expedition:
// supply freebies, the coin debit, persistence, the starting region's run, and
// the opening log entry. It refunds and tears down on every failure path, so a
// caller holding an error owes the player nothing.
//
// Callers own the balance check, the eligibility guards, and the player-facing
// messaging. This is the shared transaction under `!expedition start` and the
// boredom ticker (gogobee_boredom_plan.md §4); the returned errors are written
// as complete player-facing sentences so both callers can surface them as-is.
//
// It deliberately does NOT call markActedToday — an expedition the player did
// not ask for must not spend their daily action or count as them showing up.
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
if p.euro == nil {
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
}
cost := float64(purchase.Cost())
// Holiday perk: a complimentary standard pack is added to the supplies
// snapshot without inflating the coin cost. Bypasses the per-tier cap
// on purpose — it's a freebie on top of whatever the player bought.
suppliesPurchase := purchase
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
suppliesPurchase.StandardPacks += pk
}
supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap).
if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
}
exp, err := startExpedition(uid, zone.ID, "", supplies)
if err != nil {
// Refund on persistence failure.
p.euro.Credit(uid, cost, "expedition outfitting refund")
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
}
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
// harvest depends on this; the existing zone-run !advance/!retreat
// commands now operate on this run while the expedition is active.
if _, err := ensureRegionRun(exp, charLevel); err != nil {
// Refund and tear the expedition row back down — without a
// linked run, harvest and rooms can't function.
_ = abandonExpedition(uid)
p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
}
// Log the start with prewritten flavor.
startLine := flavor.Pick(flavor.ExpeditionStart)
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
return exp, supplies, startLine, nil
}
// raidContentWarning returns a TwinBee-voiced heads-up for zones whose // raidContentWarning returns a TwinBee-voiced heads-up for zones whose
// boss is tuned for a party rather than a solo adventurer. T5 zones // boss is tuned for a party rather than a solo adventurer. T5 zones
// (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the // (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the

View File

@@ -0,0 +1,427 @@
//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
}

View File

@@ -0,0 +1,439 @@
package plugin
import (
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Bored adventurers (gogobee_boredom_plan.md).
//
// A player who stops tending their adventurer doesn't stop having one. After
// boredomIdleThreshold 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 is already autonomous — the autopilot
// (expedition_autorun.go) walks rooms, drives elite and boss fights on the real
// turn engine, camps, harvests, rolls days over, and auto-picks forks after 8h.
// The only thing that ever needed a human was `!expedition start`, so that is
// the only thing this file does.
//
// It never buys or equips gear, and that is the entire mechanic: a neglected
// adventurer grinds half-starved runs on rusting equipment and comes home taxed.
const (
// boredomIdleThreshold — silence, measured against the player's last
// action *against Adventure*, before the adventurer leaves on its own.
boredomIdleThreshold = 24 * time.Hour
// boredomCooldown — minimum gap between boredom starts for one player.
// Also the re-trigger cadence: when a bored expedition ends, another
// boredomCooldown of silence starts the next one. An abandoned character
// keeps going until it runs out of coins for supplies.
boredomCooldown = 24 * time.Hour
// boredomTickInterval — how often the ticker wakes. The cooldown gate is
// what actually paces starts; the tick only has to be frequent enough to
// enforce it cleanly.
boredomTickInterval = 30 * time.Minute
)
// ── The idle clock ───────────────────────────────────────────────────────────
// markPlayerAction stamps player_meta.last_player_action_at — the clock the
// boredom ticker reads.
//
// It is a direct UPDATE and deliberately does NOT route through
// saveAdvCharacter. Every other candidate timestamp in the schema is unusable
// here for the same reason (gogobee_boredom_plan.md §1):
//
// - last_active_at is auto-bumped inside saveAdvCharacter, so the autopilot's
// own combat writes would refresh a bored character's idle clock and it
// would never qualify again.
// - user_stats.updated_at is bumped by any Matrix message in any room. That's
// chat presence, not a game action — a player can talk all day without
// touching their adventurer, and the adventurer should still get bored.
// - loadAdvDailyActivity is unified across dnd_expedition_log, so the
// autopilot's own walk entries read back as player activity.
//
// Any future non-Matrix entry point (web, API, Pete) should call this too — the
// clock tracks actions against Adventure, not Matrix traffic.
func markPlayerAction(userID id.UserID) {
db.Exec("boredom: mark player action",
`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
time.Now().UTC(), string(userID))
}
// advActionCommands — every `!command` routed by AdventurePlugin.OnMessage.
//
// This is NOT Commands() (adventure.go): that registers 28 names for the help
// surface and is missing expedition, zone, fight, camp, extract, resume and
// more. Routing is a 47-branch if-chain, and the boredom clock has to see all
// of it, so the set is spelled out here. TestAdvActionCommandsCoverDispatch
// greps the IsCommand literals out of adventure.go and fails if one is missing,
// so a new command can't silently fall out of the clock.
var advActionCommands = map[string]bool{
"abilities": true, "adv": true, "adventure": true, "arena": true,
"arm": true, "attack": true, "bail": true, "camp": true,
"cast": true, "check": true, "commune": true, "consume": true,
"craft": true, "duel": true, "essence": true, "expedition": true,
"explore": true, "extract": true, "fight": true, "fish": true,
"flee": true, "forage": true, "give": true, "graveyard": true,
"hospital": true, "level": true, "lore": true, "map": true,
"mine": true, "news": true, "prepare": true, "region": true,
"resources": true, "respec": true, "rest": true, "resume": true,
"revisit": true, "rivals": true, "roll": true, "scavenge": true,
"sell": true, "setup": true, "sheet": true, "spells": true,
"stats": true, "subclass": true, "thom": true, "threat": true,
"town": true, "zone": true,
}
// isPlayerAdventureAction reports whether this message is the player doing
// 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`.
func (p *AdventurePlugin) isPlayerAdventureAction(ctx MessageContext) bool {
for name := range advActionCommands {
if p.IsCommand(ctx.Body, name) {
return true
}
}
if _, pending := p.pending.Load(string(ctx.Sender)); pending {
return true
}
return false
}
// ── The ticker ───────────────────────────────────────────────────────────────
func (p *AdventurePlugin) expeditionBoredomTicker() {
ticker := time.NewTicker(boredomTickInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.fireExpeditionBoredom(time.Now().UTC())
}
}
}
// fireExpeditionBoredom sends every eligible idle adventurer into a dungeon.
func (p *AdventurePlugin) fireExpeditionBoredom(now time.Time) {
// Same politeness gate as the ambient ticker: don't drop a departure DM on
// top of the 06:00 briefing or 21:00 recap.
if inAmbientQuietWindow(now) {
return
}
uids, err := loadBoredomCandidates(now)
if err != nil {
slog.Error("boredom: load candidates", "err", err)
return
}
for _, uid := range uids {
if err := p.tryBoredomStart(uid, now); err != nil {
slog.Warn("boredom: start failed", "user", uid, "err", err)
}
}
}
// loadBoredomCandidates returns players whose last action against Adventure is
// older than boredomIdleThreshold and who haven't been sent out inside the
// cooldown.
//
// COALESCE onto created_at so a character that was made and then never played
// still qualifies — but not on its very first tick, since created_at is the
// clock until they touch anything.
func loadBoredomCandidates(now time.Time) ([]id.UserID, error) {
idleCutoff := now.Add(-boredomIdleThreshold)
coolCutoff := now.Add(-boredomCooldown)
rows, err := db.Get().Query(`
SELECT user_id
FROM player_meta
WHERE alive = 1
AND COALESCE(last_player_action_at, created_at) IS NOT NULL
AND COALESCE(last_player_action_at, created_at) < ?
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
idleCutoff, coolCutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []id.UserID
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, id.UserID(s))
}
return out, rows.Err()
}
// tryBoredomStart runs the eligibility guards for one player and, if they pass,
// outfits and launches the expedition.
func (p *AdventurePlugin) tryBoredomStart(uid id.UserID, now time.Time) error {
// The foreground commands mutate the same rows under this lock; a boredom
// start racing `!expedition start` would double-book the character.
userMu := p.advUserLock(uid)
userMu.Lock()
defer userMu.Unlock()
c, err := LoadDnDCharacter(uid)
if err != nil {
return err
}
if c == nil || c.PendingSetup {
return nil // never finished character creation — nothing to get bored
}
// Structural blockers. Every one of these is a state the player has to
// resolve themselves; the adventurer can't just walk out of it. Mirrors the
// guard set in expeditionCmdStart.
if restingLockoutRemaining(c) > 0 {
return nil
}
if hasActiveCombatSession(uid) {
return nil
}
if seated, _ := seatedExpeditionFor(uid); seated != nil {
return nil // riding someone else's party
}
if active, _ := getActiveExpedition(uid); active != nil {
return nil
}
if pending, _ := getResumableExpedition(uid); pending != nil {
return nil // extracted, holding a roster for `!resume` — theirs to settle
}
if run, _ := getActiveZoneRun(uid); run != nil {
return nil
}
zone, ok := pickBoredomZone(uid, c.Level)
if !ok {
return nil // no zone this character's level band can be sent to
}
// Claim the cooldown BEFORE spending anything. If the start then fails, or
// the process dies mid-flight, we wait out the cooldown instead of looping
// on a broken character every 30 minutes.
res, err := db.Get().Exec(`
UPDATE player_meta
SET last_boredom_at = ?
WHERE user_id = ?
AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
now, string(uid), now.Add(-boredomCooldown))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil // another tick claimed it
}
// Cheapest pack that exists. A zero-supply start is impossible —
// SupplyPurchase.Validate rejects it — so a bored run always costs coins,
// and a broke adventurer simply doesn't go. That's the floor that stops an
// abandoned character looping forever.
purchase := loadoutPurchase(zone.Tier, LoadoutLean)
cost := float64(purchase.Cost())
if p.euro == nil {
return nil
}
if balance := p.euro.GetBalance(uid); balance < cost {
return p.SendDM(uid, fmt.Sprintf(
"🪙 I got as far as the supply counter. Outfitting for **%s** runs **%d** coins and we have **%.0f**. So I've put the pack back and come home. Nothing to be done about it from where I'm standing.",
zone.Display, int(cost), balance))
}
exp, supplies, _, err := p.beginExpedition(uid, c.Level, zone, purchase, "expedition outfitting (restless)")
if err != nil {
return err
}
// Mark it as self-started. The idle reaper reads this so an expedition the
// player never asked for can't hold their streak or spare them the shame DM
// (gogobee_boredom_plan.md §6).
db.Exec("boredom: flag expedition",
`UPDATE dnd_expedition SET boredom = 1 WHERE expedition_id = ?`, exp.ID)
slog.Info("boredom: adventurer left on its own",
"user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost))
return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase))
}
// ── Reading the idle clock ───────────────────────────────────────────────────
// playerIsIdle reports whether the player has gone boredomIdleThreshold without
// an action against Adventure. A player with no recorded action at all (never
// played, or predates the column) counts as idle.
// The COALESCE is done in Go, not SQL, on purpose. modernc.org/sqlite rebuilds
// a time.Time from the column's declared type, and COALESCE() erases it — the
// value comes back a string and the Scan fails. Selecting the two declared
// DATETIME columns and folding them here keeps the affinity. This function
// fails open (an unreadable row reads as idle), so a broken scan here would
// declare every player idle and march the whole server into a dungeon at once.
func playerIsIdle(userID id.UserID, now time.Time) bool {
var lastAction, created *time.Time
err := db.Get().QueryRow(
`SELECT last_player_action_at, created_at FROM player_meta WHERE user_id = ?`,
string(userID)).Scan(&lastAction, &created)
if err != nil {
return true
}
last := lastAction
if last == nil {
last = created // never acted — the clock runs from when they were made
}
if last == nil {
return true
}
return last.Before(now.Add(-boredomIdleThreshold))
}
// isBoredomDriven reports that this character is running on its own and its
// player has not come back: the last expedition they set out on was one the
// adventurer started for itself, and they still haven't touched Adventure.
//
// This is the gate the idle reaper uses. It's deliberately narrower than "is
// the player idle": an expedition the player *did* start still holds their
// streak while the autopilot walks it, exactly as it always has. Only an
// expedition nobody asked for loses that protection.
//
// Reading the most recent expedition rather than the active one keeps it honest
// for the night after a bored run ends — the logs it left behind would
// otherwise read as player activity for another day.
func isBoredomDriven(userID id.UserID, now time.Time) bool {
if !playerIsIdle(userID, now) {
return false // they came back; nothing to reap
}
var boredom bool
err := db.Get().QueryRow(`
SELECT boredom
FROM dnd_expedition
WHERE user_id = ?
ORDER BY start_date DESC
LIMIT 1`, string(userID)).Scan(&boredom)
if err != nil {
return false // no expedition history, or unreadable — don't reap on a guess
}
return boredom
}
// ── Zone selection ───────────────────────────────────────────────────────────
// pickBoredomZone chooses where a restless adventurer goes.
//
// It uses the LevelMin/LevelMax bands on ZoneDefinition rather than
// zonesForLevel, which filters on tier alone and ignores those fields — which
// is why a level-1 player can currently walk into a T3 zone. A bored adventurer
// should neither farm trivial content nor wander somewhere absurd, so the band
// is the right gate.
//
// Order of preference:
// 1. Zones whose level band contains the character.
// 2. Among those, the non-raid ones (raidContentWarning) — the party-tuned T5
// bosses have a 0% solo clear rate, so they're avoided while an alternative
// exists.
// 3. Among those, the lowest tier: the "easiest at-level" rule.
// 4. Ties break on least-recently-visited, so a bored adventurer rotates zones
// instead of grinding one forever.
//
// If step 2 leaves nothing, raid zones are allowed back in. That only bites at
// L13+, where dragons_lair and abyss_portal are the only zones in band and both
// are raids. Those runs cannot be won solo — they wall at the boss-safety gate,
// starve, and force-extract with the haul tax. That is the intended fate of an
// abandoned endgame character, and it drains the coins that fund the next one.
func pickBoredomZone(uid id.UserID, level int) (ZoneDefinition, bool) {
var inBand, nonRaid []ZoneDefinition
for _, z := range allZones() {
if level < z.LevelMin || level > z.LevelMax {
continue
}
inBand = append(inBand, z)
if raidContentWarning(z.ID) == "" {
nonRaid = append(nonRaid, z)
}
}
pool := nonRaid
if len(pool) == 0 {
pool = inBand
}
if len(pool) == 0 {
return ZoneDefinition{}, false
}
lastVisit, err := lastExpeditionByZone(uid)
if err != nil {
slog.Warn("boredom: zone history unavailable, falling back to tier order", "user", uid, "err", err)
lastVisit = map[ZoneID]time.Time{}
}
sort.SliceStable(pool, func(i, j int) bool {
if pool[i].Tier != pool[j].Tier {
return pool[i].Tier < pool[j].Tier
}
return lastVisit[pool[i].ID].Before(lastVisit[pool[j].ID])
})
return pool[0], true
}
// lastExpeditionByZone returns when this player last set out for each zone.
// Zones they've never visited are absent, so they sort first (zero time).
//
// No MAX(start_date)/GROUP BY: the aggregate erases the column's declared type
// and modernc.org/sqlite hands back a string the Scan can't take. Walking the
// rows in ascending order and letting each overwrite the last gives the same
// answer with the affinity intact.
func lastExpeditionByZone(uid id.UserID) (map[ZoneID]time.Time, error) {
rows, err := db.Get().Query(`
SELECT zone_id, start_date
FROM dnd_expedition
WHERE user_id = ?
ORDER BY start_date ASC`, string(uid))
if err != nil {
return nil, err
}
defer rows.Close()
out := map[ZoneID]time.Time{}
for rows.Next() {
var zone string
var last time.Time
if err := rows.Scan(&zone, &last); err != nil {
return nil, err
}
out[ZoneID(zone)] = last // ascending, so the last write is the most recent
}
return out, rows.Err()
}
// ── Rendering ────────────────────────────────────────────────────────────────
// renderBoredomDepartureDM is the note left on the table. It states plainly
// that nothing was bought and nothing was upgraded — the player should be able
// to read this and know exactly why the haul is going to be bad.
func renderBoredomDepartureDM(zone ZoneDefinition, supplies ExpeditionSupplies, purchase SupplyPurchase) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("🎒 **Gone on ahead — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
b.WriteString("_" + zone.Hook + "_\n\n")
b.WriteString(flavor.Pick(flavor.ExpeditionBoredomStart))
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("**Packed:** %.0f SU (%d standard) — %d coins, the cheapest that would get us through the gate\n",
supplies.Max, purchase.StandardPacks, purchase.Cost()))
b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — roughly **%d days** before we're rationing\n",
supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
b.WriteString("**Equipment:** unchanged. I don't do the shopping.\n\n")
if w := raidContentWarning(zone.ID); w != "" {
b.WriteString(w)
b.WriteString("\n\n")
}
b.WriteString("`!expedition status` to see where we've got to. Come and find us.")
return b.String()
}

View File

@@ -0,0 +1,191 @@
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])
}
}

View File

@@ -0,0 +1,95 @@
package plugin
import (
"os"
"regexp"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// pickBoredomZone reads expedition history to break ties, so it needs a DB.
func newBoredomTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// TestAdvActionCommandsCoverDispatch pins the boredom idle clock to the actual
// command router.
//
// advActionCommands has to list every command AdventurePlugin.OnMessage routes,
// because a command missing from the set is a command the player can use
// without their adventurer noticing they showed up — so it sits there idle,
// gets "bored", and walks into a dungeon while they're actively playing.
//
// Commands() can't be used as the source of truth: it registers 28 names for
// the help surface and omits expedition, zone, fight, camp, extract and resume.
// So the router is the source of truth, and this greps it.
func TestAdvActionCommandsCoverDispatch(t *testing.T) {
src, err := os.ReadFile("adventure.go")
if err != nil {
t.Fatalf("read adventure.go: %v", err)
}
re := regexp.MustCompile(`p\.IsCommand\(ctx\.Body, "([a-z0-9-]+)"\)`)
matches := re.FindAllSubmatch(src, -1)
if len(matches) < 40 {
t.Fatalf("only found %d IsCommand call sites in adventure.go — the regex has "+
"drifted from the dispatcher and this test is no longer guarding anything", len(matches))
}
for _, m := range matches {
name := string(m[1])
if !advActionCommands[name] {
t.Errorf("!%s is routed by OnMessage but missing from advActionCommands — "+
"players using it would not reset their boredom clock", name)
}
}
}
// TestPickBoredomZoneBands walks the level bands and asserts the picker's
// rules: easiest at-level, no raid zones while an alternative exists, and the
// L13+ fallback into the raid.
func TestPickBoredomZoneBands(t *testing.T) {
newBoredomTestDB(t)
cases := []struct {
level int
want ZoneID
why string
}{
{level: 1, want: ZoneGoblinWarrens, why: "T1 band, tie broken by never-visited order"},
{level: 5, want: ZoneForestShadows, why: "L5 is in both the T2 and T3 bands — take the easier"},
{level: 10, want: ZoneUnderdark, why: "T4 band; underdark is multi-region but NOT a raid, so it stays eligible. Both T4 zones are unvisited here, so the tie falls through to registration order"},
{level: 15, want: ZoneDragonsLair, why: "only raid zones in band at L15 — fallback fires, lowest tier first"},
}
for _, tc := range cases {
got, ok := pickBoredomZone(id.UserID("@nobody:test"), tc.level)
if !ok {
t.Errorf("L%d: no zone picked (%s)", tc.level, tc.why)
continue
}
if got.ID != tc.want {
t.Errorf("L%d: picked %s, want %s (%s)", tc.level, got.ID, tc.want, tc.why)
}
}
}
// TestPickBoredomZoneAvoidsRaidsWhenPossible is the rule the player actually
// asked for, stated directly: below the endgame, a bored adventurer never gets
// sent into party-tuned content.
func TestPickBoredomZoneAvoidsRaidsWhenPossible(t *testing.T) {
newBoredomTestDB(t)
for level := 1; level <= 12; level++ {
z, ok := pickBoredomZone(id.UserID("@nobody:test"), level)
if !ok {
continue
}
if raidContentWarning(z.ID) != "" {
t.Errorf("L%d: picked raid zone %s while a non-raid zone was in band", level, z.ID)
}
}
}