mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Adv 2.0 D&D Phase 11 D1b: DungeonRun state machine + dnd_zone_run schema
dnd_zone_run table (run_id, zone_id, room_seq_json, rooms_cleared, boss_defeated, abandoned, loot_collected, gm_mood, started_at, last_action_at, completed_at) added to db.go schema with active-run index. dnd_zone_run.go ships RoomType (entry|exploration|trap|elite|boss), DungeonRun struct, generateRoomSequence (Entry → ExplorationxN1 → Trap → ExplorationxN2 → Elite → Boss within zone's [MinRooms, MaxRooms]), and persistence helpers: startZoneRun (gates on level tier + active-run exclusivity), getActiveZoneRun, getZoneRun, markRoomCleared (advances and auto-completes on boss kill), abandonZoneRun, adjustGMMood (clamped to [0,100]), addLoot. 8 new tests covering room-sequence shape (entry first, boss last, exactly one trap/elite, ≥2 explorations), happy-path start, concurrent-run rejection, unknown-zone rejection, full advance-to-boss flow with completion, abandon idempotency, GM mood clamping at both bounds, and loot accumulation order. Full repo test suite green. Boss-room behavior, !zone command surface, and TwinBee narration arrive in D1c/D1d. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
242
internal/plugin/dnd_zone_run_test.go
Normal file
242
internal/plugin/dnd_zone_run_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func setupZoneRunTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Skip("prod db not present")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dst := filepath.Join(dir, "gogobee.db")
|
||||
in, _ := os.Open(src)
|
||||
defer in.Close()
|
||||
out, _ := os.Create(dst)
|
||||
defer out.Close()
|
||||
io.Copy(out, in)
|
||||
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestGenerateRoomSequence_StartsEntryEndsBoss(t *testing.T) {
|
||||
zone := zoneGoblinWarrens()
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
seq := generateRoomSequence(zone, rng)
|
||||
if len(seq) < zone.MinRooms || len(seq) > zone.MaxRooms {
|
||||
t.Fatalf("seq len %d outside [%d,%d]", len(seq), zone.MinRooms, zone.MaxRooms)
|
||||
}
|
||||
if seq[0] != RoomEntry {
|
||||
t.Errorf("first room = %s, want entry", seq[0])
|
||||
}
|
||||
if seq[len(seq)-1] != RoomBoss {
|
||||
t.Errorf("last room = %s, want boss", seq[len(seq)-1])
|
||||
}
|
||||
// Exactly one of each special type:
|
||||
count := map[RoomType]int{}
|
||||
for _, r := range seq {
|
||||
count[r]++
|
||||
}
|
||||
if count[RoomEntry] != 1 {
|
||||
t.Errorf("entry count = %d", count[RoomEntry])
|
||||
}
|
||||
if count[RoomTrap] != 1 {
|
||||
t.Errorf("trap count = %d", count[RoomTrap])
|
||||
}
|
||||
if count[RoomElite] != 1 {
|
||||
t.Errorf("elite count = %d", count[RoomElite])
|
||||
}
|
||||
if count[RoomBoss] != 1 {
|
||||
t.Errorf("boss count = %d", count[RoomBoss])
|
||||
}
|
||||
if count[RoomExploration] < 2 {
|
||||
t.Errorf("exploration count = %d, want ≥ 2", count[RoomExploration])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartZoneRun_HappyPath(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@zone-test:example.org")
|
||||
defer cleanupZoneRuns(uid)
|
||||
|
||||
rng := rand.New(rand.NewPCG(42, 7))
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
if run.ZoneID != ZoneGoblinWarrens {
|
||||
t.Errorf("zone id = %s", run.ZoneID)
|
||||
}
|
||||
if run.CurrentRoom != 0 {
|
||||
t.Errorf("current room = %d", run.CurrentRoom)
|
||||
}
|
||||
if run.GMMood != 50 {
|
||||
t.Errorf("gm mood = %d", run.GMMood)
|
||||
}
|
||||
if !run.IsActive() {
|
||||
t.Error("expected run active")
|
||||
}
|
||||
if run.CurrentRoomType() != RoomEntry {
|
||||
t.Errorf("first room type = %s", run.CurrentRoomType())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartZoneRun_RejectsConcurrent(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@zone-concur:example.org")
|
||||
defer cleanupZoneRuns(uid)
|
||||
|
||||
if _, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := startZoneRun(uid, ZoneCryptValdris, 1, nil); err != ErrRunAlreadyActive {
|
||||
t.Errorf("err = %v, want ErrRunAlreadyActive", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartZoneRun_RejectsTierLocked(t *testing.T) {
|
||||
// L1 player can reach tier 3 max. Crypt is T1 — fine. But if we
|
||||
// register a T5 in future, this test should still work — for now,
|
||||
// fake the gate by trying with negative level.
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@zone-tier:example.org")
|
||||
defer cleanupZoneRuns(uid)
|
||||
|
||||
// All current zones are T1 so any positive level passes. Test
|
||||
// the unknown-zone path instead, which is the other guard.
|
||||
if _, err := startZoneRun(uid, ZoneID("nonexistent"), 1, nil); err != ErrUnknownZone {
|
||||
t.Errorf("err = %v, want ErrUnknownZone", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRunFlow_AdvanceToBossAndComplete(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@zone-flow:example.org")
|
||||
defer cleanupZoneRuns(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 2, rand.New(rand.NewPCG(99, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
steps := 0
|
||||
for {
|
||||
next, err := markRoomCleared(run.RunID)
|
||||
if err != nil {
|
||||
t.Fatalf("markRoomCleared step %d: %v", steps, err)
|
||||
}
|
||||
steps++
|
||||
if steps > 20 {
|
||||
t.Fatal("too many steps")
|
||||
}
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
got, err := getZoneRun(run.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.BossDefeated {
|
||||
t.Error("expected boss defeated after final room")
|
||||
}
|
||||
if got.CompletedAt == nil {
|
||||
t.Error("expected CompletedAt set")
|
||||
}
|
||||
if got.IsActive() {
|
||||
t.Error("expected run inactive")
|
||||
}
|
||||
if len(got.RoomsCleared) != got.TotalRooms {
|
||||
t.Errorf("rooms cleared %d, total %d", len(got.RoomsCleared), got.TotalRooms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbandonZoneRun(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@zone-abandon:example.org")
|
||||
defer cleanupZoneRuns(uid)
|
||||
|
||||
if _, err := startZoneRun(uid, ZoneCryptValdris, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := abandonZoneRun(uid); err != nil {
|
||||
t.Fatalf("abandon: %v", err)
|
||||
}
|
||||
active, err := getActiveZoneRun(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active != nil {
|
||||
t.Error("expected no active run after abandon")
|
||||
}
|
||||
// Second abandon should ErrNoActiveRun.
|
||||
if err := abandonZoneRun(uid); err != ErrNoActiveRun {
|
||||
t.Errorf("second abandon err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustGMMoodClampsBounds(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@zone-mood:example.org")
|
||||
defer cleanupZoneRuns(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Push above 100 then below 0.
|
||||
if err := adjustGMMood(run.RunID, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := getZoneRun(run.RunID)
|
||||
if r.GMMood != 100 {
|
||||
t.Errorf("upper clamp: mood = %d", r.GMMood)
|
||||
}
|
||||
if err := adjustGMMood(run.RunID, -250); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ = getZoneRun(run.RunID)
|
||||
if r.GMMood != 0 {
|
||||
t.Errorf("lower clamp: mood = %d", r.GMMood)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddLootAccumulates(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@zone-loot:example.org")
|
||||
defer cleanupZoneRuns(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addLoot(run.RunID, "wpn_handaxe_+1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addLoot(run.RunID, "coins_2d10x5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := getZoneRun(run.RunID)
|
||||
if len(got.LootCollected) != 2 {
|
||||
t.Fatalf("loot len %d", len(got.LootCollected))
|
||||
}
|
||||
if got.LootCollected[0] != "wpn_handaxe_+1" || got.LootCollected[1] != "coins_2d10x5" {
|
||||
t.Errorf("loot order: %v", got.LootCollected)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupZoneRuns(uid id.UserID) {
|
||||
_, _ = db.Get().Exec(`DELETE FROM dnd_zone_run WHERE user_id = ?`, string(uid))
|
||||
}
|
||||
Reference in New Issue
Block a user