Files
gogobee/internal/plugin/dnd_expedition_temporal_test.go
prosolis 9d4085f4c3 Adv 2.0 D&D Phase 12 E3b: Haunted Manor nightly reset
§7.2 cycle: every 3rd day's morning briefing in Blackspire fires the
"manor reset" temporal log — non-boss rooms respawn one enemy each.
Boss-defeated suppresses. Wrong-zone is a no-op.

Exposes ManorReset(e, day) bool for the combat-link phase to read at
!advance time when applying the actual respawn (per the deferral
pattern from E2b's wandering-encounter combat).

Reuses flavor.HauntedManorResetMorning per feedback_reuse_existing_flavor.

Deferred: Night-2 quiet warning narration on the recap path — needs
a new hook in deliverRecap; not load-bearing for the reset itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

273 lines
7.5 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 E3a — Sunken Temple tidal event.
// setExpDay rewrites the current_day column for tests.
func setExpDay(t *testing.T, expID string, day int) {
t.Helper()
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET current_day = ? WHERE expedition_id = ?`,
day, expID); err != nil {
t.Fatalf("set current_day: %v", err)
}
}
func TestSunkenTemple_TidalWarningDay4(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-tidal-d4:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "",
ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1.5, HarshMod: 1.5})
if err != nil {
t.Fatal(err)
}
setExpDay(t, exp.ID, 3) // → briefing rolls to Day 4
exp, _ = getExpedition(exp.ID)
p := &AdventurePlugin{}
startSU := exp.Supplies.Current
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.CurrentDay != 4 {
t.Fatalf("CurrentDay = %d, want 4", got.CurrentDay)
}
// Warning day: NO doubled burn — single 1.5 SU burn.
if got.Supplies.Current != startSU-1.5 {
t.Errorf("supplies = %v, want %v (single burn)", got.Supplies.Current, startSU-1.5)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
foundWarn := false
for _, e := range entries {
if e.Type == "temporal" && strings.Contains(e.Summary, "tidal warning") {
foundWarn = true
}
}
if !foundWarn {
t.Error("expected a 'temporal' tidal-warning log entry on Day 4")
}
}
func TestSunkenTemple_TidalEventDay6_ForcesDoubleBurn(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-tidal-d6:example")
defer cleanupExpeditions(uid)
// Tier-2 supplies: HarshMod 1.5 normally. Tidal must force 2×.
exp, err := startExpedition(uid, ZoneSunkenTemple, "",
ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1.5, HarshMod: 1.5})
if err != nil {
t.Fatal(err)
}
setExpDay(t, exp.ID, 5) // → rolls to Day 6
exp, _ = getExpedition(exp.ID)
p := &AdventurePlugin{}
startSU := exp.Supplies.Current
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.CurrentDay != 6 {
t.Fatalf("CurrentDay = %d, want 6", got.CurrentDay)
}
// Forced 2× burn = 1.5 * 2 = 3.0 SU
wantBurn := float32(3.0)
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (forced 2× tidal)", startSU-got.Supplies.Current, wantBurn)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
foundEvent := false
for _, e := range entries {
if e.Type == "temporal" && strings.Contains(e.Summary, "tidal peak") {
foundEvent = true
}
}
if !foundEvent {
t.Error("expected a 'temporal' tidal-peak log entry on Day 6")
}
}
func TestSunkenTemple_BossDefeatedSilencesTidal(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-tidal-boss:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "",
ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1.5, HarshMod: 1.5})
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET boss_defeated = 1, current_day = 5 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
p := &AdventurePlugin{}
startSU := exp.Supplies.Current
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
// Single normal burn, no tidal multiplier.
if got.Supplies.Current != startSU-1.5 {
t.Errorf("supplies = %v, want %v (no tidal mult)", got.Supplies.Current, startSU-1.5)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
for _, e := range entries {
if e.Type == "temporal" {
t.Errorf("did not expect temporal log entry post-boss; got %+v", e)
}
}
}
func TestManor_NightlyReset_FiresEveryThreeDays(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-manor-reset:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneManorBlackspire, "",
ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 2, HarshMod: 2})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
cases := []struct {
fromDay int
wantReset bool
}{
{1, false}, // → Day 2
{2, true}, // → Day 3
{3, false}, // → Day 4
{4, false}, // → Day 5
{5, true}, // → Day 6
{8, true}, // → Day 9
}
for _, c := range cases {
setExpDay(t, exp.ID, c.fromDay)
// Reset last_briefing_at so the next briefing actually fires.
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_briefing_at = NULL WHERE expedition_id = ?`, exp.ID); err != nil {
t.Fatal(err)
}
fresh, _ := getExpedition(exp.ID)
// Drain log entries from prior iterations so we count only today's.
preCount := manorResetLogCount(t, exp.ID)
if err := p.deliverBriefing(fresh, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got := manorResetLogCount(t, exp.ID)
gained := got - preCount
if c.wantReset && gained == 0 {
t.Errorf("from Day %d → expected a manor-reset entry; got none", c.fromDay)
}
if !c.wantReset && gained != 0 {
t.Errorf("from Day %d → expected no reset; got %d new entries", c.fromDay, gained)
}
}
}
func manorResetLogCount(t *testing.T, expID string) int {
t.Helper()
rows, err := db.Get().Query(`
SELECT COUNT(*) FROM dnd_expedition_log
WHERE expedition_id = ? AND entry_type = 'temporal'
AND summary LIKE 'manor reset%'`, expID)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if !rows.Next() {
return 0
}
var n int
_ = rows.Scan(&n)
return n
}
func TestManor_BossDefeatedSilencesReset(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-manor-boss:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneManorBlackspire, "",
ExpeditionSupplies{Current: 60, Max: 60, DailyBurn: 2, HarshMod: 2})
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET boss_defeated = 1, current_day = 2 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
p := &AdventurePlugin{}
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
if got := manorResetLogCount(t, exp.ID); got != 0 {
t.Errorf("boss-defeated manor should not reset; got %d entries", got)
}
}
func TestManorReset_PredicateOnly(t *testing.T) {
e := &Expedition{ZoneID: ZoneManorBlackspire}
for _, d := range []int{0, 1, 2, 4, 5, 7, 8} {
if ManorReset(e, d) {
t.Errorf("ManorReset(day %d) = true; want false", d)
}
}
for _, d := range []int{3, 6, 9, 12, 30} {
if !ManorReset(e, d) {
t.Errorf("ManorReset(day %d) = false; want true", d)
}
}
// Wrong zone is always false.
other := &Expedition{ZoneID: ZoneGoblinWarrens}
if ManorReset(other, 3) {
t.Error("ManorReset on non-Manor zone should be false")
}
}
func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-tidal-other:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
setExpDay(t, exp.ID, 5)
exp, _ = getExpedition(exp.ID)
p := &AdventurePlugin{}
startSU := exp.Supplies.Current
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.Supplies.Current != startSU-1 {
t.Errorf("non-tidal zone burned %v, want 1", startSU-got.Supplies.Current)
}
}