mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
`--target @user` has been advertised by !help and swallowed by the parser since SP2 — "reserved for SP3, accept and ignore". §1 wired ally heals inside a fight; out of combat the cleric still could not put a hit point on anybody but himself, which is most of where a party is actually hurt: between the rooms, not in them. The target set is the expedition, not the world. In a fight splitCastTarget resolves against the people in the fight; the standing-around equivalent is the people you are travelling with, so both `!cast` paths answer the same question. Only a heal may name somebody else. UTILITY resolves on the caster and everything else queues as a PendingCast for the *caster's* next fight, where an ally target has nothing to mean — so a target on those is refused outright rather than silently dropped, which is exactly what the old parse did. The ally's row is mutated with one guarded UPDATE inside a transaction, not a read-modify-write under a second lock (gifting sets the precedent). Two clerics healing each other at the same instant would otherwise take their advUserLocks in opposite orders and deadlock the pair of them. The max-HP clamp lives in the SQL for the same reason. Refunds the slot on every path that heals nobody — full-HP ally, downed ally, no sheet, stranger. A slot spent for zero HP is the kind of thing players do not forgive. All four are pinned end-to-end through the real handler. A heal is still not a resurrection: it will not raise the dead, same rule the combat path holds. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
140 lines
4.5 KiB
Go
140 lines
4.5 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// The whole `!cast cure wounds @friend` path, out of combat, through the real
|
|
// handler: parse → class/known/prepared gates → slot debit → the ally's sheet.
|
|
//
|
|
// The seams are unit-tested next door; this exists for the one thing they cannot
|
|
// see — that the slot ledger and the HP write agree about whether the heal
|
|
// happened. A refund that does not fire is a slot a player paid for nothing, and
|
|
// a refund that fires twice is a free heal.
|
|
|
|
// castingCleric turns an existing sheet into a cleric who knows and has prepared
|
|
// Cure Wounds, with slots to spend.
|
|
func castingCleric(t *testing.T, uid id.UserID) {
|
|
t.Helper()
|
|
c, err := LoadDnDCharacter(uid)
|
|
if err != nil || c == nil {
|
|
t.Fatalf("load %s: %v", uid, err)
|
|
}
|
|
c.Class = ClassCleric
|
|
c.Level = 5
|
|
c.WIS = 16
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setSpellSlotsForCharacter(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := setSpellPrepared(uid, "cure_wounds", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func slotsUsed(t *testing.T, uid id.UserID, level int) int {
|
|
t.Helper()
|
|
slots, err := getSpellSlots(uid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return slots[level][1]
|
|
}
|
|
|
|
func setCharHP(t *testing.T, uid id.UserID, hp int) {
|
|
t.Helper()
|
|
c, err := LoadDnDCharacter(uid)
|
|
if err != nil || c == nil {
|
|
t.Fatalf("load %s: %v", uid, err)
|
|
}
|
|
c.HPCurrent = hp
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestCastAlly_OutOfCombat_HealsTheFriendAndSpendsOneSlot(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader, member := seatedMember(t, "e2eheal")
|
|
castingCleric(t, leader)
|
|
setCharHP(t, member, 5) // of 20
|
|
|
|
p := &AdventurePlugin{}
|
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
|
t.Fatalf("cast: %v", err)
|
|
}
|
|
|
|
mc, _ := LoadDnDCharacter(member)
|
|
if mc.HPCurrent <= 5 {
|
|
t.Fatalf("the friend is still on %d HP; the heal landed on nobody", mc.HPCurrent)
|
|
}
|
|
// The caster must NOT have healed themselves — the bug this whole section exists for.
|
|
if lc, _ := LoadDnDCharacter(leader); lc.HPCurrent != lc.HPMax {
|
|
t.Fatalf("caster HP moved to %d/%d; the heal landed on the caster", lc.HPCurrent, lc.HPMax)
|
|
}
|
|
if used := slotsUsed(t, leader, 1); used != 1 {
|
|
t.Fatalf("level-1 slots used = %d, want exactly 1", used)
|
|
}
|
|
}
|
|
|
|
// Naming a full-HP ally must cost nothing: the slot is debited before the target
|
|
// is touched, so this is the refund path firing for real.
|
|
func TestCastAlly_OutOfCombat_FullHPAllyRefundsTheSlot(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader, member := seatedMember(t, "e2efull")
|
|
castingCleric(t, leader)
|
|
|
|
p := &AdventurePlugin{}
|
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @"+member.Localpart()); err != nil {
|
|
t.Fatalf("cast: %v", err)
|
|
}
|
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
|
t.Fatalf("level-1 slots used = %d after healing a full-HP ally; the slot was not refunded", used)
|
|
}
|
|
}
|
|
|
|
// A target nobody can reach is refused before anything is spent.
|
|
func TestCastAlly_OutOfCombat_StrangerCostsNothing(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader, _ := seatedMember(t, "e2estranger")
|
|
castingCleric(t, leader)
|
|
|
|
p := &AdventurePlugin{}
|
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "cure wounds @nobody"); err != nil {
|
|
t.Fatalf("cast: %v", err)
|
|
}
|
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
|
t.Fatalf("level-1 slots used = %d after naming a stranger; nothing should have been spent", used)
|
|
}
|
|
}
|
|
|
|
// A target on a spell that cannot use one is refused, rather than quietly
|
|
// dropping the target and firing the spell at the caster — which is what the old
|
|
// "accept and ignore" parse did.
|
|
func TestCastAlly_OutOfCombat_TargetOnANonHealIsRefused(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader, member := seatedMember(t, "e2enonheal")
|
|
castingCleric(t, leader)
|
|
if err := addKnownSpell(leader, "guiding_bolt", "class", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
p := &AdventurePlugin{}
|
|
if err := p.handleDnDCastCmd(MessageContext{Sender: leader}, "guiding bolt @"+member.Localpart()); err != nil {
|
|
t.Fatalf("cast: %v", err)
|
|
}
|
|
if used := slotsUsed(t, leader, 1); used != 0 {
|
|
t.Fatalf("level-1 slots used = %d; a targeted non-heal must be refused before it is paid for", used)
|
|
}
|
|
if lc, _ := LoadDnDCharacter(leader); lc.PendingCast != "" {
|
|
t.Fatalf("guiding bolt queued as %q; the target should have refused the cast outright", lc.PendingCast)
|
|
}
|
|
}
|