diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index 14f0c8a..d57f324 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -81,24 +81,27 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { return p.SendDM(ctx.Sender, "You're out of short rest charges. Take a `!rest long` to restore them.") } - if c.HPCurrent >= c.HPMax { - return p.SendDM(ctx.Sender, "You're already at full HP. Save the charge for when you need it.") - } - - conMod := abilityModifier(c.CON) - healDie := 1 + rand.IntN(6) // 1d6 - heal := healDie + conMod - if heal < 1 { - heal = 1 - } - if c.Level >= 5 { - heal *= 2 // v1.0 ยง10.1: "x2 at levels 5+" + hpFull := c.HPCurrent >= c.HPMax + hasRefreshableSlots, _ := casterHasUsedSlots(ctx.Sender) + if hpFull && !hasRefreshableSlots { + return p.SendDM(ctx.Sender, "You're at full HP and your slots are full. Save the charge for when you need it.") } before := c.HPCurrent - c.HPCurrent += heal - if c.HPCurrent > c.HPMax { - c.HPCurrent = c.HPMax + if !hpFull { + conMod := abilityModifier(c.CON) + healDie := 1 + rand.IntN(6) // 1d6 + heal := healDie + conMod + if heal < 1 { + heal = 1 + } + if c.Level >= 5 { + heal *= 2 // v1.0 ยง10.1: "x2 at levels 5+" + } + c.HPCurrent += heal + if c.HPCurrent > c.HPMax { + c.HPCurrent = c.HPMax + } } c.ShortRestCharges-- now := time.Now().UTC() @@ -112,9 +115,16 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) } - msg := fmt.Sprintf( - "๐Ÿ›Œ **Short rest.** You recover **%d HP** (%dโ†’%d / %d).\n_Charges remaining: %d. You're resting โ€” `!zone` and `!expedition` locked for 1 hour._", - c.HPCurrent-before, before, c.HPCurrent, c.HPMax, c.ShortRestCharges) + var msg string + if c.HPCurrent > before { + msg = fmt.Sprintf( + "๐Ÿ›Œ **Short rest.** You recover **%d HP** (%dโ†’%d / %d).\n_Charges remaining: %d. You're resting โ€” `!zone` and `!expedition` locked for 1 hour._", + c.HPCurrent-before, before, c.HPCurrent, c.HPMax, c.ShortRestCharges) + } else { + msg = fmt.Sprintf( + "๐Ÿ›Œ **Short rest.** You catch your breath โ€” HP already full (%d/%d).\n_Charges remaining: %d. You're resting โ€” `!zone` and `!expedition` locked for 1 hour._", + c.HPCurrent, c.HPMax, c.ShortRestCharges) + } if slotLine := dndShortRestSlotLine(slotsRestored); slotLine != "" { msg += "\n_" + slotLine + "_" } diff --git a/internal/plugin/dnd_rest_test.go b/internal/plugin/dnd_rest_test.go index 97e8bca..8913fd5 100644 --- a/internal/plugin/dnd_rest_test.go +++ b/internal/plugin/dnd_rest_test.go @@ -265,6 +265,65 @@ func TestShortRest_RefreshesPartialSlotsForMage(t *testing.T) { } } +// 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) diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index f2ced06..ec5c387 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -543,6 +543,18 @@ func refundSpellSlot(userID id.UserID, slotLevel int) error { return err } +// casterHasUsedSlots reports whether any spell slot for userID has used>0. +// Used by the short-rest gate so casters at full HP can still rest to +// recover slots (partialRefreshSpellSlots is otherwise unreachable for +// them). +func casterHasUsedSlots(userID id.UserID) (bool, error) { + var n int + err := db.Get().QueryRow( + `SELECT COUNT(*) FROM dnd_spell_slots WHERE user_id = ? AND used > 0`, + string(userID)).Scan(&n) + return n > 0, err +} + // refreshSpellSlots resets used=0 across all of a player's slots. Called // on long rest. func refreshSpellSlots(userID id.UserID) error {