Files
gogobee/internal/plugin/dnd_expedition_threat_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

275 lines
7.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 E2a — Threat Clock state-machine tests.
func TestThreatBandFor(t *testing.T) {
cases := []struct {
level int
siege bool
want ThreatBand
}{
{0, false, ThreatBandQuiet},
{20, false, ThreatBandQuiet},
{21, false, ThreatBandStirring},
{40, false, ThreatBandStirring},
{41, false, ThreatBandAlert},
{60, false, ThreatBandAlert},
{61, false, ThreatBandHostile},
{80, false, ThreatBandHostile},
{81, false, ThreatBandSiege},
{100, false, ThreatBandSiege},
{50, true, ThreatBandSiege},
}
for _, c := range cases {
got := threatBandFor(c.level, c.siege)
if got != c.want {
t.Errorf("threatBandFor(%d, %v) = %v, want %v", c.level, c.siege, got, c.want)
}
}
}
func TestThreatBandInfo_SiegeFlags(t *testing.T) {
info := threatBandInfo(ThreatBandSiege)
if !info.NoShortRest {
t.Error("siege should disable short rests")
}
if info.SupplyMult != 2 {
t.Errorf("siege supply mult = %v, want 2", info.SupplyMult)
}
if !info.InitAdv || !info.TrapsArm {
t.Error("siege should grant init advantage and re-arm traps")
}
}
func TestDailyThreatDrift_MoodMod(t *testing.T) {
// Phase 5-B: base threat drift lowered from 3 to 1 to slow the
// threat clock (paired with the player floor + supply burn cut).
// All expected values shift by -2 (base 3 → 1) but the mood-mod
// shape is unchanged.
cases := []struct {
mood int
want int
}{
{50, 1}, // neutral: 1 + 0
{60, 1}, // friendly (still neutral): 1 + 0
{80, -2}, // effusive → elated: 1 + -3
{19, 6}, // hostile → wrathful: 1 + 5
{0, 6}, // hostile lower bound
}
for _, c := range cases {
got, _ := dailyThreatDrift(c.mood)
if got != c.want {
t.Errorf("mood=%d drift=%d, want %d", c.mood, got, c.want)
}
}
}
func TestApplyDailyThreatDrift_PersistsAndCrossesThreshold(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-threat-drift:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
// Phase 5-B: base drift dropped to 1. Pre-load to 20 (Quiet band) so
// the +1 drift crosses into Stirring at 21.
if err := applyThreatDelta(exp.ID, 20, "test seed"); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
delta, _, err := applyDailyThreatDrift(exp)
if err != nil {
t.Fatal(err)
}
if delta != 1 {
t.Errorf("delta = %d, want 1", delta)
}
got, _ := getExpedition(exp.ID)
if got.ThreatLevel != 21 {
t.Errorf("threat = %d, want 21", got.ThreatLevel)
}
// Threshold transition log entry should exist with TwinBee narration.
entries, _ := recentExpeditionLog(exp.ID, 10)
foundTransition := false
for _, e := range entries {
if e.Type == "threat" && strings.Contains(strings.ToLower(e.Summary), "stirring") {
if e.Flavor == "" {
t.Error("threat transition missing flavor line")
}
foundTransition = true
}
}
if !foundTransition {
t.Error("expected threat transition log entry into Stirring")
}
}
func TestApplyDailyThreatDrift_NoOpAfterBoss(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-threat-postboss:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(`UPDATE dnd_expedition SET boss_defeated = 1 WHERE expedition_id = ?`, exp.ID); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
delta, _, err := applyDailyThreatDrift(exp)
if err != nil {
t.Fatal(err)
}
if delta != 0 {
t.Errorf("delta = %d, want 0 after boss kill", delta)
}
}
func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-brief-drift: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{}
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.ThreatLevel != 1 {
t.Errorf("post-briefing threat = %d, want 1 (mood-neutral drift, Phase 5-B base)", got.ThreatLevel)
}
}
func TestApplyDailyThreatDrift_LogsApproachingSiegeOnceAt70(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-threat-70warn:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
// Phase 5-B: base drift dropped to 1. Seed to 69 so a +1 drift
// crosses 70 → 70.
if err := applyThreatDelta(exp.ID, 69, "seed"); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
if _, _, err := applyDailyThreatDrift(exp); err != nil {
t.Fatal(err)
}
entries, _ := recentExpeditionLog(exp.ID, 20)
count := 0
for _, e := range entries {
if e.Type == "threat" && strings.Contains(e.Summary, "past 70") {
count++
}
}
if count != 1 {
t.Errorf("expected exactly one approaching-siege log, got %d", count)
}
// A subsequent drift past 70 should not re-emit the warning.
exp, _ = getExpedition(exp.ID)
if _, _, err := applyDailyThreatDrift(exp); err != nil {
t.Fatal(err)
}
entries, _ = recentExpeditionLog(exp.ID, 20)
count = 0
for _, e := range entries {
if e.Type == "threat" && strings.Contains(e.Summary, "past 70") {
count++
}
}
if count != 1 {
t.Errorf("expected approaching-siege log still 1 after second drift, got %d", count)
}
}
func TestThreatBandEffectsBlock_Bands(t *testing.T) {
q := threatBandEffectsBlock(threatBandInfo(ThreatBandQuiet))
if !strings.Contains(strings.ToLower(q), "unaware") {
t.Errorf("Quiet block should mention unaware, got %q", q)
}
s := threatBandEffectsBlock(threatBandInfo(ThreatBandSiege))
if !strings.Contains(strings.ToLower(s), "short rests blocked") {
t.Errorf("Siege block should call out blocked short rests, got %q", s)
}
if !strings.Contains(s, "×2") {
t.Errorf("Siege block should show ×2 supply mult, got %q", s)
}
}
func TestHandleThreatCmd_NoExpeditionDoesNotPanic(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@threat-cmd-noexp:example")
defer cleanupExpeditions(uid)
p := &AdventurePlugin{}
if err := p.handleThreatCmd(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
}
func TestHandleThreatCmd_ActiveExpeditionLogsEvents(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@threat-cmd-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)
}
if err := applyThreatDelta(exp.ID, 45, "test seed"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleThreatCmd(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
}
func TestApplyBossDefeatThreat_DropsLevel(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-boss-threat:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if err := applyThreatDelta(exp.ID, 50, "seed"); err != nil {
t.Fatal(err)
}
if err := applyBossDefeatThreat(exp.ID); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.ThreatLevel != 30 {
t.Errorf("post-boss threat = %d, want 30", got.ThreatLevel)
}
}