mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
We only ever told Pete about outcomes. Nothing emitted when an expedition *started*, which is why the two bored adventurers walked into dungeons and the news feed said nothing at all — it wasn't broken, it had nothing to say. Two halves: A roster snapshot, pushed every 2 minutes. Deliberately NOT on the durable fact queue: a fact is history and losing it loses it forever, so it retries. A snapshot is a photograph of the present, and a retried one is a lie — by the time it lands, she's moved. The next tick carries the truth. That's also what lets Pete's staleness timer work: if we stay down, nothing arrives, and the board stops claiming to be live instead of insisting forever that Josie is still in holymachina. And a "departure" bulletin when a bored adventurer lets itself out. The snapshot omits opted-out players rather than anonymizing them, and carries a board token distinct from every event token, so a standing row can't become the key that links a player's dispatches back together. The player_meta scan folds last_player_action_at/created_at in Go instead of COALESCE()ing in SQL — modernc rebuilds time.Time from the declared column type and COALESCE erases it. A failed scan here would publish an empty board and every adventurer would vanish from the page.
460 lines
18 KiB
Go
460 lines
18 KiB
Go
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`.
|
|
//
|
|
// 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 {
|
|
body := strings.ToLower(strings.TrimSpace(ctx.Body))
|
|
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
|
|
}
|
|
}
|
|
if v, ok := p.pending.Load(string(ctx.Sender)); ok {
|
|
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
|
|
}
|
|
|
|
// ── 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))
|
|
emitBoredomDeparture(uid, zone, c.Level)
|
|
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()
|
|
}
|