From 7528d5c2392bb08efd96204ba9b2cae37e3104b5 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 15:20:20 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E1d: real-time day cycle (06:00 briefing / 21:00 recap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tickers in adventure.go startup. Each minute, when UTC clock hits the trigger hour, walks every active expedition whose last_briefing_at / last_recap_at is stale relative to today's threshold and fires it. Per- expedition gating (rather than global db.JobCompleted) means a player who started mid-day still gets their first briefing the next morning. Briefing: applies one day's supply burn (harsh mult kicks in when threat > 60 / siege / temporal stack > 0), advances current_day by one, picks flavor.MorningBriefing (Day1/Day3/Day7/Day14/Day21 → otherwise Generic), DMs the §12.1 block, appends a 'briefing' log entry, stamps last_briefing_at. Recap: scans today's log entries — boss kill picks flavor.EveningRecapBossKilled, no combat + no narrative entries picks EveningRecapNothingHappened, otherwise EveningRecapGeneric. DMs the §12.2 block (HP-aware close-call detection deferred to E2 since it needs combat hooks). Stamps last_recap_at. Tests cover day-band selection, day advance + supply burn (incl. harsh mult), threshold filtering, recap log entry creation, and boss-kill override. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure.go | 2 + internal/plugin/dnd_expedition_cycle.go | 341 +++++++++++++++++++ internal/plugin/dnd_expedition_cycle_test.go | 214 ++++++++++++ 3 files changed, 557 insertions(+) create mode 100644 internal/plugin/dnd_expedition_cycle.go create mode 100644 internal/plugin/dnd_expedition_cycle_test.go diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 1e5a71a..4f80a5d 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -173,6 +173,8 @@ func (p *AdventurePlugin) Init() error { go p.hospitalNudgeTicker() go p.mortgageTicker() go p.coopTicker() + go p.expeditionBriefingTicker() + go p.expeditionRecapTicker() // Auto-cashout any arena runs left in 'awaiting' from a prior restart p.arenaCleanupStaleRuns() diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go new file mode 100644 index 0000000..07c7ab9 --- /dev/null +++ b/internal/plugin/dnd_expedition_cycle.go @@ -0,0 +1,341 @@ +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 range 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 range 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 IN ('active', 'extracting') + 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 IN ('active', 'extracting') + 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. +func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { + // Advance day + supply burn happen together at the morning rollover. + harsh := e.ThreatLevel > 60 || e.SiegeMode || e.TemporalStack > 0 + newSupplies, burn := applyDailyBurn(e.Supplies, harsh) + if err := updateSupplies(e.ID, newSupplies); err != nil { + return err + } + if err := advanceExpeditionDay(e.ID); err != nil { + return err + } + e.Supplies = newSupplies + e.CurrentDay++ + + line := pickMorningBriefing(e.CurrentDay) + body := renderMorningBriefing(e, line, burn) + + 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 + } + _, err := db.Get().Exec(` + UPDATE dnd_expedition + SET last_briefing_at = ?, + last_activity = ? + WHERE expedition_id = ?`, now, now, e.ID) + return err +} + +// 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. +func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error { + dayEntries, err := dayLogEntries(e.ID, e.CurrentDay) + if err != nil { + return err + } + line := pickEveningRecap(e, dayEntries) + body := renderEveningRecap(e, line, dayEntries) + + 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 + } + _, err = db.Get().Exec(` + UPDATE dnd_expedition + SET last_recap_at = ?, + last_activity = ? + WHERE expedition_id = ?`, now, now, e.ID) + return err +} + +// 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() +} diff --git a/internal/plugin/dnd_expedition_cycle_test.go b/internal/plugin/dnd_expedition_cycle_test.go new file mode 100644 index 0000000..863d149 --- /dev/null +++ b/internal/plugin/dnd_expedition_cycle_test.go @@ -0,0 +1,214 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Phase 12 E1d — day cycle tests. We can't easily fast-forward UTC time, +// so the tests drive deliverBriefing / deliverRecap directly with a +// synthetic "now" and verify state transitions. Ticker scheduling is a +// thin wrapper over those. + +func TestPickMorningBriefing_DayBands(t *testing.T) { + cases := []struct { + day int + want []string // any-of pool + }{ + {1, []string{"First morning", "Day one complete"}}, + {3, []string{"Day three", "Three days in"}}, + {7, []string{"One week", "Seven days", "A week underground"}}, + {14, []string{"Two weeks", "Fourteen days"}}, + {21, []string{"Three weeks"}}, + } + for _, c := range cases { + got := pickMorningBriefing(c.day) + matched := false + for _, prefix := range c.want { + if strings.Contains(got, prefix) { + matched = true + break + } + } + if !matched { + t.Errorf("Day %d picked %q — none of %v matched", c.day, got, c.want) + } + } + // Generic fallback for off-band days. + if got := pickMorningBriefing(2); got == "" { + t.Error("generic fallback empty") + } + if got := pickMorningBriefing(99); got == "" { + t.Error("generic fallback empty") + } +} + +func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-cycle-brief:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + now := time.Now().UTC() + if err := p.deliverBriefing(exp, now); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 2 { + t.Errorf("current_day = %d, want 2", got.CurrentDay) + } + if got.Supplies.Current != 9 { + t.Errorf("supplies = %v, want 9", got.Supplies.Current) + } + if got.LastBriefingAt == nil { + t.Error("LastBriefingAt should be set") + } + // Briefing log entry should exist. + entries, _ := recentExpeditionLog(exp.ID, 5) + foundBriefing := false + for _, e := range entries { + if e.Type == "briefing" { + foundBriefing = true + } + } + if !foundBriefing { + t.Error("briefing log entry missing") + } +} + +func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-cycle-harsh:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 2}) + if err != nil { + t.Fatal(err) + } + // Force harsh conditions via threat clock above 60. + if err := applyThreatDelta(exp.ID, 70, "test"); err != nil { + t.Fatal(err) + } + exp, _ = getExpedition(exp.ID) + p := &AdventurePlugin{} + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.Supplies.Current != 8 { // 1 base × 2 harsh = 2 SU + t.Errorf("harsh burn supplies = %v, want 8", got.Supplies.Current) + } +} + +func TestLoadExpeditionsNeedingBriefing_FiltersByThreshold(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-cycle-load:example") + defer cleanupExpeditions(uid) + + // Start an expedition; rewind start_date so threshold falls past it. + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + yesterday := time.Now().UTC().Add(-25 * time.Hour) + if _, err := db.Get().Exec(`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, + yesterday, exp.ID); err != nil { + t.Fatal(err) + } + + threshold := time.Now().UTC() + got, err := loadExpeditionsNeedingBriefing(threshold) + if err != nil { + t.Fatal(err) + } + found := false + for _, e := range got { + if e.ID == exp.ID { + found = true + } + } + if !found { + t.Error("expected expedition in needs-briefing list") + } + + // Stamp briefing → should drop out. + now := time.Now().UTC() + if _, err := db.Get().Exec(`UPDATE dnd_expedition SET last_briefing_at = ? WHERE expedition_id = ?`, + now, exp.ID); err != nil { + t.Fatal(err) + } + got, err = loadExpeditionsNeedingBriefing(threshold.Add(-1 * time.Minute)) + if err != nil { + t.Fatal(err) + } + for _, e := range got { + if e.ID == exp.ID { + t.Error("expedition should be filtered out after briefing stamped") + } + } +} + +func TestDeliverRecap_StampsAndLogs(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-cycle-recap:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{} + if err := p.deliverRecap(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.LastRecapAt == nil { + t.Error("LastRecapAt should be set") + } + entries, _ := recentExpeditionLog(exp.ID, 5) + foundRecap := false + for _, e := range entries { + if e.Type == "recap" { + foundRecap = true + } + } + if !foundRecap { + t.Error("recap log entry missing") + } +} + +func TestPickEveningRecap_BossOverridesAll(t *testing.T) { + exp := &Expedition{} + today := []ExpeditionEntry{ + {Type: "combat"}, + {Type: "boss", Summary: "boss defeated"}, + } + got := pickEveningRecap(exp, today) + if got == "" { + t.Fatal("expected boss-killed flavor") + } + if !strings.Contains(strings.ToLower(got), "boss") { + t.Errorf("expected boss-killed pool, got %q", got) + } +} + +func TestPickEveningRecap_NothingHappenedFallback(t *testing.T) { + exp := &Expedition{} + today := []ExpeditionEntry{} // no entries today + got := pickEveningRecap(exp, today) + if got == "" { + t.Error("expected fallback flavor") + } +}