mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
Adventure's web feed now carries a live per-room run log, so the bot no
longer narrates every beat into Matrix. The three per-player DM sources
that fired on a clock collapse into the 06:00 briefing:
- ambient events (6h cooldown, up to ~3/day)
- the 21:00 recap
- the autopilot's Night-camp end-of-day digest
Only the messaging goes quiet. Every mechanical effect still fires on
exactly the schedule it always did: ambient still applies its ±SU and
threat nudges, the recap still runs the night wandering check and its
threat bump, the night camp still burns supply and rolls the day. Each
now writes its outcome to the expedition log and stops there, and the
next morning's briefing reads that log back as a "Since yesterday"
block -- walks collapsed to a count, frame and narration types skipped,
capped with an explicit overflow note so a truncated digest never reads
as a complete one.
The briefing carries the reader's own /adventure/who/{token} link,
reusing the salted one-way roster token the board already publishes, so
a DM link and a board link resolve to one page and neither leaks a
Matrix handle. PETE_PUBLIC_URL overrides the default; it is deliberately
not PETE_INGEST_URL, which is Headscale-only and unreachable from a
player's phone.
N1/A6's digest-anchored mid-day event roll moved to deliverBriefing
along with the digest. An anchored roll's whole premise is firing at a
moment the player is demonstrably reading a DM, and the night camp no
longer sends one; leaving it in place would have quietly starved the
anchor for exactly the autopilot-only player it was added for.
Interrupt-driven DMs are untouched: a fork needs a human, a death and a
run-completion are terminal, and a boss-safety hold is an explicit
stop. Batching those would strand a decision behind the 8h auto-pick or
report a finished story.
Net effect for a player with an active expedition: roughly 6-10 DMs a
day down to one, plus whatever genuinely interrupts.
Tests: the recap and ambient send paths had no coverage at all, so
nothing would have caught them silently dropping their mechanics along
with their message. Adds behavioural tests via the message sink for all
three silenced paths, digest rendering, token-not-handle in the URL,
and a keep-set regression guard.
791 lines
28 KiB
Go
791 lines
28 KiB
Go
package plugin
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
"gogobee/internal/db"
|
||
"gogobee/internal/flavor"
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 12 E1d — real-time day cycle engine.
|
||
// Spec: gogobee_expedition_system.md §3, §12.
|
||
//
|
||
// Two tickers run at 1-minute granularity: briefings fire once per UTC day
|
||
// at 06:00, recaps once per UTC day at 21:00. Each tick walks all active
|
||
// expeditions and fires for any whose last_briefing_at / last_recap_at is
|
||
// stale relative to today's threshold. Per-expedition gating (rather than
|
||
// global JobCompleted) lets a player who started mid-day still receive a
|
||
// briefing the next morning even if some other expedition already triggered
|
||
// the global ticker.
|
||
//
|
||
// Day rollover: the briefing also bumps current_day by one and applies one
|
||
// day's supply burn. Day 1's briefing fires the first morning *after* the
|
||
// expedition begins (so an expedition started at 23:00 doesn't immediately
|
||
// flip to Day 2 at midnight; the spec treats Day 1 as the calendar day
|
||
// of departure).
|
||
|
||
const (
|
||
expeditionBriefingHour = 6
|
||
expeditionRecapHour = 21
|
||
|
||
// nightSafetyNet — if an event-anchored expedition's last rollover is
|
||
// older than this, the 06:00 UTC briefing ticker force-fires
|
||
// processNightCamp itself. Without this, an expedition whose autopilot
|
||
// stalled (long combat, missed tick, manual halt) would drift forever
|
||
// without burning supplies or advancing days. 28h gives the autopilot
|
||
// 4h of slop past a normal day before we step in.
|
||
nightSafetyNet = 28 * time.Hour
|
||
)
|
||
|
||
// briefingIdleSkipWindow — D4-b: an event-anchored expedition skips its
|
||
// 06:00 UTC re-engagement DM when the player's last_activity is older than
|
||
// the new day's briefing threshold (i.e. they haven't moved since before
|
||
// the day rolled). The briefing then fires lazily from OnMessage on the
|
||
// next inbound message via maybeDeliverDeferredBriefing. The safety-net
|
||
// force-fire path still wins past nightSafetyNet so stalled autopilots
|
||
// never sit forever waiting on a silent player.
|
||
|
||
// eventAnchoredCutoff — expeditions started at or after this timestamp
|
||
// use the D2-b event-anchored day-rollover model: day++/burn/threat-drift
|
||
// fire when the autopilot (or a player !camp) pitches a night camp, and
|
||
// the 06:00 UTC briefing becomes a re-engagement DM with a safety-net
|
||
// force. Expeditions started before this stay on the legacy UTC-anchored
|
||
// briefing rollover until they end.
|
||
var eventAnchoredCutoff = time.Date(2026, 5, 27, 18, 0, 0, 0, time.UTC)
|
||
|
||
// isEventAnchored — true when this expedition uses the D2-b model.
|
||
func isEventAnchored(e *Expedition) bool {
|
||
if e == nil {
|
||
return false
|
||
}
|
||
return !e.StartDate.Before(eventAnchoredCutoff)
|
||
}
|
||
|
||
// nightRolloverResult — the side effects processNightCamp produced, so
|
||
// callers (autopilot pitch, manual !camp, safety-net briefing) can render
|
||
// the same numbers into their own DM block.
|
||
type nightRolloverResult struct {
|
||
Burn float32
|
||
TemporalLines []string
|
||
MilestoneLines []string
|
||
Starved bool
|
||
}
|
||
|
||
// nightRolloverBurn — stage 1 of the day rollover: zone temporal pre-burn
|
||
// + daily supply burn + current_day++. Returns the burn applied. Callers
|
||
// follow this with applyCampRest (if pitching) and then nightRolloverDrift
|
||
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
|
||
// between the two so a fortified camp's −5 lands before drift's +3.
|
||
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
|
||
var burn float32
|
||
// Forage and burn land in one write, so they resolve together against the
|
||
// pool as it stands now — not as `e` last saw it.
|
||
newSupplies, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
|
||
// today's supplies, not tomorrow's. Logged so the end-of-day digest
|
||
// can surface the gain; pure no-op for non-Ranger characters.
|
||
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil {
|
||
if gain := applyRangerForage(fresh, c, nil); gain > 0 {
|
||
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage",
|
||
fmt.Sprintf("ranger forage +%g SU", gain),
|
||
flavor.Pick(flavor.HarvestForageSuccess))
|
||
}
|
||
}
|
||
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1)
|
||
if burnOverride.Multiplier > 0 {
|
||
// The temporal override replaces the harsh/siege multiplier, not the
|
||
// roster's: a party still eats N × 0.8 rations inside a time-warped zone.
|
||
burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100
|
||
next := fresh.Supplies
|
||
next.Current -= burn
|
||
if next.Current < 0 {
|
||
next.Current = 0
|
||
}
|
||
next.ForagedToday = false
|
||
return next, nil
|
||
}
|
||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||
burn = b
|
||
return next, nil
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if err := advanceExpeditionDay(e.ID); err != nil {
|
||
return 0, err
|
||
}
|
||
e.Supplies = newSupplies
|
||
e.CurrentDay++
|
||
return burn, nil
|
||
}
|
||
|
||
// nightRolloverDrift — stage 2: daily threat drift, zone temporal post-
|
||
// rollover, starvation check, max-threat record, milestones. Stamps
|
||
// last_briefing_at = now so the UTC briefing ticker treats today as
|
||
// already-rolled. `now` is the wallclock to stamp; callers that already
|
||
// did the stamp via a CAS (deliverBriefing) pass time.Time{} to skip.
|
||
func (p *AdventurePlugin) nightRolloverDrift(e *Expedition, now time.Time) nightRolloverResult {
|
||
var out nightRolloverResult
|
||
if _, _, err := applyDailyThreatDrift(e); err != nil {
|
||
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
|
||
}
|
||
out.TemporalLines = applyZoneTemporalPostRollover(e)
|
||
|
||
if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive {
|
||
_, _, _ = forcedExtractExpedition(e.ID, "starvation")
|
||
e.Status = ExpeditionStatusAbandoned
|
||
line := flavor.Pick(flavor.ExtractionForced)
|
||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
|
||
"forced extraction: starvation", line)
|
||
out.Starved = true
|
||
}
|
||
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
|
||
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
|
||
if tax > 0 {
|
||
p.euro.Debit(id.UserID(e.UserID), float64(tax),
|
||
"forced extraction tax")
|
||
}
|
||
}
|
||
_ = recordMaxThreat(e)
|
||
out.MilestoneLines = p.checkDailyMilestones(e)
|
||
|
||
if !now.IsZero() {
|
||
if _, err := db.Get().Exec(`
|
||
UPDATE dnd_expedition
|
||
SET last_briefing_at = ?
|
||
WHERE expedition_id = ?`, now, e.ID); err == nil {
|
||
e.LastBriefingAt = &now
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// processNightCamp — burn + drift in one go, no rest in between. Used by
|
||
// callers (autopilot pitch, manual !camp, event-anchored safety net) that
|
||
// either apply their own rest separately or don't apply one at all.
|
||
func (p *AdventurePlugin) processNightCamp(e *Expedition) (nightRolloverResult, error) {
|
||
burn, err := p.nightRolloverBurn(e)
|
||
if err != nil {
|
||
return nightRolloverResult{}, err
|
||
}
|
||
out := p.nightRolloverDrift(e, time.Now().UTC())
|
||
out.Burn = burn
|
||
return out, nil
|
||
}
|
||
|
||
// expeditionBriefingTicker — 06:00 UTC daily briefing.
|
||
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-p.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
now := time.Now().UTC()
|
||
if now.Hour() != expeditionBriefingHour || now.Minute() != 0 {
|
||
continue
|
||
}
|
||
p.fireExpeditionBriefings(now)
|
||
}
|
||
}
|
||
}
|
||
|
||
// expeditionRecapTicker — 21:00 UTC daily recap.
|
||
func (p *AdventurePlugin) expeditionRecapTicker() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-p.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
now := time.Now().UTC()
|
||
if now.Hour() != expeditionRecapHour || now.Minute() != 0 {
|
||
continue
|
||
}
|
||
p.fireExpeditionRecaps(now)
|
||
}
|
||
}
|
||
}
|
||
|
||
// fireExpeditionBriefings finds every active expedition without a briefing
|
||
// for today's UTC date and fires one per. Public-on-package for testing.
|
||
func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) {
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||
exps, err := loadExpeditionsNeedingBriefing(threshold)
|
||
if err != nil {
|
||
slog.Error("expedition: load briefings", "err", err)
|
||
return
|
||
}
|
||
for _, e := range exps {
|
||
// Don't roll the expedition day forward into a live turn-based fight:
|
||
// an active combat session locks the run, and the briefing burns
|
||
// supply / advances the day / processes overnight camp. last_briefing_at
|
||
// stays stale, so the rollover simply lands on the next 06:00 tick —
|
||
// the expedition holds on its current day for one extra real day, which
|
||
// is mild and player-favorable versus mutating a locked run.
|
||
if hasActiveCombatSession(id.UserID(e.UserID)) {
|
||
slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
|
||
continue
|
||
}
|
||
// D4-b: skip the ticker DM for event-anchored expeditions whose
|
||
// player has been idle past the new day's threshold. The safety-
|
||
// net force path (handled inside deliverBriefingEventAnchored)
|
||
// still has to run when the autopilot stalled past nightSafetyNet,
|
||
// so only skip when both the player is idle AND we're not in the
|
||
// safety-net window.
|
||
if isEventAnchored(e) && e.LastActivity.Before(threshold) {
|
||
var since time.Duration
|
||
if e.LastBriefingAt != nil {
|
||
since = now.Sub(*e.LastBriefingAt)
|
||
} else {
|
||
since = now.Sub(e.StartDate)
|
||
}
|
||
if since <= nightSafetyNet {
|
||
slog.Info("expedition: briefing deferred — player idle, awaiting next inbound", "expedition", e.ID, "user", e.UserID)
|
||
continue
|
||
}
|
||
}
|
||
if err := p.deliverBriefing(e, now); err != nil {
|
||
slog.Error("expedition: briefing", "expedition", e.ID, "err", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// fireExpeditionRecaps finds every active expedition without a recap for
|
||
// today's UTC date and fires one per.
|
||
func (p *AdventurePlugin) fireExpeditionRecaps(now time.Time) {
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionRecapHour, 0, 0, 0, time.UTC)
|
||
exps, err := loadExpeditionsNeedingRecap(threshold)
|
||
if err != nil {
|
||
slog.Error("expedition: load recaps", "err", err)
|
||
return
|
||
}
|
||
for _, e := range exps {
|
||
// Same guard as briefings: the recap runs the night wandering check,
|
||
// which can bump threat. Don't mutate a run locked by a live fight —
|
||
// last_recap_at stays stale and the recap lands on the next tick.
|
||
if hasActiveCombatSession(id.UserID(e.UserID)) {
|
||
slog.Info("expedition: recap deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
|
||
continue
|
||
}
|
||
if err := p.deliverRecap(e, now); err != nil {
|
||
slog.Error("expedition: recap", "expedition", e.ID, "err", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// loadExpeditionsNeedingBriefing — active expeditions whose last_briefing_at
|
||
// is NULL or strictly before today's 06:00 UTC.
|
||
func loadExpeditionsNeedingBriefing(threshold time.Time) ([]*Expedition, error) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT expedition_id, user_id, zone_id, run_id, status,
|
||
start_date, current_day, current_region, boss_defeated,
|
||
supplies_json, camp_json, threat_level, threat_siege,
|
||
threat_events, temporal_stack, region_state,
|
||
xp_earned, coins_earned, gm_mood,
|
||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||
last_activity, completed_at
|
||
FROM dnd_expedition
|
||
WHERE status = 'active'
|
||
AND start_date < ?
|
||
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
|
||
threshold, threshold)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
return scanExpeditionRows(rows)
|
||
}
|
||
|
||
// loadExpeditionsNeedingRecap — same but for the 21:00 threshold.
|
||
func loadExpeditionsNeedingRecap(threshold time.Time) ([]*Expedition, error) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT expedition_id, user_id, zone_id, run_id, status,
|
||
start_date, current_day, current_region, boss_defeated,
|
||
supplies_json, camp_json, threat_level, threat_siege,
|
||
threat_events, temporal_stack, region_state,
|
||
xp_earned, coins_earned, gm_mood,
|
||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||
last_activity, completed_at
|
||
FROM dnd_expedition
|
||
WHERE status = 'active'
|
||
AND (last_recap_at IS NULL OR last_recap_at < ?)`, threshold)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
return scanExpeditionRows(rows)
|
||
}
|
||
|
||
func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
||
var out []*Expedition
|
||
for rows.Next() {
|
||
e, err := scanExpedition(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// deliverBriefing posts the morning briefing DM. For legacy UTC-anchored
|
||
// expeditions it also drives the day rollover (supply burn, day++, threat
|
||
// drift) via processNightCamp. For event-anchored expeditions (D2-b) the
|
||
// rollover is owned by the autopilot's night-camp pitch; the briefing
|
||
// ticker only re-engages the player and force-fires the rollover after a
|
||
// safety-net window.
|
||
//
|
||
// Idempotency: atomic compare-and-set on last_briefing_at gates the body.
|
||
// A double-fire on the same expedition is a no-op.
|
||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||
priorBriefing := e.LastBriefingAt
|
||
// Capture the day number before any rollover below bumps it: the
|
||
// overnight digest reports the day that just ended, not the one
|
||
// starting now.
|
||
priorDay := e.CurrentDay
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||
res, err := db.Get().Exec(`
|
||
UPDATE dnd_expedition
|
||
SET last_briefing_at = ?,
|
||
last_activity = ?
|
||
WHERE expedition_id = ?
|
||
AND status = 'active'
|
||
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
|
||
now, now, e.ID, threshold)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return nil // already delivered for this day
|
||
}
|
||
e.LastBriefingAt = &now
|
||
|
||
// D2-b: event-anchored expeditions own the day rollover via the
|
||
// autopilot night camp. The 06:00 ticker either posts a re-engagement
|
||
// DM (rollover happened recently) or force-fires processNightCamp
|
||
// itself (safety net for stalled autopilots).
|
||
if isEventAnchored(e) {
|
||
return p.deliverBriefingEventAnchored(e, priorBriefing, priorDay)
|
||
}
|
||
|
||
burn, err := p.nightRolloverBurn(e)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// E2d: overnight camp rest. Runs after burn/day++ but before drift so
|
||
// a fortified camp's −5 lands first and a same-day +3 drift can't push
|
||
// back over a threshold the rest just dropped.
|
||
restSummary := processOvernightCamp(e)
|
||
if restSummary != "" {
|
||
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||
e.ThreatLevel = fresh.ThreatLevel
|
||
e.SiegeMode = fresh.SiegeMode
|
||
e.Camp = fresh.Camp
|
||
}
|
||
}
|
||
|
||
// Pass time.Time{} — the CAS at the top of deliverBriefing already
|
||
// stamped last_briefing_at with the synthetic now; don't overwrite it.
|
||
roll := p.nightRolloverDrift(e, time.Time{})
|
||
roll.Burn = burn
|
||
|
||
line := pickMorningBriefing(e.CurrentDay)
|
||
body := renderMorningBriefing(e, line, burn)
|
||
// The single daily message: fold in what the now-silent recap, night
|
||
// check and ambient events recorded against the day that just ended.
|
||
body = appendOvernightDigest(body, e.ID, priorDay)
|
||
if sl := p.shadowBriefingLine(e); sl != "" {
|
||
body += "\n" + sl + "\n"
|
||
}
|
||
if restSummary != "" {
|
||
body += "\n💤 _" + restSummary + "_\n"
|
||
}
|
||
for _, tl := range roll.TemporalLines {
|
||
body += "\n🌀 " + tl + "\n"
|
||
}
|
||
for _, ml := range roll.MilestoneLines {
|
||
body += "\n" + ml
|
||
}
|
||
|
||
p.fanOutExpeditionDM(e, body, p.briefingPerReader)
|
||
// N1/A6 anchor, relocated here from the retired night-camp digest DM.
|
||
// Only on a run still under way: an expedition that just ended does not
|
||
// want a mid-day event landing on top of the emergence.
|
||
if e.Status == ExpeditionStatusActive {
|
||
p.fireDigestEventAnchor(e)
|
||
}
|
||
// Emergence seam: a briefing-time forced extraction (starvation / abyss
|
||
// collapse) surfaces the players alive — roll pet arrival. Combat/patrol
|
||
// deaths never reach deliverBriefing (the row is already abandoned), so an
|
||
// abandoned status here means a survived emergence; those death paths roll
|
||
// on respawn instead. Every member emerges, so every member rolls.
|
||
if e.Status == ExpeditionStatusAbandoned {
|
||
for _, uid := range expeditionAudience(e) {
|
||
p.maybeRollPetArrivalOnEmerge(uid)
|
||
}
|
||
}
|
||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
|
||
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// maybeDeliverDeferredBriefing — D4-b lazy-delivery hook. When the 06:00
|
||
// UTC ticker skips an event-anchored expedition because the player was
|
||
// idle, the morning DM is posted here on their next inbound message.
|
||
// Cheap fast paths (no expedition, not event-anchored, briefing already
|
||
// stamped past today's threshold) keep the per-message cost to one
|
||
// indexed row lookup. Idempotency rides on deliverBriefing's CAS.
|
||
func (p *AdventurePlugin) maybeDeliverDeferredBriefing(uid id.UserID, now time.Time) {
|
||
if uid == "" {
|
||
return
|
||
}
|
||
exp, err := getActiveExpedition(uid)
|
||
if err != nil || exp == nil || !isEventAnchored(exp) {
|
||
return
|
||
}
|
||
// Stamp presence: per-D4-b, any inbound message in any room counts as
|
||
// "the player is here." The ticker's idle-skip reads last_activity to
|
||
// decide whether to suppress the 06:00 DM, so we update it on every
|
||
// message — not just bot commands.
|
||
if _, err := db.Get().Exec(
|
||
`UPDATE dnd_expedition SET last_activity = ? WHERE expedition_id = ?`,
|
||
now, exp.ID); err != nil {
|
||
slog.Warn("expedition: stamp activity", "user", uid, "err", err)
|
||
}
|
||
// Only lazy-deliver when a briefing is actually owed: a previous
|
||
// briefing exists (so we know the cadence is live) or the autopilot
|
||
// has rolled past day 1 without one (so a rollover happened in the
|
||
// player's absence). Day-1 inbounds shouldn't trigger a briefing
|
||
// before the first night camp has even happened.
|
||
if exp.LastBriefingAt == nil && exp.CurrentDay <= 1 {
|
||
return
|
||
}
|
||
// Don't lazy-deliver before today's 06:00 UTC threshold. The
|
||
// deliverBriefing CAS keys off the same threshold, so a pre-06:00
|
||
// fire would double-emit when the 06:00 ticker sweep arrives.
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||
if now.Before(threshold) {
|
||
return
|
||
}
|
||
if exp.LastBriefingAt != nil && !exp.LastBriefingAt.Before(threshold) {
|
||
return
|
||
}
|
||
if hasActiveCombatSession(uid) {
|
||
return
|
||
}
|
||
if err := p.deliverBriefing(exp, now); err != nil {
|
||
slog.Warn("expedition: deferred briefing", "user", uid, "err", err)
|
||
}
|
||
}
|
||
|
||
// deliverBriefingEventAnchored — D2-b 06:00 UTC ticker for event-anchored
|
||
// expeditions. The autopilot's night-camp pitch owns day++/burn/threat-
|
||
// drift; the ticker just re-engages the player. If the autopilot has
|
||
// stalled past nightSafetyNet we force-fire processNightCamp ourselves so
|
||
// the expedition doesn't sit frozen.
|
||
//
|
||
// priorBriefing is the last_briefing_at value as of entry into deliverBriefing
|
||
// (before the CAS clobbered it). nil means day-1 or genuinely never rolled.
|
||
func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time, priorDay int) error {
|
||
now := time.Now().UTC()
|
||
var since time.Duration
|
||
if priorBriefing != nil {
|
||
since = now.Sub(*priorBriefing)
|
||
} else {
|
||
since = now.Sub(e.StartDate)
|
||
}
|
||
forced := since > nightSafetyNet
|
||
|
||
var (
|
||
burn float32
|
||
temporalLines []string
|
||
mileLines []string
|
||
)
|
||
if forced {
|
||
roll, err := p.processNightCamp(e)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
burn = roll.Burn
|
||
temporalLines = roll.TemporalLines
|
||
mileLines = roll.MilestoneLines
|
||
}
|
||
|
||
line := pickMorningBriefing(e.CurrentDay)
|
||
body := renderMorningBriefing(e, line, burn)
|
||
body = appendOvernightDigest(body, e.ID, priorDay)
|
||
if sl := p.shadowBriefingLine(e); sl != "" {
|
||
body += "\n" + sl + "\n"
|
||
}
|
||
if forced {
|
||
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
|
||
}
|
||
for _, tl := range temporalLines {
|
||
body += "\n🌀 " + tl + "\n"
|
||
}
|
||
for _, ml := range mileLines {
|
||
body += "\n" + ml
|
||
}
|
||
|
||
p.fanOutExpeditionDM(e, body, p.briefingPerReader)
|
||
// N1/A6 anchor, relocated here from the retired night-camp digest DM.
|
||
// Only on a run still under way: an expedition that just ended does not
|
||
// want a mid-day event landing on top of the emergence.
|
||
if e.Status == ExpeditionStatusActive {
|
||
p.fireDigestEventAnchor(e)
|
||
}
|
||
if forced && e.Status == ExpeditionStatusAbandoned {
|
||
for _, uid := range expeditionAudience(e) {
|
||
p.maybeRollPetArrivalOnEmerge(uid)
|
||
}
|
||
}
|
||
summary := "morning re-engagement"
|
||
if forced {
|
||
summary = fmt.Sprintf("safety-net rollover — %.1f SU consumed overnight", burn)
|
||
}
|
||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing", summary, line); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// deliverRecap posts the evening recap DM, appends a log entry, and stamps
|
||
// last_recap_at. No supply burn here — that's the briefing's job.
|
||
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first.
|
||
func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||
expeditionRecapHour, 0, 0, 0, time.UTC)
|
||
res, err := db.Get().Exec(`
|
||
UPDATE dnd_expedition
|
||
SET last_recap_at = ?,
|
||
last_activity = ?
|
||
WHERE expedition_id = ?
|
||
AND status = 'active'
|
||
AND (last_recap_at IS NULL OR last_recap_at < ?)`,
|
||
now, now, e.ID, threshold)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return nil
|
||
}
|
||
|
||
// E2b: night phase wandering check. Still fires on the 21:00 clock and
|
||
// still writes its own "night" log entry — processNightCheck owns that —
|
||
// which is how the outcome reaches the next morning's digest now that
|
||
// the recap itself no longer sends anything.
|
||
if e.Camp != nil && e.Camp.Active {
|
||
c, _ := LoadDnDCharacter(id.UserID(e.UserID))
|
||
var charClass DnDClass
|
||
if c != nil {
|
||
charClass = c.Class
|
||
}
|
||
nc := resolveWanderingCheck(e, charClass, nil)
|
||
if err := processNightCheck(e, nc); err != nil {
|
||
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
|
||
}
|
||
// §7.4: Feywild double-day fires an extra wandering check.
|
||
if e.ZoneID == ZoneFeywildCrossing {
|
||
if today, _ := e.RegionState["feywild_today"].(string); today == string(FeywildDistortionDouble) {
|
||
extra := resolveWanderingCheck(e, charClass, nil)
|
||
if err := processNightCheck(e, extra); err != nil {
|
||
slog.Warn("expedition: feywild extra night check", "expedition", e.ID, "err", err)
|
||
}
|
||
}
|
||
}
|
||
// Refresh in-memory threat after possible signs-of-passage bump.
|
||
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||
e.ThreatLevel = fresh.ThreatLevel
|
||
e.SiegeMode = fresh.SiegeMode
|
||
}
|
||
}
|
||
|
||
dayEntries, err := dayLogEntries(e.ID, e.CurrentDay)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
line := pickEveningRecap(e, dayEntries)
|
||
|
||
// Once-a-day cadence: the night check above has already run and already
|
||
// written its own "night" log entry, so the morning digest reports the
|
||
// outcome. Nothing is sent here. The recap entry below still lands so
|
||
// the site keeps a day boundary to render against.
|
||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
|
||
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// briefingPetPrefix folds the reader's own 25% morning pet event onto the front
|
||
// of a briefing and grants the resulting one-day defense buff (reset at midnight
|
||
// by resetAllPetMorningDefense). The legacy overworld morning DM is skipped
|
||
// while underground, so this is where that roll lives.
|
||
//
|
||
// It is per-reader rather than per-expedition: each member of a party keeps
|
||
// their own pet, and the buff lands on their own character sheet.
|
||
func (p *AdventurePlugin) briefingPetPrefix(uid id.UserID, body string) string {
|
||
pet, err := loadPetState(uid)
|
||
if err != nil {
|
||
return body
|
||
}
|
||
petEvent := petMorningEvent(pet)
|
||
if petEvent == "" {
|
||
return body
|
||
}
|
||
if char, cerr := loadAdvCharacter(uid); cerr == nil {
|
||
char.PetMorningDefense = true
|
||
if serr := saveAdvCharacter(char); serr != nil {
|
||
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
|
||
}
|
||
}
|
||
return fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
|
||
}
|
||
|
||
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
|
||
// 14, 21 each have their own pool; everything else uses the generic pool.
|
||
func pickMorningBriefing(day int) string {
|
||
var pool []string
|
||
switch day {
|
||
case 1:
|
||
pool = flavor.MorningBriefingDay1
|
||
case 3:
|
||
pool = flavor.MorningBriefingDay3
|
||
case 7:
|
||
pool = flavor.MorningBriefingDay7
|
||
case 14:
|
||
pool = flavor.MorningBriefingDay14
|
||
case 21:
|
||
pool = flavor.MorningBriefingDay21
|
||
default:
|
||
pool = flavor.MorningBriefingGeneric
|
||
}
|
||
if line := flavor.Pick(pool); line != "" {
|
||
return line
|
||
}
|
||
return flavor.Pick(flavor.MorningBriefingGeneric)
|
||
}
|
||
|
||
// pickEveningRecap chooses a recap line based on what happened today.
|
||
// Boss kill > nothing-happened > generic. Close-call detection isn't wired
|
||
// in E1d (no HP-delta tracking yet); deferred to E2 alongside the threat
|
||
// clock combat hooks.
|
||
func pickEveningRecap(e *Expedition, today []ExpeditionEntry) string {
|
||
bossKilled := false
|
||
combatCount := 0
|
||
for _, en := range today {
|
||
if en.Type == "combat" {
|
||
combatCount++
|
||
}
|
||
if en.Type == "boss" || strings.Contains(strings.ToLower(en.Summary), "boss defeated") {
|
||
bossKilled = true
|
||
}
|
||
}
|
||
if bossKilled {
|
||
if l := flavor.Pick(flavor.EveningRecapBossKilled); l != "" {
|
||
return l
|
||
}
|
||
}
|
||
// "Nothing happened" = no combat, no narrative-action entries today.
|
||
if combatCount == 0 && len(today) <= 1 {
|
||
if l := flavor.Pick(flavor.EveningRecapNothingHappened); l != "" {
|
||
return l
|
||
}
|
||
}
|
||
return flavor.Pick(flavor.EveningRecapGeneric)
|
||
}
|
||
|
||
// dayLogEntries returns log entries for a specific expedition-day, oldest first.
|
||
func dayLogEntries(expID string, day int) ([]ExpeditionEntry, error) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor
|
||
FROM dnd_expedition_log
|
||
WHERE expedition_id = ? AND day = ?
|
||
ORDER BY timestamp ASC, entry_id ASC`, expID, day)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []ExpeditionEntry
|
||
for rows.Next() {
|
||
var e ExpeditionEntry
|
||
if err := rows.Scan(
|
||
&e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp,
|
||
&e.Type, &e.Summary, &e.Flavor,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// renderMorningBriefing builds the §12.1 block.
|
||
func renderMorningBriefing(e *Expedition, line string, burnConsumed float32) string {
|
||
zone, _ := getZone(e.ZoneID)
|
||
burn := currentBurn(e)
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Morning Briefing, Day %d**\n\n", e.CurrentDay))
|
||
b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
|
||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burned %.1f overnight, ~%d days left)_\n",
|
||
e.Supplies.Current, e.Supplies.Max, burnConsumed, estimateDays(e.Supplies.Current, burn)))
|
||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||
e.ThreatLevel, threatThresholdLabel(e.ThreatLevel, e.SiegeMode)))
|
||
if e.TemporalStack != 0 {
|
||
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", e.TemporalStack))
|
||
}
|
||
if state := supplyDepletion(e.Supplies); state != SupplyNormal {
|
||
b.WriteString(fmt.Sprintf("⚠ **%s** (roll %d)\n",
|
||
depletionLabel(state), supplyRollModifier(state)))
|
||
}
|
||
if line != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// renderEveningRecap builds the §12.2 block.
|
||
func renderEveningRecap(e *Expedition, line string, today []ExpeditionEntry) string {
|
||
zone, _ := getZone(e.ZoneID)
|
||
combatCount := 0
|
||
for _, en := range today {
|
||
if en.Type == "combat" {
|
||
combatCount++
|
||
}
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Evening Recap, Day %d**\n\n", e.CurrentDay))
|
||
b.WriteString(fmt.Sprintf("📍 **Zone:** %s\n", zone.Display))
|
||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU\n", e.Supplies.Current, e.Supplies.Max))
|
||
b.WriteString(fmt.Sprintf("⚔ **Combat encounters today:** %d\n", combatCount))
|
||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||
e.ThreatLevel, threatThresholdLabel(e.ThreatLevel, e.SiegeMode)))
|
||
if e.Camp == nil || !e.Camp.Active {
|
||
b.WriteString("\n_You haven't camped tonight. The dungeon does not._\n")
|
||
}
|
||
if line != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|