Files
gogobee/internal/plugin/expedition_ambient.go
prosolis f424300d87 Ship-audit P0/P1 sweep on open5e: prose, combat math, UX hygiene
P0 — actual bugs:
- eldritch_blast: SRD-imported "Whatever" placeholder leaked to players;
  overlay added in dnd_spells_data.go.
- Battle Master disarming_attack + parry: math.Max on multiplicative
  DamageReduct was a no-op vs the 1.0 default. Switched to *= and added
  regression tests covering the stacking case too.
- Extended TestSpellDescriptionsAreJargonFree to catch "Whatever",
  trailing ellipses, mid-word truncation, and empty-size phrases. This
  surfaced 4 more genuinely-broken SRD descriptions (find_familiar,
  clairvoyance, glyph_of_warding, teleportation_circle); all now
  overlayed in dnd_spells_data.go.

P1 — UX hygiene:
- Setup completion now nudges new casters toward !spells/!cast.
- Magic-item attunement vocab unified to "bond" everywhere user-facing;
  renamed the Treasures sheet section to avoid clashing with the bond
  vocab.
- Ambient DM footers stripped of "Threat +N" / "Supplies −N SU" hidden-
  meter leakage; verb-form footers instead.
- !expedition status defaults to a days-left + threat-label summary;
  raw SU / threat-out-of-100 / zone-stack / roll-modifier moved behind
  `!expedition status --debug`.
- !zone taunt/compliment help text dropped the "(mood −10)" / "(mood +5)"
  numerics that turned the hidden TwinBee-mood mechanic into a min/max
  knob.
- TwinBee pronoun pass: switched she/her to singular they on the two
  surfaces I touched plus achievements.bj_beat_twinbee.

P1 — correctness/scale:
- adventure_shop curio purchase now guards the euro.Debit bool return
  before inventory grant + pot cut; the GetBalance preflight isn't
  enough since a concurrent blackjack/lottery debit can slip the balance
  across BLACKJACK_DEBT_LIMIT between read and write.
- equipMagicItem reordered: remove inventory item BEFORE equip, with a
  best-effort rollback if equip fails. The prior order left a dup
  window if the inventory delete tripped on a transient DB error.
- magicItemsByRarityCache promoted to sync.Once — concurrent cold-start
  loot rolls otherwise raced on the lazy assignment.

Stale-audit verifications (no code change needed):
- light / druidcraft / water_walk overlays already exist and win the
  SRD merge — the broken raw text never reaches players.
- bootstrapPhase5BHPRefresh is already wired at adventure.go:228.

Deferred:
- combatantsForSession per-turn 6-load DB chatter (perf P1-A): touches
  the hot combat path; needs its own test pass before shipping. Tracked
  in memory at project_combat_session_cache_deferred.

go vet clean; go test -race ./... green (229s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:43:11 -07:00

406 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)
}
// Verb-form footer — "Threat +N" exposed the hidden meter.
return "The dungeon listens a little harder."
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 "Something out there is paying attention now."
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 ""
}
// Don't surface the raw SU number — it's a hidden meter and
// "SU" doesn't appear in player vocab anywhere else.
_ = drain
return "A little less in the pack than there was."
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
}