mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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.
This commit is contained in:
@@ -81,24 +81,27 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender,
|
return p.SendDM(ctx.Sender,
|
||||||
"You're out of short rest charges. Take a `!rest long` to restore them.")
|
"You're out of short rest charges. Take a `!rest long` to restore them.")
|
||||||
}
|
}
|
||||||
if c.HPCurrent >= c.HPMax {
|
hpFull := c.HPCurrent >= c.HPMax
|
||||||
return p.SendDM(ctx.Sender, "You're already at full HP. Save the charge for when you need it.")
|
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.")
|
||||||
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+"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
before := c.HPCurrent
|
before := c.HPCurrent
|
||||||
c.HPCurrent += heal
|
if !hpFull {
|
||||||
if c.HPCurrent > c.HPMax {
|
conMod := abilityModifier(c.CON)
|
||||||
c.HPCurrent = c.HPMax
|
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--
|
c.ShortRestCharges--
|
||||||
now := time.Now().UTC()
|
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())
|
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
var msg string
|
||||||
"🛌 **Short rest.** You recover **%d HP** (%d→%d / %d).\n_Charges remaining: %d. You're resting — `!zone` and `!expedition` locked for 1 hour._",
|
if c.HPCurrent > before {
|
||||||
c.HPCurrent-before, before, c.HPCurrent, c.HPMax, c.ShortRestCharges)
|
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 != "" {
|
if slotLine := dndShortRestSlotLine(slotsRestored); slotLine != "" {
|
||||||
msg += "\n_" + slotLine + "_"
|
msg += "\n_" + slotLine + "_"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestDndShortRestSlotLine(t *testing.T) {
|
||||||
if got := dndShortRestSlotLine(nil); got != "" {
|
if got := dndShortRestSlotLine(nil); got != "" {
|
||||||
t.Errorf("nil → %q, want empty", got)
|
t.Errorf("nil → %q, want empty", got)
|
||||||
|
|||||||
@@ -543,6 +543,18 @@ func refundSpellSlot(userID id.UserID, slotLevel int) error {
|
|||||||
return err
|
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
|
// refreshSpellSlots resets used=0 across all of a player's slots. Called
|
||||||
// on long rest.
|
// on long rest.
|
||||||
func refreshSpellSlots(userID id.UserID) error {
|
func refreshSpellSlots(userID id.UserID) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user