mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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
103 lines
3.9 KiB
Go
103 lines
3.9 KiB
Go
package plugin
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// The out-of-combat half of §1. `--target` was parsed and thrown away here since
|
|
// SP2 ("reserved for SP3, accept and ignore"), so a party cleric standing over a
|
|
// bleeding friend between fights could do precisely nothing for them.
|
|
|
|
func TestSplitOutOfCombatTarget(t *testing.T) {
|
|
cases := []struct {
|
|
name, args, wantRest, wantTarget string
|
|
}{
|
|
{"flag", "cure wounds --target @alex", "cure wounds", "alex"},
|
|
{"flag mid-string", "cure wounds --target @alex --upcast 3", "cure wounds --upcast 3", "alex"},
|
|
{"flag without the @", "cure wounds --target alex", "cure wounds", "alex"},
|
|
{"trailing mention", "cure wounds @alex", "cure wounds", "alex"},
|
|
{"no target", "cure wounds", "cure wounds", ""},
|
|
// A trailing bare word is a spell word, not a name — the `@` is what makes
|
|
// it a target out of combat. Getting this wrong eats half of "cure wounds".
|
|
{"bare trailing word is not a target", "healing word", "healing word", ""},
|
|
{"upcast digit is not a target", "cure wounds --upcast 3", "cure wounds --upcast 3", ""},
|
|
{"dangling flag", "cure wounds --target", "cure wounds --target", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rest, target := splitOutOfCombatTarget(tc.args)
|
|
if rest != tc.wantRest || target != tc.wantTarget {
|
|
t.Fatalf("splitOutOfCombatTarget(%q) = (%q, %q), want (%q, %q)",
|
|
tc.args, rest, target, tc.wantRest, tc.wantTarget)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The target set is the expedition, not the world: you can heal the person you
|
|
// are travelling with, and nobody else.
|
|
func TestResolveCastTarget_OnlyThePartyIsReachable(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
leader, member := seatedMember(t, "casttarget")
|
|
|
|
uid, errMsg := resolveCastTargetOnExpedition(leader, member.Localpart())
|
|
if errMsg != "" || uid != member {
|
|
t.Fatalf("leader → member = (%q, %q); want the member, no error", uid, errMsg)
|
|
}
|
|
|
|
// Somebody with a sheet, but not on this expedition.
|
|
stranger := id.UserID("@stranger-casttarget:example.org")
|
|
zoneCmdTestCharacter(t, stranger, 1)
|
|
if _, errMsg := resolveCastTargetOnExpedition(leader, stranger.Localpart()); errMsg == "" {
|
|
t.Fatal("healed a stranger across the world; only the party is reachable")
|
|
}
|
|
|
|
// Naming yourself is just casting it on yourself: no target, no error. A
|
|
// refusal here would make `!cast cure wounds @me` cost a slot for nothing.
|
|
if uid, errMsg := resolveCastTargetOnExpedition(leader, leader.Localpart()); uid != "" || errMsg != "" {
|
|
t.Fatalf("self-target = (%q, %q); want the self-heal path, silently", uid, errMsg)
|
|
}
|
|
}
|
|
|
|
func TestHealPartyMember_ClampsAtMaxAndWillNotRaiseTheDead(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
target := id.UserID("@heal-target:example.org")
|
|
zoneCmdTestCharacter(t, target, 1) // HPMax 20
|
|
|
|
setHP := func(hp int) {
|
|
c, err := LoadDnDCharacter(target)
|
|
if err != nil || c == nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
c.HPCurrent = hp
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// A heal bigger than the wound tops out at max rather than overflowing.
|
|
setHP(15)
|
|
before, after, maxHP, err := healPartyMember(target, 100)
|
|
if err != nil || before != 15 || after != 20 || maxHP != 20 {
|
|
t.Fatalf("overheal = (%d → %d / %d, %v); want clamped to 20", before, after, maxHP, err)
|
|
}
|
|
|
|
// Already full: decline so the caller can refund. Spending a slot to heal
|
|
// zero HP is the kind of thing players never forgive.
|
|
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetFull) {
|
|
t.Fatalf("healing a full-HP ally = %v; want errHealTargetFull", err)
|
|
}
|
|
|
|
// Down: a heal is not a resurrection. Same rule the combat path holds.
|
|
setHP(0)
|
|
if _, _, _, err := healPartyMember(target, 10); !errors.Is(err, errHealTargetDown) {
|
|
t.Fatalf("healing a downed ally = %v; want errHealTargetDown", err)
|
|
}
|
|
if c, _ := LoadDnDCharacter(target); c == nil || c.HPCurrent != 0 {
|
|
t.Fatal("the downed ally was raised by a heal")
|
|
}
|
|
}
|