Files
gogobee/internal/plugin/expedition_ambient.go
prosolis 368b980650 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()
2026-05-14 23:28:54 -07:00

402 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}