mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
!camp now applies long-rest effects (HP, spell slots, exhaustion, threat -5, Underforge heat) immediately instead of waiting for the 06:00 UTC briefing. New CampState.RestApplied flag stops processOvernightCamp from re-applying at briefing — it just strikes the camp. Also dials ambient cooldown 3h → 6h so a workday gives ~1–2 hits instead of ~3.
435 lines
14 KiB
Go
435 lines
14 KiB
Go
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 ~1–2 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
|
||
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
|
||
}
|