Files
gogobee/internal/plugin/dnd_expedition_camp_test.go
prosolis a5c0a83e2a Branching zones G9b: stop persisting current_room / room_seq_json
The two legacy columns are no longer needed at runtime. CurrentRoom is
derived from len(VisitedNodes)-1 on read (lockstep with what the
dual-write era stored), and RoomSeq is a transient in-memory field
populated only when a fresh run is started.

- INSERT, SELECT, and UPDATE statements drop the two columns. The
  schema still carries them — they retire in G9c.
- scanZoneRun derives CurrentRoom and falls back to a linear-derived
  CurrentNode only if a row predates the G4 dual-write deploy.
- markRoomCleared rewritten to walk the registered graph (first
  outgoing edge / dead-end completion). Tests that use it as an
  end-to-end run driver continue to pass.
- advanceZoneRunNode drops its current_room dual-write.
- adventure_activity_unified reads visited_nodes for the daily
  "X/N rooms" progress fragment.
- Camp boss-room test pivots to setting current_node directly.

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

301 lines
8.6 KiB
Go

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")
}
}