Expedition autopilot Phase 3: ambient between-day ticker

Active expeditions now tick on a 3-hour cadence between the 06:00
briefing and 21:00 recap. Each fire DMs the player a short TwinBee
moment — mostly pure flavor (dripping sounds, polite possums,
diplomatic moths) with a 50/50 chance of a small mechanical nudge
(±SU, +threat, 1d4 coins from a found purse, +1 HP if camped).

The dungeon is now louder while you're offline without being
balance-relevant. Multi-day expeditions feel populated by something
other than the player's calendar.

Mechanics:
- new last_ambient_at column with CAS idempotency (mirrors
  briefing/recap pattern in dnd_expedition_cycle.go)
- ambientCooldown=3h, skipped if in turn-based combat session
- 60min politeness window around scheduled briefing/recap so we
  don't pile DMs on the daily messages
- weighted event pool with per-event eligibility (camp_visitor
  requires camped, pack_rat requires SU>=1, faction_whisper
  requires threat>=30 and not siege)
- effects: applyThreatDelta, updateSupplies, euro.Credit +
  coins_earned bump, SaveDnDCharacter HP nudge (clipped at max)

Files:
- internal/db/db.go: ALTER TABLE + CREATE TABLE update
- internal/flavor/twinbee_ambient_flavor.go: 7 humor-heavy pools
- internal/plugin/expedition_ambient.go: ticker + resolver
- internal/plugin/expedition_ambient_test.go: 8 pure-logic tests
- internal/plugin/adventure.go: wire goroutine in Start()
This commit is contained in:
prosolis
2026-05-14 23:28:54 -07:00
parent 632e5ee315
commit 368b980650
5 changed files with 669 additions and 0 deletions

View File

