Files
gogobee/internal/plugin/dnd_expedition_cycle_test.go
prosolis c729433353 Long expeditions D2-b: event-anchored day rollover
Splits the legacy briefing body into nightRolloverBurn + nightRolloverDrift,
plus a processNightCamp convenience. For expeditions started after the new
eventAnchoredCutoff, the 06:00 UTC briefing stops mutating: it posts a
re-engagement DM, and only force-fires processNightCamp itself after a 28h
safety-net window. Day++/burn/threat-drift now ride along the autopilot
night-camp pitch (decideAutopilotCamp sets Night=true when ≥16h since the
last rollover) and on the first player !camp since the last rollover. The
legacy UTC-anchored flow still works via the same staged helpers, with
processOvernightCamp interleaved to preserve the rest-before-drift ordering.

Tests pin eventAnchoredCutoff to year 9999 in TestMain so the existing
legacy assertions still run; new tests cover the night decision, the
night-camp pitch advancing the day, the event-anchored skip, and the
safety-net force-rollover.
2026-05-27 18:42:57 -07:00

315 lines
8.9 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)
}
}
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")
}
}