H5: partial spell-slot refresh on short rest

Short rest now restores all L1 slots plus floor(level/4) additional slots
at the next-available tier ≥L2, lowest-first. Long rest still does a full
wipe; martials and full-pool casters see no DM change.
This commit is contained in:
prosolis
2026-05-17 12:45:21 -07:00
parent 736494d945
commit 1cd53eb890
3 changed files with 214 additions and 0 deletions

View File

@@ -146,6 +146,139 @@ func TestShortRest_AlreadyFullHP(t *testing.T) {
}
}
// 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)
}
}
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")