@@ -0,0 +1,401 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Phase 12 E7 — Ambient between-day events. The expedition autopilot's
// "background ticking" pass: while an active expedition is sitting
// between briefings and recaps, the dungeon does its own small things
// every few hours. Most fires are pure flavor (TwinBee narrating a
// dripping sound, a draft, a polite possum); a few apply tiny
// mechanical nudges (±SU, +threat, small coin find, +1 HP if camped).
//
// Design constraints:
// • At most one event per expedition per ambientCooldown window.
// • Idempotency via compare-and-set on dnd_expedition.last_ambient_at
// — survives bot restarts, double-fires, clock skew.
// • Skip if a turn-based combat session is open: the per-round combat
// DMs are the player's feed right now; ambient noise would talk
// over them.
// • Skip if we're inside the politeness window around the scheduled
// 06:00 briefing or 21:00 recap so we don't pile DMs on top of the
// big daily message.
// • Effects are deliberately small — ambient is texture, not balance.
const (
// ambientCooldown — minimum time between ambient events for one
// expedition. Tuned so a player offline for a workday sees ~3 hits,
// not a flood, but a multi-day expedition feels alive.
ambientCooldown = 3 * time.Hour
// ambientNearScheduleWindow — skip if we're within this many minutes
// of a scheduled briefing (06:00 UTC) or recap (21:00 UTC).
ambientNearScheduleWindow = 60 * time.Minute
// ambientTickInterval — how often the ticker wakes up to check.
// The cooldown gate is what actually paces events; the tick just
// has to be frequent enough that the cooldown is enforced cleanly.
ambientTickInterval = 5 * time.Minute
)
// expeditionAmbientTicker — fires ambient events every ambientTickInterval.
func (p *AdventurePlugin) expeditionAmbientTicker() {
ticker := time.NewTicker(ambientTickInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.fireExpeditionAmbients(time.Now().UTC())
}
}
}
// fireExpeditionAmbients walks active expeditions whose last_ambient_at
// is older than ambientCooldown and fires one ambient event per.
func (p *AdventurePlugin) fireExpeditionAmbients(now time.Time) {
if inAmbientQuietWindow(now) {
return
}
due, err := loadExpeditionsForAmbient(now.Add(-ambientCooldown))
if err != nil {
slog.Error("expedition: load ambient candidates", "err", err)
return
}
for _, expID := range due {
// Re-load each row fresh — the candidate list was a cheap
// id-only scan, and per-expedition state may have moved.
e, err := getExpedition(expID)
if err != nil || e == nil {
continue
}
if e.Status != ExpeditionStatusActive {
continue
}
uid := id.UserID(e.UserID)
if hasActiveCombatSession(uid) {
continue
}
if err := p.deliverAmbient(e, now); err != nil {
slog.Warn("expedition: ambient deliver", "expedition", e.ID, "err", err)
}
}
}
// inAmbientQuietWindow reports whether `now` is inside the politeness
// window around 06:00 UTC (briefing) or 21:00 UTC (recap). Centered on
// the scheduled minute: ±ambientNearScheduleWindow.
func inAmbientQuietWindow(now time.Time) bool {
briefing := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC)
recap := time.Date(now.Year(), now.Month(), now.Day(),
expeditionRecapHour, 0, 0, 0, time.UTC)
for _, t := range []time.Time{briefing, recap} {
d := now.Sub(t)
if d < 0 {
d = -d
}
if d < ambientNearScheduleWindow {
return true
}
}
return false
}
// loadExpeditionsForAmbient — active expedition IDs whose last_ambient_at
// is NULL or older than the cutoff. Also requires start_date older than
// the cutoff so a freshly-started expedition gets a real beat of silence
// before the dungeon starts chiming in.
func loadExpeditionsForAmbient(cutoff time.Time) ([]string, error) {
rows, err := db.Get().Query(`
SELECT expedition_id
FROM dnd_expedition
WHERE status = 'active'
AND start_date < ?
AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
cutoff, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
ids = append(ids, s)
}
return ids, rows.Err()
}
// deliverAmbient claims the slot, picks an event, applies its effect,
// DMs the player, and appends a log entry. Idempotent: the CAS update
// short-circuits on rowsAffected == 0 so a double-fire is a no-op.
func (p *AdventurePlugin) deliverAmbient(e *Expedition, now time.Time) error {
cutoff := now.Add(-ambientCooldown)
res, err := db.Get().Exec(`
UPDATE dnd_expedition
SET last_ambient_at = ?,
last_activity = ?
WHERE expedition_id = ?
AND status = 'active'
AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
now, now, e.ID, cutoff)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil // someone else got there first
}
ev := pickAmbientEvent(e, defaultAmbientRNG())
line := flavor.Pick(ev.Pool)
if line == "" {
// Pool was empty — degrade to a generic monologue rather than
// firing an event with no narration.
ev = ambientEventMonologue()
line = flavor.Pick(ev.Pool)
}
footer := p.applyAmbientEffect(e, ev)
body := renderAmbientDM(e, ev, line, footer)
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: ambient DM", "user", uid, "err", err)
}
}
summary := fmt.Sprintf("ambient: %s", ev.Kind)
if footer != "" {
summary += " — " + footer
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "ambient",
summary, line); err != nil {
return err
}
return nil
}
// ── Event table ──────────────────────────────────────────────────────────────
// ambientEvent — one entry in the ambient pool. Weight is relative; the
// resolver normalizes over eligible events at pick time.
type ambientEvent struct {
Kind string
Pool []string
Weight int
Eligible func(*Expedition) bool
}
// ambientEventMonologue — the always-eligible flavor fallback. Pulled
// out so deliverAmbient can degrade to it if a chosen pool is empty.
func ambientEventMonologue() ambientEvent {
return ambientEvent{
Kind: "monologue",
Pool: flavor.AmbientMonologue,
Weight: 35,
Eligible: func(*Expedition) bool { return true },
}
}
// ambientEvents returns the full event table. Constructed per-call so
// it's cheap to test in isolation and free of package-level state.
func ambientEvents() []ambientEvent {
always := func(*Expedition) bool { return true }
return []ambientEvent{
ambientEventMonologue(),
{
Kind: "small_find",
Pool: flavor.AmbientSmallFind,
Weight: 10,
Eligible: always,
},
{
Kind: "noise",
Pool: flavor.AmbientNoise,
Weight: 15,
Eligible: always,
},
{
Kind: "pack_rat",
Pool: flavor.AmbientPackRat,
Weight: 12,
// Pack rats need something to nibble. If supplies are
// already at the floor, skip — no point in the player
// getting a "you lost 0.0 SU" DM.
Eligible: func(e *Expedition) bool {
return e.Supplies.Current >= 1
},
},
{
Kind: "lucky_find",
Pool: flavor.AmbientLuckyFind,
Weight: 10,
Eligible: always,
},
{
Kind: "camp_visitor",
Pool: flavor.AmbientCampVisitor,
Weight: 8,
Eligible: func(e *Expedition) bool {
return e.Camp != nil && e.Camp.Active
},
},
{
Kind: "faction_whisper",
Pool: flavor.AmbientFactionWhisper,
Weight: 10,
// Whispers from a faction that doesn't know you exist
// land flat. Gate behind a real threat level.
Eligible: func(e *Expedition) bool {
return e.ThreatLevel >= 30 && !e.SiegeMode
},
},
}
}
// pickAmbientEvent runs a weighted pick over eligible events.
// Exposed for tests; the rng parameter lets them seed deterministically.
func pickAmbientEvent(e *Expedition, rng *rand.Rand) ambientEvent {
events := ambientEvents()
total := 0
for _, ev := range events {
if ev.Eligible(e) {
total += ev.Weight
}
}
if total == 0 {
return ambientEventMonologue()
}
r := rng.IntN(total)
for _, ev := range events {
if !ev.Eligible(e) {
continue
}
if r < ev.Weight {
return ev
}
r -= ev.Weight
}
return ambientEventMonologue() // unreachable barring float drift
}
func defaultAmbientRNG() *rand.Rand {
return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
}
// ── Effects ──────────────────────────────────────────────────────────────────
// applyAmbientEffect mutates expedition state per event Kind and returns a
// terse footer string for the DM ("Supplies 0.3", "+3 coins", "Threat +1").
// Pure-flavor kinds return "".
func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) string {
rng := defaultAmbientRNG()
switch ev.Kind {
case "monologue", "small_find":
return ""
case "noise":
if err := applyThreatDelta(e.ID, 1, "ambient: distant noise"); err != nil {
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
}
return "Threat +1"
case "faction_whisper":
if err := applyThreatDelta(e.ID, 2, "ambient: faction whisper"); err != nil {
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
}
return "Threat +2"
case "pack_rat":
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
ns := e.Supplies
ns.Current -= drain
if ns.Current < 0 {
ns.Current = 0
}
if err := updateSupplies(e.ID, ns); err != nil {
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
return ""
}
return fmt.Sprintf("Supplies %.1f SU", drain)
case "lucky_find":
coins := 1 + rng.IntN(4) // 1d4
if p.euro != nil {
p.euro.Credit(id.UserID(e.UserID), float64(coins), "expedition ambient: lucky find")
}
// Mirror into coins_earned so the post-expedition tally is honest.
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET coins_earned = coins_earned + ?,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, coins, e.ID); err != nil {
slog.Warn("expedition: ambient coin tally", "expedition", e.ID, "err", err)
}
return fmt.Sprintf("+%d coins", coins)
case "camp_visitor":
// Tiny morale-style HP +1, clipped at max. Only fires when
// camped (gated in Eligible). No-op if already full.
uid := id.UserID(e.UserID)
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
return ""
}
if c.HPCurrent >= c.HPMax {
return ""
}
c.HPCurrent++
if err := SaveDnDCharacter(c); err != nil {
slog.Warn("expedition: ambient HP nudge", "user", uid, "err", err)
return ""
}
return "+1 HP"
}
return ""
}
// renderAmbientDM builds the DM body. Short header so it doesn't look
// like a briefing, then the flavor line, then a one-line footer when
// there was a mechanical effect.
func renderAmbientDM(e *Expedition, ev ambientEvent, line, footer string) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("🌫 *Day %d — between briefings*\n\n", e.CurrentDay))
b.WriteString(line)
if footer != "" {
b.WriteString("\n\n_")
b.WriteString(footer)
b.WriteString("_")
}
return b.String()
}
// ── Test seam — sql.NullTime helper kept here so tests can compose ──────────
// expeditionAmbientStamp returns the last_ambient_at value for a row, or
// (zero, false) if NULL. Used in tests to assert idempotency.
func expeditionAmbientStamp(expID string) (time.Time, bool, error) {
var raw sql.NullTime
err := db.Get().QueryRow(`
SELECT last_ambient_at FROM dnd_expedition WHERE expedition_id = ?`,
expID).Scan(&raw)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return time.Time{}, false, nil
}
return time.Time{}, false, err
}
if !raw.Valid {
return time.Time{}, false, nil
}
return raw.Time, true, nil
}