Files
gogobee/internal/plugin/dnd_rest_test.go
prosolis dd25de71f0 H5 follow-up: short-rest at full HP can still refresh slots
handleDnDShortRest early-returned when HPCurrent >= HPMax, which dates
from when short rest was HP-only. After H5 (1cd53eb) added the partial
slot refresh, that gate left casters who hadn't been hit unable to
recover slots between fights — the most common slot-attrition scenario.

Loosen the gate: short rest is allowed when HP can heal OR any spell
slot has used > 0. New helper casterHasUsedSlots gates the slot branch.
Martial behavior unchanged (still bails at full HP). Two new tests.
2026-05-17 16:06:46 -07:00

408 lines
12 KiB
Go

package plugin
import (
"io"
"os"
"path/filepath"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func setupRestTestDB(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 makeRestTestChar(t *testing.T, uid id.UserID, level int) *DnDCharacter {
t.Helper()
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12,
}
conMod := abilityModifier(c.CON)
c.HPMax = computeMaxHP(c.Class, conMod, level)
c.HPCurrent = 1 // wounded
c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX))
c.ShortRestCharges = level // charges = level (matches autoBuildCharacter)
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
// Also need an adventure_characters row so loadAdvCharacter doesn't fail.
if err := createAdvCharacter(uid, "rest_test"); err != nil {
t.Fatal(err)
}
return c
}
func TestShortRest_HealsWithinExpectedRange(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@short_rest:example")
c := makeRestTestChar(t, uid, 3) // L3 → 1d6+CON, no x2
p := &AdventurePlugin{}
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, err := LoadDnDCharacter(uid)
if err != nil || got == nil {
t.Fatal(err)
}
conMod := abilityModifier(c.CON) // +2
// Heal range L1-4: 1d6 + 2 = 3..8
healed := got.HPCurrent - 1
if healed < 1+conMod || healed > 6+conMod {
t.Errorf("healed %d HP, want %d..%d (1d6+%d)", healed, 1+conMod, 6+conMod, conMod)
}
if got.LastShortRestAt == nil {
t.Error("LastShortRestAt not set")
}
}
func TestShortRest_DoublesAtL5(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@short_rest_l5:example")
c := makeRestTestChar(t, uid, 5) // L5 → 2*(1d6+CON)
conMod := abilityModifier(c.CON)
p := &AdventurePlugin{}
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
healed := got.HPCurrent - 1
// Range: 2*(1+2)..2*(6+2) = 6..16
low, high := 2*(1+conMod), 2*(6+conMod)
if healed < low || healed > high {
t.Errorf("L5 healed %d HP, want %d..%d", healed, low, high)
}
}
func TestShortRest_ChargesEnforced(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@short_cd:example")
c := makeRestTestChar(t, uid, 3)
// Drain to last charge so the second rest hits the empty branch.
c.ShortRestCharges = 1
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
// First rest succeeds and burns the last charge.
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got1, _ := LoadDnDCharacter(uid)
if got1.ShortRestCharges != 0 {
t.Errorf("charge not consumed: %d remaining", got1.ShortRestCharges)
}
hpAfterFirst := got1.HPCurrent
// Second rest should bail (no charges) — HP unchanged.
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got2, _ := LoadDnDCharacter(uid)
if got2.HPCurrent != hpAfterFirst {
t.Errorf("charges not enforced: HP changed from %d → %d on second rest",
hpAfterFirst, got2.HPCurrent)
}
}
func TestShortRest_AlreadyFullHP(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@short_full:example")
c := makeRestTestChar(t, uid, 3)
c.HPCurrent = c.HPMax
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
if got.LastShortRestAt != nil {
t.Error("rest at full HP shouldn't consume cooldown")
}
}
// makeRestTestMage builds a wounded mage (so short rest doesn't bail on
// full-HP) and provisions the class slot pool. The caller is responsible
// for burning slots before testing the refresh.
func makeRestTestMage(t *testing.T, uid id.UserID, level int) *DnDCharacter {
t.Helper()
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassMage, Level: level,
STR: 8, DEX: 13, CON: 12, INT: 16, WIS: 10, CHA: 12,
}
conMod := abilityModifier(c.CON)
c.HPMax = computeMaxHP(c.Class, conMod, level)
c.HPCurrent = 1
c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX))
c.ShortRestCharges = level
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if err := createAdvCharacter(uid, "mage_rest_test"); err != nil {
t.Fatal(err)
}
if err := setSpellSlotsForLevel(uid, ClassMage, level); err != nil {
t.Fatal(err)
}
return c
}
func TestPartialRefreshSpellSlots_L5MageToPsAtL2(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@partial_l5:example")
makeRestTestMage(t, uid, 5) // L5 mage → 4 L1, 3 L2, 2 L3
// Burn everything so the refresh has work to do.
pool := slotsForClassLevel(ClassMage, 5)
for lvl, total := range pool {
for i := 0; i < total; i++ {
if _, err := consumeSpellSlot(uid, lvl); err != nil {
t.Fatal(err)
}
}
}
restored, err := partialRefreshSpellSlots(uid, 5)
if err != nil {
t.Fatal(err)
}
if got := restored[1]; got != pool[1] {
t.Errorf("L1 restored = %d, want %d (all)", got, pool[1])
}
if got := restored[2]; got != 1 {
t.Errorf("L2 restored = %d, want 1 (floor(5/4))", got)
}
if _, ok := restored[3]; ok {
t.Errorf("L3 should not have been restored: %v", restored)
}
slots, _ := getSpellSlots(uid)
if used := slots[1][1]; used != 0 {
t.Errorf("L1 used after refresh = %d, want 0", used)
}
if used := slots[2][1]; used != pool[2]-1 {
t.Errorf("L2 used after refresh = %d, want %d", used, pool[2]-1)
}
if used := slots[3][1]; used != pool[3] {
t.Errorf("L3 used after refresh = %d, want %d (untouched)", used, pool[3])
}
}
func TestPartialRefreshSpellSlots_NonCasterNoop(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@partial_fighter:example")
makeRestTestChar(t, uid, 5) // fighter — no slots
restored, err := partialRefreshSpellSlots(uid, 5)
if err != nil {
t.Fatal(err)
}
if restored != nil {
t.Errorf("fighter got slots restored: %v", restored)
}
}
func TestPartialRefreshSpellSlots_FullCasterReturnsEmpty(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@partial_full:example")
makeRestTestMage(t, uid, 5) // slots already at used=0 from setup
restored, err := partialRefreshSpellSlots(uid, 5)
if err != nil {
t.Fatal(err)
}
if restored != nil {
t.Errorf("full mage got slots restored: %v", restored)
}
}
func TestShortRest_RefreshesPartialSlotsForMage(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@short_mage:example")
makeRestTestMage(t, uid, 5)
pool := slotsForClassLevel(ClassMage, 5)
for lvl, total := range pool {
for i := 0; i < total; i++ {
consumeSpellSlot(uid, lvl)
}
}
p := &AdventurePlugin{}
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
slots, _ := getSpellSlots(uid)
if used := slots[1][1]; used != 0 {
t.Errorf("L1 used after short rest = %d, want 0", used)
}
if used := slots[2][1]; used != pool[2]-1 {
t.Errorf("L2 used after short rest = %d, want %d", used, pool[2]-1)
}
}
// TestShortRest_FullHPCasterCanRefreshSlots covers the H5 follow-up: a
// caster at full HP with spent slots must still be allowed to short rest
// to recover them. Before the fix the HP-full guard short-circuited and
// the slot refresh was unreachable.
func TestShortRest_FullHPCasterCanRefreshSlots(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@short_full_hp_mage:example")
c := makeRestTestMage(t, uid, 5)
c.HPCurrent = c.HPMax
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
pool := slotsForClassLevel(ClassMage, 5)
for lvl, total := range pool {
for i := 0; i < total; i++ {
consumeSpellSlot(uid, lvl)
}
}
p := &AdventurePlugin{}
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != got.HPMax {
t.Errorf("HP changed despite full-HP entry: %d/%d", got.HPCurrent, got.HPMax)
}
if got.ShortRestCharges != c.ShortRestCharges-1 {
t.Errorf("charge not spent: %d → %d", c.ShortRestCharges, got.ShortRestCharges)
}
slots, _ := getSpellSlots(uid)
if used := slots[1][1]; used != 0 {
t.Errorf("L1 used after short rest = %d, want 0", used)
}
}
// TestShortRest_FullHPMartialBails confirms a non-caster at full HP is
// still told to save the charge — the relaxed gate only opens for casters
// who actually have refreshable slots.
func TestShortRest_FullHPMartialBails(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@short_full_hp_fighter:example")
makeRestTestChar(t, uid, 5)
c, _ := LoadDnDCharacter(uid)
c.HPCurrent = c.HPMax
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
if got.ShortRestCharges != c.ShortRestCharges {
t.Errorf("charge spent on no-op short rest: %d → %d", c.ShortRestCharges, got.ShortRestCharges)
}
}
func TestDndShortRestSlotLine(t *testing.T) {
if got := dndShortRestSlotLine(nil); got != "" {
t.Errorf("nil → %q, want empty", got)
}
if got := dndShortRestSlotLine(map[int]int{}); got != "" {
t.Errorf("empty → %q, want empty", got)
}
got := dndShortRestSlotLine(map[int]int{1: 4, 2: 1})
want := "Spell slots restored: 4 (L1), 1 (L2)."
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestLongRest_RequiresHousingOrInn(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@long_no_house:example")
makeRestTestChar(t, uid, 3)
// AdventureCharacter has HouseTier=0; player has no euros either by default.
p := &AdventurePlugin{euro: nil} // no euro plugin → can't pay inn
if err := p.handleDnDLongRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent == got.HPMax {
t.Error("long rest succeeded without housing or inn payment")
}
if got.LastLongRestAt != nil {
t.Error("LastLongRestAt set despite failed rest")
}
}
func TestLongRest_WithHousing(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@long_house:example")
makeRestTestChar(t, uid, 3)
// Upgrade to housing.
advChar, _ := loadAdvCharacter(uid)
advChar.HouseTier = 2
if err := saveAdvCharacter(advChar); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleDnDLongRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != got.HPMax {
t.Errorf("long rest with housing didn't fully heal: %d/%d", got.HPCurrent, got.HPMax)
}
if got.LastLongRestAt == nil {
t.Error("LastLongRestAt not set after successful long rest")
}
}
func TestLongRest_CooldownEnforced(t *testing.T) {
setupRestTestDB(t)
uid := id.UserID("@long_cd:example")
c := makeRestTestChar(t, uid, 3)
advChar, _ := loadAdvCharacter(uid)
advChar.HouseTier = 1
saveAdvCharacter(advChar)
now := time.Now().UTC().Add(-1 * time.Hour) // recent rest
c.LastLongRestAt = &now
c.HPCurrent = 1
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleDnDLongRest(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != 1 {
t.Errorf("long rest cooldown not enforced; HP went from 1 → %d", got.HPCurrent)
}
}