Files
gogobee/internal/plugin/expedition_ambient.go
prosolis d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00

437 lines
14 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 ~12 hits,
// not a flood, but a multi-day expedition still feels alive.
ambientCooldown = 6 * 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
}
// Safety net: if the wrapping zone run is abandoned or completed,
// the player has functionally left the dungeon even though the
// expedition row hasn't been flipped. Skip silently rather than
// DM about a place they're no longer in.
if e.RunID != "" {
if run, _ := getZoneRun(e.RunID); run != nil && !run.IsActive() {
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 {
// Pick before claiming the slot so the CAS can persist the new Kind
// alongside last_ambient_at — that way back-to-back fires for the
// same expedition (different ticker tick, different rng) read the
// updated LastAmbientKind on the next load. Picking is pure and
// cheap; if the CAS loses, we simply discard the picked event.
ev := pickAmbientEvent(e, defaultAmbientRNG(), e.LastAmbientKind)
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)
}
cutoff := now.Add(-ambientCooldown)
res, err := db.Get().Exec(`
UPDATE dnd_expedition
SET last_ambient_at = ?,
last_ambient_kind = ?,
last_activity = ?
WHERE expedition_id = ?
AND status = 'active'
AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
now, ev.Kind, now, e.ID, cutoff)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil // someone else got there first
}
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. avoidKind
// is the Kind of the previous fire (or "" if none); when a pick lands on
// that same Kind, we re-roll once. We only re-roll once so the bias is a
// soft nudge — if the user is in a state where most weight sits on one
// Kind (e.g. starving cuts pack_rat → camped + low threat narrows the
// table further) we still let it repeat rather than loop forever.
// Exposed for tests; the rng parameter lets them seed deterministically.
func pickAmbientEvent(e *Expedition, rng *rand.Rand, avoidKind string) ambientEvent {
first := weightedPickAmbient(e, rng)
if avoidKind == "" || first.Kind != avoidKind {
return first
}
return weightedPickAmbient(e, rng)
}
// weightedPickAmbient is the underlying weighted-pick over eligible events.
func weightedPickAmbient(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
if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
ns := fresh.Supplies
ns.Current -= drain
if ns.Current < 0 {
ns.Current = 0
}
return ns, nil
}); 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
}