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

@@ -245,6 +245,7 @@ func (p *AdventurePlugin) Init() error {
go p.mortgageTicker()
go p.expeditionBriefingTicker()
go p.expeditionRecapTicker()
go p.expeditionAmbientTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()

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
}

View File

@@ -0,0 +1,150 @@
package plugin
import (
"math/rand/v2"
"strings"
"testing"
"time"
)
// Pure-logic tests for Phase 12 E7 (ambient ticker). The full
// deliverAmbient path mutates the prod DB; those gates already live
// behind setupAuditTestDB in other expedition tests. The behavior tested
// here — eligibility, weighted pick, quiet-window math, DM render —
// covers the resolution layer in isolation.
func TestInAmbientQuietWindow(t *testing.T) {
day := func(h, m int) time.Time {
return time.Date(2026, 5, 14, h, m, 0, 0, time.UTC)
}
cases := []struct {
name string
t time.Time
want bool
}{
{"on briefing", day(6, 0), true},
{"30 min before briefing", day(5, 30), true},
{"59 min before briefing", day(5, 1), true},
{"61 min before briefing", day(4, 59), false},
{"mid-afternoon", day(14, 17), false},
{"on recap", day(21, 0), true},
{"30 min after recap", day(21, 30), true},
{"61 min after recap", day(22, 1), false},
{"deep night", day(3, 0), false},
}
for _, c := range cases {
if got := inAmbientQuietWindow(c.t); got != c.want {
t.Errorf("%s: inAmbientQuietWindow(%v) = %v, want %v", c.name, c.t, got, c.want)
}
}
}
func TestPickAmbientEvent_AlwaysReturnsEligible(t *testing.T) {
// Brand-new expedition: no camp, no threat. Only "always" events
// should ever come back. We sample many times with different seeds
// to brute-force the weighted pick.
e := &Expedition{Supplies: ExpeditionSupplies{Current: 5}}
disallowed := map[string]bool{
"camp_visitor": true,
"faction_whisper": true,
}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i), uint64(i*7+1)))
ev := pickAmbientEvent(e, rng)
if disallowed[ev.Kind] {
t.Fatalf("seed %d picked ineligible event %q", i, ev.Kind)
}
}
}
func TestPickAmbientEvent_CampVisitorRequiresCamp(t *testing.T) {
e := &Expedition{
Supplies: ExpeditionSupplies{Current: 5},
Camp: &CampState{Active: true},
}
// With camp active, camp_visitor should appear at least once in 500 spins.
sawVisitor := false
for i := 0; i < 500; i++ {
rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i*13+2)))
if pickAmbientEvent(e, rng).Kind == "camp_visitor" {
sawVisitor = true
break
}
}
if !sawVisitor {
t.Errorf("expected camp_visitor to be reachable when camped")
}
}
func TestPickAmbientEvent_PackRatNeedsSupplies(t *testing.T) {
// Starving expedition: pack_rat should never fire — there's nothing
// for the rat to take, and a "0.0 SU" DM would be silly.
e := &Expedition{Supplies: ExpeditionSupplies{Current: 0}}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i+3), uint64(i*5+7)))
if pickAmbientEvent(e, rng).Kind == "pack_rat" {
t.Fatalf("seed %d: pack_rat fired with no supplies", i)
}
}
}
func TestPickAmbientEvent_WhisperNeedsThreat(t *testing.T) {
low := &Expedition{Supplies: ExpeditionSupplies{Current: 5}, ThreatLevel: 10}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i+5), uint64(i*11+3)))
if pickAmbientEvent(low, rng).Kind == "faction_whisper" {
t.Fatalf("seed %d: whisper fired below threshold", i)
}
}
hot := &Expedition{Supplies: ExpeditionSupplies{Current: 5}, ThreatLevel: 60}
saw := false
for i := 0; i < 500; i++ {
rng := rand.New(rand.NewPCG(uint64(i+9), uint64(i*17+5)))
if pickAmbientEvent(hot, rng).Kind == "faction_whisper" {
saw = true
break
}
}
if !saw {
t.Errorf("expected faction_whisper to be reachable at threat 60")
}
}
func TestPickAmbientEvent_SiegeSuppressesWhisper(t *testing.T) {
// In siege mode the faction has stopped whispering and started
// kicking the door. The ambient whisper would undersell the moment.
e := &Expedition{
Supplies: ExpeditionSupplies{Current: 5},
ThreatLevel: 100,
SiegeMode: true,
}
for i := 0; i < 200; i++ {
rng := rand.New(rand.NewPCG(uint64(i+13), uint64(i*19+11)))
if pickAmbientEvent(e, rng).Kind == "faction_whisper" {
t.Fatalf("seed %d: whisper fired during siege", i)
}
}
}
func TestRenderAmbientDM_NoFooterPureFlavor(t *testing.T) {
e := &Expedition{CurrentDay: 4}
ev := ambientEventMonologue()
body := renderAmbientDM(e, ev, "A pebble blinks.", "")
if !strings.Contains(body, "Day 4") {
t.Errorf("missing day header: %q", body)
}
if !strings.Contains(body, "A pebble blinks.") {
t.Errorf("missing flavor line: %q", body)
}
if strings.Contains(body, "_") && strings.Count(body, "_") >= 2 {
t.Errorf("unexpected italic footer in pure-flavor DM: %q", body)
}
}
func TestRenderAmbientDM_WithFooter(t *testing.T) {
e := &Expedition{CurrentDay: 7}
body := renderAmbientDM(e, ambientEventMonologue(), "A door slams.", "Threat +1")
if !strings.Contains(body, "Threat +1") {
t.Errorf("missing footer: %q", body)
}
}