package plugin import ( "testing" "time" "maunium.net/go/mautrix/id" ) func TestDecideAutopilotCamp_SkipsWhenCamped(t *testing.T) { in := autoCampInputs{ Camp: &CampState{Active: true, Type: CampTypeRough}, Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, HPCur: 1, HPMax: 20, // very low — would otherwise camp Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, } if _, ok := decideAutopilotCamp(in); ok { t.Errorf("expected no camp when already camped") } } func TestDecideAutopilotCamp_SkipsHealthyHP(t *testing.T) { in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, HPCur: 20, HPMax: 20, Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, } if _, ok := decideAutopilotCamp(in); ok { t.Errorf("expected no camp when HP full") } } func TestDecideAutopilotCamp_LowHPClearedPitchesStandard(t *testing.T) { in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, HPCur: 5, HPMax: 20, // 25% — below 55% threshold Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, } d, ok := decideAutopilotCamp(in) if !ok || d.Kind != CampTypeStandard { t.Errorf("expected standard camp, got %+v ok=%v", d, ok) } } func TestDecideAutopilotCamp_LowHPUnclearedPitchesRough(t *testing.T) { in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 2, RoomsCleared: []int{0, 1}}, HPCur: 5, HPMax: 20, Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, } d, ok := decideAutopilotCamp(in) if !ok || d.Kind != CampTypeRough { t.Errorf("expected rough camp, got %+v ok=%v", d, ok) } } func TestDecideAutopilotCamp_DowngradesWhenSuppliesTight(t *testing.T) { in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, HPCur: 5, HPMax: 20, Supplies: ExpeditionSupplies{Current: 0.6, Max: 5, DailyBurn: 1}, // can't afford 1.0 } d, ok := decideAutopilotCamp(in) if !ok || d.Kind != CampTypeRough { t.Errorf("expected downgrade to rough, got %+v ok=%v", d, ok) } } func TestDecideAutopilotCamp_SkipsBossRoom(t *testing.T) { in := autoCampInputs{ Run: &DungeonRun{ CurrentRoom: 0, RoomsCleared: []int{}, RoomSeq: []RoomType{RoomBoss}, }, HPCur: 1, HPMax: 20, } if _, ok := decideAutopilotCamp(in); ok { t.Errorf("expected no camp in boss room") } } func TestDecideAutopilotCamp_BaseCampWhenRegionCleared(t *testing.T) { in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, Multi: true, RegionCleared: true, BaseSite: true, BaseAlready: false, HPCur: 20, HPMax: 20, // healthy — still pitch base waypoint Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, } d, ok := decideAutopilotCamp(in) if !ok || d.Kind != CampTypeBase { t.Errorf("expected base camp, got %+v ok=%v", d, ok) } } func TestDecideAutopilotCamp_BaseCampSkippedIfAlreadyPersisted(t *testing.T) { in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 0, RoomsCleared: []int{0}}, Multi: true, RegionCleared: true, BaseSite: true, BaseAlready: true, HPCur: 20, HPMax: 20, Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, } if _, ok := decideAutopilotCamp(in); ok { t.Errorf("expected no second base-camp pitch") } } func TestShouldSkipAutoRunForCamp(t *testing.T) { now := time.Now().UTC() stickyPlayerCamp := &Expedition{Camp: &CampState{ Active: true, Type: CampTypeStandard, AutoPitched: false, EstablishedAt: now.Add(-10 * time.Hour), }} if !shouldSkipAutoRunForCamp(stickyPlayerCamp, now) { t.Error("player camp should always block autorun") } freshAuto := &Expedition{Camp: &CampState{ Active: true, Type: CampTypeStandard, AutoPitched: true, EstablishedAt: now.Add(-1 * time.Hour), }} if !shouldSkipAutoRunForCamp(freshAuto, now) { t.Error("auto camp within dwell window should skip") } staleAuto := &Expedition{Camp: &CampState{ Active: true, Type: CampTypeStandard, AutoPitched: true, EstablishedAt: now.Add(-(minAutoCampDwell + time.Minute)), }} if shouldSkipAutoRunForCamp(staleAuto, now) { t.Error("auto camp past dwell should let walk proceed") } none := &Expedition{} if shouldSkipAutoRunForCamp(none, now) { t.Error("no camp should not skip") } } func TestPitchAutopilotCamp_DeductsSuppliesAndRestores(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@autocamp-pitch:example") campTestCharacter(t, uid, 1) // Damage the character so the standard rest can heal them. c, _ := LoadDnDCharacter(uid) c.HPCurrent = 4 _ = SaveDnDCharacter(c) defer cleanupExpeditions(uid) exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) if err != nil { t.Fatal(err) } p := &AdventurePlugin{} block, err := p.pitchAutopilotCamp(exp, autoCampDecision{ Kind: CampTypeStandard, Reason: "test pitch", }, time.Now().UTC()) if err != nil { t.Fatal(err) } if block == "" { t.Error("expected non-empty camp block") } fresh, _ := getActiveExpedition(uid) if fresh.Camp == nil || !fresh.Camp.Active { t.Fatal("expected camp pitched") } if !fresh.Camp.AutoPitched { t.Error("expected AutoPitched flag set") } if !fresh.Camp.RestApplied { t.Error("expected RestApplied flag set") } if fresh.Supplies.Current != 4.0 { t.Errorf("supplies = %v, want 4.0 after standard pitch", fresh.Supplies.Current) } cc, _ := LoadDnDCharacter(uid) if cc.HPCurrent != cc.HPMax { t.Errorf("HP = %d/%d, want full after standard rest", cc.HPCurrent, cc.HPMax) } } func TestBreakAutoCampIfDue_RespectsDwellWindow(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@autocamp-break:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) if err != nil { t.Fatal(err) } now := time.Now().UTC() exp.Camp = &CampState{ Active: true, Type: CampTypeRough, AutoPitched: true, EstablishedAt: now.Add(-1 * time.Hour), } if err := updateCamp(exp.ID, exp.Camp); err != nil { t.Fatal(err) } if breakAutoCampIfDue(exp, now) { t.Error("camp inside dwell window should not be broken") } exp.Camp.EstablishedAt = now.Add(-(minAutoCampDwell + time.Minute)) if err := updateCamp(exp.ID, exp.Camp); err != nil { t.Fatal(err) } if !breakAutoCampIfDue(exp, now) { t.Error("camp past dwell window should be broken") } fresh, _ := getActiveExpedition(uid) if fresh.Camp != nil && fresh.Camp.Active { t.Errorf("expected camp cleared, got %+v", fresh.Camp) } } func TestDecideAutopilotCamp_NightTriggerOnEventAnchored(t *testing.T) { now := time.Now().UTC() stale := now.Add(-(nightCampWindow + time.Hour)) in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, HPCur: 20, HPMax: 20, // healthy — only the night window should pitch Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, Now: now, EventAnchored: true, LastBriefingAt: &stale, } d, ok := decideAutopilotCamp(in) if !ok { t.Fatal("expected night camp pitch") } if !d.Night { t.Errorf("Night = false, want true (end of day)") } if d.Kind != CampTypeStandard { t.Errorf("Kind = %s, want standard (cleared room)", d.Kind) } } func TestDecideAutopilotCamp_NightSkippedOnLegacy(t *testing.T) { now := time.Now().UTC() stale := now.Add(-24 * time.Hour) in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, HPCur: 20, HPMax: 20, Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, Now: now, EventAnchored: false, // legacy — only HP-low triggers LastBriefingAt: &stale, } if _, ok := decideAutopilotCamp(in); ok { t.Error("legacy expedition should not pitch a night camp on time alone") } } func TestDecideAutopilotCamp_NightFlagSetEvenOnHPLow(t *testing.T) { now := time.Now().UTC() stale := now.Add(-(nightCampWindow + time.Hour)) in := autoCampInputs{ Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, HPCur: 5, HPMax: 20, // also low Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, Now: now, EventAnchored: true, LastBriefingAt: &stale, } d, ok := decideAutopilotCamp(in) if !ok || !d.Night { t.Errorf("expected Night pitch even when HP also low, got %+v ok=%v", d, ok) } } func TestPitchAutopilotCamp_NightRunsProcessNightCamp(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@autocamp-night:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) if err != nil { t.Fatal(err) } // Force event-anchored for this test. saved := eventAnchoredCutoff eventAnchoredCutoff = exp.StartDate.Add(-time.Minute) defer func() { eventAnchoredCutoff = saved }() startDay := exp.CurrentDay startSU := exp.Supplies.Current p := &AdventurePlugin{} _, err = p.pitchAutopilotCamp(exp, autoCampDecision{ Kind: CampTypeStandard, Reason: "night-camp test", Night: true, }, time.Now().UTC()) if err != nil { t.Fatal(err) } got, _ := getActiveExpedition(uid) if got.CurrentDay != startDay+1 { t.Errorf("day = %d, want %d (night camp advances day)", got.CurrentDay, startDay+1) } // Night burn (0.5 SU) + camp cost (1.0 SU) = 1.5 SU; 5 - 1.5 = 3.5. wantSU := startSU - 0.5 - campSupplyCost[CampTypeStandard] if got.Supplies.Current != wantSU { t.Errorf("supplies = %v, want %v (night burn + camp cost)", got.Supplies.Current, wantSU) } if got.LastBriefingAt == nil { t.Error("LastBriefingAt should be stamped by night rollover") } } func TestBreakAutoCampIfDue_LeavesPlayerCampAlone(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@autocamp-playercamp:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) if err != nil { t.Fatal(err) } now := time.Now().UTC() exp.Camp = &CampState{ Active: true, Type: CampTypeStandard, AutoPitched: false, EstablishedAt: now.Add(-10 * time.Hour), } if err := updateCamp(exp.ID, exp.Camp); err != nil { t.Fatal(err) } if breakAutoCampIfDue(exp, now) { t.Error("player camp must not be auto-broken") } }