Files
gogobee/internal/plugin/dnd_expedition_cycle.go
prosolis 45863bd5f3 Adv 2.0 D&D Phase R7: post-completion audit hardening
High-priority fixes from the multi-agent audit of Adventure 2.0:

- Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe
  getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a
  placeholder instead of panicking.
- Briefing/recap idempotency: deliverBriefing/deliverRecap now claim
  the rollover via a conditional UPDATE. Double-fires from clock skew
  or restart become no-ops; supply burn and day++ no longer reapply.
- Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all
  11 background tickers now select on stopCh in addition to ticker.C.
- Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation
  so sub-hour gaps decay correctly.
- 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and
  abandons stale runs whose LastActionAt is over 24h old.
- Respec / auto-migrate orphan cleanup: !respec and the auto-migrated
  draft wipe now abandon active zone runs and expeditions before
  deleting the dnd_character row.
- Phase R combat-link: applyBossDefeatThreat now wired from
  resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds
  +5/+8 from non-boss/elite kills (§8.1).
- Starvation → forced extraction: briefing-time check forces extract
  with §10.2 coin tax when supplies hit zero.
- GMNat20/Nat1 narration wired into resolveCombatRoom and
  resolveBossRoom (nat-20 takes precedence over nat-1).
- Treasure-undo race: LoadAndDelete on both timer-fire and `undo`
  paths so only one side wins.
- Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6
  of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike)
  documented inline as needing ally/reaction mechanics the engine
  doesn't model.
- Threat-70 warning: tracked in RegionState["siege_warning_fired"]
  so a drop-and-recross doesn't re-fire the beat.
- region_state JSON decode error now logged via slog.Warn instead
  of silently discarded.

Failing TestProdDB_DnDLayer fixed via option (a): track migrated
characters and only run round-trip / idempotency assertions on those,
skipping pre-existing prod-DB rows accumulated from live bot use.

New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon,
briefing double-fire idempotency, threat-70 warning idempotency,
multi-region extract→resume state preservation, and starvation
forced-extraction.

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

493 lines
16 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"
"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
)
// 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 {
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 {
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_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_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 rolls a day forward, applies supply burn, posts the
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
//
// Idempotency: the first thing we do is an atomic compare-and-set on
// last_briefing_at. If another invocation (clock skew, restart, double
// fire) already claimed today's rollover, rowsAffected == 0 and we bail
// without re-applying supply burn / day++ / threat drift.
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
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
}
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
// can override the entire burn calculation with a fixed multiplier
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
// Advance day + supply burn happen together at the morning rollover.
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 {
burn = e.Supplies.DailyBurn * burnOverride.Multiplier
newSupplies = e.Supplies
newSupplies.Current -= burn
if newSupplies.Current < 0 {
newSupplies.Current = 0
}
newSupplies.ForagedToday = false
} else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
}
if err := updateSupplies(e.ID, newSupplies); err != nil {
return err
}
if err := advanceExpeditionDay(e.ID); err != nil {
return err
}
e.Supplies = newSupplies
e.CurrentDay++
// E2d: overnight camp rest effects (HP/spells/threat). Auto-breaks
// the camp after applying. Run before threat 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
}
}
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after
// boss kill. May cross a threshold and append a flavor-bearing log.
if _, _, err := applyDailyThreatDrift(e); err != nil {
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
}
// E3: zone temporal events post-rollover narration (after the day
// has advanced, so e.CurrentDay reflects the new day).
temporalLines := applyZoneTemporalPostRollover(e)
// §4.3: starvation triggers forced extraction. With no CON tracking
// in this layer, the briefing-time check is the practical equivalent
// of "CON reaches 0" — a starvation morning means the player can't
// reasonably press on. Apply the §10.2 coin tax and flip status.
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)
}
// E5b: if a temporal event (or starvation above) forced extraction,
// apply the §10.2 coin tax. The temporal layer flips the row to
// 'abandoned'; the cycle holds the euro handle to do the debit.
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")
}
}
// E6b: sample today's threat into RegionState["max_threat_seen"] before
// any milestone check reads it; then award daily milestones reached by
// the new day count (First Night day 2, Week One day 8, Two Weeks day 15).
_ = recordMaxThreat(e)
milestoneLines := p.checkDailyMilestones(e)
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
if restSummary != "" {
body += "\n💤 _" + restSummary + "_\n"
}
for _, tl := range temporalLines {
body += "\n🌀 " + tl + "\n"
}
for _, ml := range milestoneLines {
body += "\n" + ml
}
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), 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 fires before the recap so its
// outcome is part of today's log when the recap renders.
var night *NightCheck
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)
}
night = &nc
// §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)
body := renderEveningRecap(e, line, dayEntries)
if night != nil {
body += "\n" + renderNightCheck(*night)
}
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send recap DM", "user", uid, "err", err)
}
}
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
}
// 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()
}