Files
gogobee/internal/plugin/dnd_expedition_cycle_test.go
prosolis aaec0ba225 Long expeditions D4-b: activity-anchored morning briefing
Event-anchored expeditions no longer pin their re-engagement DM to 06:00
UTC. The ticker skips idle players (last_activity older than today's
threshold, inside the 28h safety net); maybeDeliverDeferredBriefing fires
the owed briefing on the next inbound message in any room. The OnMessage
hook also stamps last_activity so chat presence (not just bot commands)
counts toward "the player is here."
2026-05-27 19:21:49 -07:00

406 lines
12 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 (
"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.
// rewindToLegacyAnchor backdates an expedition's start_date to before
// eventAnchoredCutoff so deliverBriefing exercises the legacy UTC-anchored
// mutator path. Tests of the D2-b event-anchored path should NOT call this.
func rewindToLegacyAnchor(t *testing.T, exp *Expedition) {
t.Helper()
before := eventAnchoredCutoff.Add(-24 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`,
before, exp.ID); err != nil {
t.Fatal(err)
}
exp.StartDate = before
}
func TestPickMorningBriefing_DayBands(t *testing.T) {
cases := []struct {
day int
want []string // any-of pool
}{
{1, []string{"First morning", "Day one", "day two"}},
{3, []string{"Day three", "Three days"}},
{7, []string{"One week", "Seven days", "A week underground"}},
{14, []string{"Two weeks", "Fourteen days", "Day fifteen"}},
{21, []string{"Three weeks", "Day twenty-one"}},
}
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)
}
rewindToLegacyAnchor(t, exp)
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)
}
// Phase 5-B: applyDailyBurn scales by phase5BDailyBurnRatePct=50, so
// raw burn 1 × 0.5 = 0.5 SU. Current 10 - 0.5 = 9.5.
if got.Supplies.Current != 9.5 {
t.Errorf("supplies = %v, want 9.5", 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")
}
}
// useEventAnchored fences the eventAnchoredCutoff to before the given
// expedition's start_date so the D2-b path is taken. Test-scoped via t.Cleanup.
func useEventAnchored(t *testing.T, exp *Expedition) {
t.Helper()
saved := eventAnchoredCutoff
eventAnchoredCutoff = exp.StartDate.Add(-time.Minute)
t.Cleanup(func() { eventAnchoredCutoff = saved })
}
func TestDeliverBriefing_EventAnchoredSkipsMutators(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-evt-skip:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
// last_briefing_at is NULL and start_date is "now-ish", so the safety
// net should NOT fire — sub-28h since start.
p := &AdventurePlugin{}
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.CurrentDay != 1 {
t.Errorf("day = %d, want 1 (event-anchored briefing should not advance day)", got.CurrentDay)
}
if got.Supplies.Current != 10 {
t.Errorf("supplies = %v, want 10 (event-anchored briefing should not burn)", got.Supplies.Current)
}
}
func TestDeliverBriefing_EventAnchoredSafetyNetForces(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-evt-safety:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
// Backdate start_date so > nightSafetyNet has elapsed with no rollover.
before := time.Now().UTC().Add(-(nightSafetyNet + time.Hour))
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`,
before, exp.ID); err != nil {
t.Fatal(err)
}
exp.StartDate = before
p := &AdventurePlugin{}
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.CurrentDay != 2 {
t.Errorf("day = %d, want 2 (safety net should force rollover)", got.CurrentDay)
}
if got.Supplies.Current >= 10 {
t.Errorf("supplies = %v, want < 10 (safety net should burn)", got.Supplies.Current)
}
}
// D4-b: the 06:00 ticker skips event-anchored expeditions whose player
// hasn't moved since before today's threshold; the briefing lands lazily
// on the next inbound message via maybeDeliverDeferredBriefing.
func TestFireBriefings_EventAnchoredIdleSkip(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-d4b-idle:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
// Synthetic now pinned past today's 06:00 UTC so the test outcome is
// independent of wall-clock time of day.
wall := time.Now().UTC()
// Synthetic now is today's 06:30 UTC — just past the ticker threshold,
// but well inside the 28h nightSafetyNet window so the safety-net
// force path doesn't kick in.
now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC)
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC)
idleActivity := threshold.Add(-2 * time.Hour)
priorBriefing := now.Add(-24 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET current_day = 3 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
idleActivity, priorBriefing, exp.ID); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.fireExpeditionBriefings(now)
got, _ := getExpedition(exp.ID)
if got.LastBriefingAt == nil || !got.LastBriefingAt.Equal(priorBriefing) {
t.Fatalf("idle ticker should not have stamped last_briefing_at: got %v want %v",
got.LastBriefingAt, priorBriefing)
}
// Simulate inbound message: lazy delivery should fire now.
p.maybeDeliverDeferredBriefing(uid, now)
got, _ = getExpedition(exp.ID)
if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) {
t.Fatalf("deferred delivery should have stamped a fresh last_briefing_at: got %v",
got.LastBriefingAt)
}
}
// D4-b: an active player (last_activity >= today's threshold) still gets
// the morning DM on the regular ticker — no idle skip.
func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-d4b-active:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
wall := time.Now().UTC()
now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC)
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC)
activeActivity := threshold.Add(15 * time.Minute)
priorBriefing := now.Add(-24 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
activeActivity, priorBriefing, exp.ID); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.fireExpeditionBriefings(now)
got, _ := getExpedition(exp.ID)
if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) {
t.Fatalf("active player should have received briefing: last_briefing_at=%v",
got.LastBriefingAt)
}
}
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)
}
rewindToLegacyAnchor(t, exp)
// 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)
// Phase 5-B: 1 base × 2 harsh × phase5B 50% = 1 SU; current 10 - 1 = 9.
if got.Supplies.Current != 9 {
t.Errorf("harsh burn supplies = %v, want 9", 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")
}
// Boss-killed pool entries reference the kill in varied ways — "boss",
// "thing in the chamber", "vacancy", etc. Match any of the recurring
// motifs rather than the literal word.
low := strings.ToLower(got)
matched := false
for _, marker := range []string{"boss", "thing in the chamber", "vacancy"} {
if strings.Contains(low, marker) {
matched = true
break
}
}
if !matched {
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")
}
}