package plugin import ( "testing" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) func campTestCharacter(t *testing.T, uid id.UserID, level int) { t.Helper() if err := createAdvCharacter(uid, "campcmd"); err != nil { t.Fatal(err) } c := &DnDCharacter{ UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level, STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10, HPMax: 20, HPCurrent: 20, ArmorClass: 14, } if err := SaveDnDCharacter(c); err != nil { t.Fatal(err) } } func TestCampCmd_NoExpeditionBlocked(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-noexp:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) if exp != nil { t.Error("no expedition should exist") } } func TestCampCmd_RoughDeducts0_5SU(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-rough:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) if exp.Camp == nil || !exp.Camp.Active { t.Fatal("expected camp pitched") } if exp.Camp.Type != CampTypeRough { t.Errorf("camp type = %q, want rough", exp.Camp.Type) } if exp.Supplies.Current != 4.5 { t.Errorf("supplies = %v, want 4.5 after 0.5 SU rough cost", exp.Supplies.Current) } } func TestCampCmd_StandardDeducts1SU(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-standard:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "standard"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) if exp.Camp == nil || exp.Camp.Type != CampTypeStandard { t.Errorf("camp = %+v, want standard", exp.Camp) } if exp.Supplies.Current != 4.0 { t.Errorf("supplies = %v, want 4.0 after 1.0 SU standard cost", exp.Supplies.Current) } } func TestCampCmd_RejectsDoublePitch(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-double:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil { t.Fatal(err) } if err := p.handleCampCmd(MessageContext{Sender: uid}, "standard"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) if exp.Camp == nil || exp.Camp.Type != CampTypeRough { t.Errorf("camp should still be rough after rejected second pitch, got %+v", exp.Camp) } } func TestCampCmd_BreakClearsCamp(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-break:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil { t.Fatal(err) } if err := p.handleCampCmd(MessageContext{Sender: uid}, "break"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) if exp.Camp != nil { t.Errorf("camp should be cleared, got %+v", exp.Camp) } } func TestCampCmd_RejectsFortifiedAndBase(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-locked:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "fortified"); err != nil { t.Fatal(err) } if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) if exp.Camp != nil { t.Errorf("camp should not be pitched for locked types, got %+v", exp.Camp) } } func TestCampCmd_BaseRejectsNonBaseCampSiteRegion(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-base-bad-site:example") campTestCharacter(t, uid, 12) defer cleanupExpeditions(uid) exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) if err != nil { t.Fatal(err) } // Surface Tunnels has BaseCampSite=false. Even if we mark it // cleared, !camp base should reject. if _, err := MarkRegionBossDefeated(exp, "underdark_surface_tunnels"); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil { t.Fatal(err) } loaded, _ := getActiveExpedition(uid) if loaded.Camp != nil { t.Error("base camp should not pitch in non-base-camp-site region") } } func TestCampCmd_BaseRejectsUnclearedRegion(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-base-uncleared:example") campTestCharacter(t, uid, 12) defer cleanupExpeditions(uid) exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) if err != nil { t.Fatal(err) } // Move to Drow Outpost (BaseCampSite=true) but don't clear it. if err := setCurrentRegion(exp, "underdark_drow_outpost"); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil { t.Fatal(err) } loaded, _ := getActiveExpedition(uid) if loaded.Camp != nil { t.Error("base camp should not pitch before region boss is cleared") } } func TestCampCmd_BasePitchPersistsWaypoint(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-base-pitch:example") campTestCharacter(t, uid, 12) defer cleanupExpeditions(uid) exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) if err != nil { t.Fatal(err) } if err := setCurrentRegion(exp, "underdark_drow_outpost"); err != nil { t.Fatal(err) } if _, err := MarkRegionBossDefeated(exp, "underdark_drow_outpost"); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil { t.Fatal(err) } loaded, _ := getActiveExpedition(uid) if loaded.Camp == nil || loaded.Camp.Type != CampTypeBase { t.Fatalf("expected base camp pitched; got %+v", loaded.Camp) } if loaded.Supplies.Current != 7 { // 10 - 3 SU base cost t.Errorf("supplies after base pitch = %v, want 7", loaded.Supplies.Current) } if !HasBaseCampAt(loaded, "underdark_drow_outpost") { t.Error("base camp waypoint not persisted to RegionState") } } func TestCampCmd_InsufficientSuppliesRejected(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-broke:example") campTestCharacter(t, uid, 1) defer cleanupExpeditions(uid) if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Current: 0.3, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil { t.Fatal(err) } p := &AdventurePlugin{} if err := p.handleCampCmd(MessageContext{Sender: uid}, "rough"); err != nil { t.Fatal(err) } exp, _ := getActiveExpedition(uid) if exp.Camp != nil { t.Errorf("camp should not be pitched with 0.3 SU vs 0.5 cost, got %+v", exp.Camp) } } func TestCampLocationCheck_BossRoomBlocks(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@camp-boss:example") defer cleanupZoneRuns(uid) defer cleanupExpeditions(uid) // Pre-create a zone run, then warp current_node to the graph's boss. run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) if err != nil { t.Fatal(err) } g, ok := loadZoneGraph(run.ZoneID) if !ok { t.Fatalf("no graph for zone %q", run.ZoneID) } bossNodeID := "" for nid, n := range g.Nodes { if n.IsBoss { bossNodeID = nid break } } if bossNodeID == "" { t.Fatal("no boss node in graph") } exp := &Expedition{RunID: run.RunID} if _, err := db.Get().Exec(`UPDATE dnd_zone_run SET current_node = ? WHERE run_id = ?`, bossNodeID, run.RunID); err != nil { t.Fatal(err) } cleared, problem := campLocationCheck(exp) if problem == "" { t.Error("expected boss-room rejection") } if cleared { t.Error("boss room should not read as cleared") } }