Files
gogobee/internal/plugin/dnd_expedition_cycle_test.go
prosolis 7528d5c239 Adv 2.0 D&D Phase 12 E1d: real-time day cycle (06:00 briefing / 21:00 recap)
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<DayBand> (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) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

215 lines
5.6 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.
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")
}
}