Files
gogobee/internal/plugin/dnd_expedition_camp_test.go
prosolis 0a3417a341 Adv 2.0 D&D Phase 12 E1e: !camp rough/standard + placement guards
Wires the basic camp surface per gogobee_expedition_system.md §5. Two
camp types ship: rough (any location, +0.5 SU, partial rest) and
standard (cleared room, +1 SU, full long rest). Fortified and base
camps remain explicitly rejected at the surface — those wire up in E2
and E4 respectively, so the command tree is forward-compatible.

Placement validation per §5.2: boss rooms and trap rooms reject; non-
cleared rooms downgrade an intended standard camp to rough. Active-
enemy detection is deferred to E2 (it needs the combat-state hooks
that don't exist on the expedition path yet). Until !advance is wired
into the expedition layer, the player is treated as standing at the
entry room (always cleared), so today's validation effectively only
fires once room context catches up.

Camp-pitch consumes SU immediately and uses flavor.CampEstablished for
the narrative line. !camp break struck and recorded. !camp with no
arg shows help + current camp state.

Tests cover deduction amounts, double-pitch rejection, break, locked-
type rejection (fortified/base), insufficient-supply rejection, and
boss-room placement guard via a synthetic zone run.

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

213 lines
6.0 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_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 advance current_room to the boss room.
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatal(err)
}
// Walk to the boss room directly (last index).
bossIdx := -1
for i, rt := range run.RoomSeq {
if rt == RoomBoss {
bossIdx = i
}
}
if bossIdx < 0 {
t.Fatal("no boss room in seq")
}
exp := &Expedition{RunID: run.RunID}
// Force current_room to bossIdx.
if _, err := db.Get().Exec(`UPDATE dnd_zone_run SET current_room = ? WHERE run_id = ?`, bossIdx, 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")
}
}