mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
getActiveZoneRun's 24h stale-run reaper abandoned the run but left the wrapping expedition status='active' pointing at a dead run. The autopilot then read run==nil and bailed while briefing/recap tickers kept firing, so the player soft-locked at the last fork with no way to route on. The reaper now force-extracts the wrapping active expedition (run-loss seam) when it reaps that expedition's current run. Root cause was a background fork: the autopilot can't pick for the player, so the run idled all the way to the reaper. Add an 8h forkAutoPickTimeout — a background fork left unanswered that long now auto-picks the first unlocked route (same path as !zone go) and keeps walking; all-locked forks are left for the player. Foreground !expedition run still always prompts. Tests: idle-timeout extracts the expedition; stale fork takes the available route; all-locked fork stays intact.
394 lines
12 KiB
Go
394 lines
12 KiB
Go
package plugin
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Phase-R7 hardening tests covering the audit gaps: 24h auto-abandon,
|
|
// briefing idempotency, threat-70 warning idempotency, multi-region
|
|
// extract→resume, and starvation→forced extraction.
|
|
|
|
// ── 24-hour zone-run auto-abandon (§4.3) ────────────────────────────────────
|
|
|
|
func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@stale-run:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
|
if err != nil {
|
|
t.Fatalf("startZoneRun: %v", err)
|
|
}
|
|
// Backdate last_action_at to 25 hours ago.
|
|
if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil {
|
|
t.Fatalf("backdate: %v", err)
|
|
}
|
|
got, err := getActiveZoneRun(uid)
|
|
if err != nil {
|
|
t.Fatalf("getActiveZoneRun: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("expected nil after 24h timeout, got run %q", got.RunID)
|
|
}
|
|
// And the row is marked abandoned.
|
|
raw, _ := getZoneRun(run.RunID)
|
|
if raw == nil || !raw.Abandoned {
|
|
t.Errorf("run not abandoned: %+v", raw)
|
|
}
|
|
}
|
|
|
|
// A run reaped by the idle timeout must also terminate the wrapping active
|
|
// expedition, or the expedition is orphaned 'active' over a dead run and the
|
|
// player soft-locks (the original feywild "stuck, can't route" bug).
|
|
func TestZoneRun_IdleTimeoutExtractsWrappingExpedition(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@orphan-run:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
|
if err != nil {
|
|
t.Fatalf("startZoneRun: %v", err)
|
|
}
|
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
|
if err != nil {
|
|
t.Fatalf("startExpedition: %v", err)
|
|
}
|
|
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
|
|
t.Fatalf("link run: %v", err)
|
|
}
|
|
// Backdate the run past the 24h stale threshold.
|
|
if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil {
|
|
t.Fatalf("backdate: %v", err)
|
|
}
|
|
|
|
got, err := getActiveZoneRun(uid)
|
|
if err != nil {
|
|
t.Fatalf("getActiveZoneRun: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("expected nil after timeout, got run %q", got.RunID)
|
|
}
|
|
// The wrapping expedition must no longer be active.
|
|
after, err := getExpedition(exp.ID)
|
|
if err != nil {
|
|
t.Fatalf("getExpedition: %v", err)
|
|
}
|
|
if after == nil {
|
|
t.Fatal("expedition row vanished")
|
|
}
|
|
if after.Status == ExpeditionStatusActive {
|
|
t.Errorf("expedition still active after run idle-timeout; status=%q", after.Status)
|
|
}
|
|
}
|
|
|
|
// A stale background fork auto-picks the first unlocked route so the
|
|
// expedition keeps moving instead of idling out to the reaper.
|
|
func TestAutoPickStaleFork_TakesAvailableRoute(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@stale-fork:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
|
if err != nil {
|
|
t.Fatalf("startZoneRun: %v", err)
|
|
}
|
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
|
if err != nil {
|
|
t.Fatalf("startExpedition: %v", err)
|
|
}
|
|
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
|
|
t.Fatalf("link run: %v", err)
|
|
}
|
|
pf := pendingFork{
|
|
PendingAt: "goblin_warrens.cavern_junction",
|
|
Options: []pendingChoice{
|
|
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
|
|
Unlocked: false, Lock: "perception_check", Reason: "Perception 8 vs DC 14"},
|
|
{Index: 2, To: "goblin_warrens.kennel_path", Label: "Kennel Path",
|
|
Unlocked: true, Lock: "none"},
|
|
},
|
|
}
|
|
if err := writePendingFork(run.RunID, pf); err != nil {
|
|
t.Fatalf("writePendingFork: %v", err)
|
|
}
|
|
r2, _ := getZoneRun(run.RunID)
|
|
got, _ := decodePendingFork(r2.NodeChoices)
|
|
if got == nil {
|
|
t.Fatal("fork not persisted")
|
|
}
|
|
|
|
p := &AdventurePlugin{}
|
|
if ok := p.autoPickStaleFork(exp, r2, got); !ok {
|
|
t.Fatal("expected auto-pick to commit a route")
|
|
}
|
|
after, _ := getZoneRun(run.RunID)
|
|
if after.CurrentNode != "goblin_warrens.kennel_path" {
|
|
t.Errorf("did not advance to the unlocked route: current_node=%q", after.CurrentNode)
|
|
}
|
|
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 != nil {
|
|
t.Errorf("pending fork not cleared after auto-pick")
|
|
}
|
|
}
|
|
|
|
// When every option is locked there's nothing safe to auto-pick — leave the
|
|
// fork intact for the player (or the reaper).
|
|
func TestAutoPickStaleFork_AllLockedLeavesForkIntact(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@locked-fork:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
|
if err != nil {
|
|
t.Fatalf("startZoneRun: %v", err)
|
|
}
|
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
|
if err != nil {
|
|
t.Fatalf("startExpedition: %v", err)
|
|
}
|
|
_ = setExpeditionRunID(exp.ID, run.RunID)
|
|
pf := pendingFork{
|
|
PendingAt: "goblin_warrens.cavern_junction",
|
|
Options: []pendingChoice{
|
|
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
|
|
Unlocked: false, Lock: "perception_check", Reason: "DC 14"},
|
|
},
|
|
}
|
|
if err := writePendingFork(run.RunID, pf); err != nil {
|
|
t.Fatalf("writePendingFork: %v", err)
|
|
}
|
|
r2, _ := getZoneRun(run.RunID)
|
|
before := r2.CurrentNode
|
|
got, _ := decodePendingFork(r2.NodeChoices)
|
|
|
|
p := &AdventurePlugin{}
|
|
if ok := p.autoPickStaleFork(exp, r2, got); ok {
|
|
t.Fatal("expected no auto-pick when every option is locked")
|
|
}
|
|
after, _ := getZoneRun(run.RunID)
|
|
if after.CurrentNode != before {
|
|
t.Errorf("current_node moved on an all-locked fork: %q → %q", before, after.CurrentNode)
|
|
}
|
|
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 == nil {
|
|
t.Errorf("pending fork was cleared on an all-locked fork")
|
|
}
|
|
}
|
|
|
|
func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@fresh-run:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := getActiveZoneRun(uid)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("fresh run dropped: got=%v err=%v", got, err)
|
|
}
|
|
if got.RunID != run.RunID {
|
|
t.Errorf("returned wrong run: %q vs %q", got.RunID, run.RunID)
|
|
}
|
|
}
|
|
|
|
// ── Briefing idempotency: double-fire same day is a no-op ───────────────────
|
|
|
|
func TestDeliverBriefing_DoubleFireIsIdempotent(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@brief-idem:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Backdate start so the briefing precondition (start_date < threshold) passes.
|
|
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
|
t.Fatalf("backdate: %v", err)
|
|
}
|
|
|
|
p := &AdventurePlugin{}
|
|
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
|
|
|
if err := p.deliverBriefing(exp, now); err != nil {
|
|
t.Fatalf("first deliver: %v", err)
|
|
}
|
|
first, _ := getExpedition(exp.ID)
|
|
day1 := first.CurrentDay
|
|
supplies1 := first.Supplies.Current
|
|
|
|
// Re-fetch as a fresh copy (simulates a second tick reading the row
|
|
// before its claimed last_briefing_at lands in the loader filter).
|
|
exp2, _ := getExpedition(exp.ID)
|
|
exp2.Supplies = first.Supplies // pretend stale snapshot
|
|
if err := p.deliverBriefing(exp2, now); err != nil {
|
|
t.Fatalf("second deliver: %v", err)
|
|
}
|
|
second, _ := getExpedition(exp.ID)
|
|
if second.CurrentDay != day1 {
|
|
t.Errorf("day double-bumped: %d → %d", day1, second.CurrentDay)
|
|
}
|
|
if second.Supplies.Current != supplies1 {
|
|
t.Errorf("supplies double-burned: %.1f → %.1f", supplies1, second.Supplies.Current)
|
|
}
|
|
}
|
|
|
|
// ── Threat-70 warning fires once across multiple crossings ──────────────────
|
|
|
|
func TestApplyDailyThreatDrift_70WarningOnceOnly(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@threat-70-once: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, 65, "seed"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Cross 70 the first time.
|
|
// Push over 70 the first time.
|
|
if err := applyThreatDelta(exp.ID, 6, "drift"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exp, _ = getExpedition(exp.ID)
|
|
if _, _, err := applyDailyThreatDrift(exp); err != nil {
|
|
t.Fatalf("first drift: %v", err)
|
|
}
|
|
|
|
// Drop below 70 then bump back over.
|
|
if err := applyThreatDelta(exp.ID, -15, "fortified rest"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := applyThreatDelta(exp.ID, 10, "loud combat"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exp, _ = getExpedition(exp.ID)
|
|
if _, _, err := applyDailyThreatDrift(exp); err != nil {
|
|
t.Fatalf("second drift: %v", err)
|
|
}
|
|
|
|
rows, err := recentExpeditionLog(exp.ID, 50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
warnings := 0
|
|
for _, r := range rows {
|
|
if r.Type == "threat" && strings.Contains(strings.ToLower(r.Summary), "siege approaches") {
|
|
warnings++
|
|
}
|
|
}
|
|
if warnings > 1 {
|
|
t.Errorf("threat-70 warning fired %d times across drift loop; want at most 1", warnings)
|
|
}
|
|
}
|
|
|
|
// ── Starvation triggers forced extraction at briefing time ──────────────────
|
|
|
|
func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@starve-extract:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
|
ExpeditionSupplies{Current: 0.5, Max: 10, DailyBurn: 5, HarshMod: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rewindToLegacyAnchor(t, exp)
|
|
|
|
p := &AdventurePlugin{}
|
|
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
|
if err := p.deliverBriefing(exp, now); err != nil {
|
|
t.Fatalf("deliver: %v", err)
|
|
}
|
|
|
|
got, _ := getExpedition(exp.ID)
|
|
if got.Status != ExpeditionStatusAbandoned {
|
|
t.Errorf("status = %q after starvation briefing, want abandoned", got.Status)
|
|
}
|
|
}
|
|
|
|
// ── Multi-region extract→resume preserves region state ──────────────────────
|
|
|
|
func TestExtractResume_MultiRegionPreservesState(t *testing.T) {
|
|
setupZoneRunTestDB(t)
|
|
uid := id.UserID("@multiregion-resume:example")
|
|
defer cleanupExpeditions(uid)
|
|
|
|
exp, err := startExpedition(uid, ZoneUnderdark, "underdark_surface_tunnels",
|
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Visit a second region and mark a region-boss kill.
|
|
if _, err := MarkRegionVisited(exp, "underdark_drow_outpost"); err != nil {
|
|
t.Fatalf("mark visited: %v", err)
|
|
}
|
|
if _, err := MarkRegionBossDefeated(exp, "underdark_drow_outpost"); err != nil {
|
|
t.Fatalf("mark boss: %v", err)
|
|
}
|
|
|
|
// Extract.
|
|
if _, err := voluntaryExtractExpedition(uid); err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
|
|
// Resume with fresh supplies.
|
|
resumed, err := getResumableExpedition(uid)
|
|
if err != nil || resumed == nil {
|
|
t.Fatalf("getResumable: %v %v", resumed, err)
|
|
}
|
|
if err := resumeExpedition(resumed.ID, ExpeditionSupplies{
|
|
Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1,
|
|
}); err != nil {
|
|
t.Fatalf("resume: %v", err)
|
|
}
|
|
|
|
// Re-load — region state must survive.
|
|
live, _ := getActiveExpedition(uid)
|
|
if live == nil {
|
|
t.Fatal("post-resume: no active expedition")
|
|
}
|
|
if !IsRegionVisited(live, "underdark_drow_outpost") {
|
|
t.Error("visited region lost on resume")
|
|
}
|
|
if !IsRegionCleared(live, "underdark_drow_outpost") {
|
|
t.Error("region-cleared flag lost on resume")
|
|
}
|
|
}
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
func dbExecZoneRunBackdate(runID string, ago time.Duration) (int64, error) {
|
|
when := time.Now().UTC().Add(-ago)
|
|
r, err := db.Get().Exec(
|
|
`UPDATE dnd_zone_run SET last_action_at = ? WHERE run_id = ?`, when, runID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return r.RowsAffected()
|
|
}
|
|
|
|
func dbExecExpeditionBackdate(expID string, ago time.Duration) (int64, error) {
|
|
when := time.Now().UTC().Add(-ago)
|
|
r, err := db.Get().Exec(
|
|
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, when, expID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return r.RowsAffected()
|
|
}
|