mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Combat engine §1 (the other half): a cleric can heal a friend at camp
`--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
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -27,9 +29,11 @@ import (
|
|||||||
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
||||||
// a "Phase 11" note — they require the boss turn-based engine.
|
// a "Phase 11" note — they require the boss turn-based engine.
|
||||||
//
|
//
|
||||||
// Cross-player targeting (--target @user) is deliberately deferred. SP2
|
// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
|
||||||
// supports self-target only. Heals on self work; ally buffs queue with the
|
// on your expedition — see dnd_cast_target.go. Only heals may name somebody else
|
||||||
// caster as the target until SP3 wires up the multi-player resolution.
|
// out of combat: 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. In-combat targeting is splitCastTarget (combat_cmd.go).
|
||||||
|
|
||||||
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
||||||
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
||||||
@@ -97,6 +101,13 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
return p.dndCastDrop(ctx, c)
|
return p.dndCastDrop(ctx, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An ally target — `--target @alex`, or a trailing `@alex` — comes off the
|
||||||
|
// string before the spell parser sees it, exactly as in combat. It is
|
||||||
|
// resolved (and refused) further down, once we know the spell is a heal: a
|
||||||
|
// target on anything else is a mistake, and refusing it early would cost us
|
||||||
|
// the "which spell?" half of the error message.
|
||||||
|
args, targetName := splitOutOfCombatTarget(args)
|
||||||
|
|
||||||
// Parse spell name + optional --upcast N.
|
// Parse spell name + optional --upcast N.
|
||||||
tokens := strings.Fields(args)
|
tokens := strings.Fields(args)
|
||||||
upcast := 0
|
upcast := 0
|
||||||
@@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
case "--target":
|
|
||||||
// Reserved for SP3 — accept and ignore for now.
|
|
||||||
if i+1 < len(tokens) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
spellTokens = append(spellTokens, tokens[i])
|
spellTokens = append(spellTokens, tokens[i])
|
||||||
}
|
}
|
||||||
@@ -195,6 +201,23 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve an ally target now — BEFORE anything is spent. Out of combat only a
|
||||||
|
// heal can land on somebody else: UTILITY resolves on the caster and
|
||||||
|
// everything else queues as a PendingCast for *this* caster's next fight, so
|
||||||
|
// a target on those would be accepted and then silently dropped.
|
||||||
|
var targetUser id.UserID
|
||||||
|
if targetName != "" {
|
||||||
|
if spell.Effect != EffectSpellHeal {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name))
|
||||||
|
}
|
||||||
|
uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName)
|
||||||
|
if errMsg != "" {
|
||||||
|
return p.SendDM(ctx.Sender, errMsg)
|
||||||
|
}
|
||||||
|
targetUser = uid // empty when they named themselves — self-heal as usual
|
||||||
|
}
|
||||||
|
|
||||||
// Material cost (Revivify, Raise Dead).
|
// Material cost (Revivify, Raise Dead).
|
||||||
if spell.MaterialCost > 0 {
|
if spell.MaterialCost > 0 {
|
||||||
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||||
@@ -220,6 +243,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
// debited above; if SaveDnDCharacter fails we refund.
|
// debited above; if SaveDnDCharacter fails we refund.
|
||||||
switch spell.Effect {
|
switch spell.Effect {
|
||||||
case EffectSpellHeal:
|
case EffectSpellHeal:
|
||||||
|
if targetUser != "" {
|
||||||
|
return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
|
||||||
|
}
|
||||||
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
||||||
case EffectUtility:
|
case EffectUtility:
|
||||||
return p.resolveUtility(ctx, c, spell, slotLevel)
|
return p.resolveUtility(ctx, c, spell, slotLevel)
|
||||||
@@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
|
|
||||||
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
// rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a
|
||||||
|
// property of the caster (their WIS, their domain), never of the body it lands
|
||||||
|
// on, so an ally heal rolls exactly the same as a self-heal.
|
||||||
|
func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
|
||||||
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
||||||
if dice == 0 {
|
if dice == 0 {
|
||||||
dice, faces = 1, 8 // safety fallback
|
dice, faces = 1, 8 // safety fallback
|
||||||
@@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
|||||||
}
|
}
|
||||||
heal += abilityModifier(c.WIS)
|
heal += abilityModifier(c.WIS)
|
||||||
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
||||||
|
return heal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||||
|
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||||
|
|
||||||
before := c.HPCurrent
|
before := c.HPCurrent
|
||||||
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
||||||
@@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara
|
|||||||
renderSlotsBrief(ctx.Sender)))
|
renderSlotsBrief(ctx.Sender)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is
|
||||||
|
// already spent, so every failure below refunds it: a heal that lands on nobody
|
||||||
|
// must not cost the caster anything.
|
||||||
|
func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error {
|
||||||
|
refund := func(msg string) error {
|
||||||
|
if spell.Level > 0 {
|
||||||
|
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||||||
|
before, after, maxHP, err := healPartyMember(target, heal)
|
||||||
|
|
||||||
|
targetName := charName(target)
|
||||||
|
if targetName == "" {
|
||||||
|
targetName = target.Localpart()
|
||||||
|
}
|
||||||
|
casterName := charName(ctx.Sender)
|
||||||
|
if casterName == "" {
|
||||||
|
casterName = ctx.Sender.Localpart()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errHealTargetFull):
|
||||||
|
return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP))
|
||||||
|
case errors.Is(err, errHealTargetDown):
|
||||||
|
// The same rule the combat path holds: a heal is not a resurrection.
|
||||||
|
return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName))
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName))
|
||||||
|
case err != nil:
|
||||||
|
return refund("Couldn't apply the heal: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = p.SendDM(target, fmt.Sprintf(
|
||||||
|
"🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).",
|
||||||
|
casterName, spell.Name, after-before, before, after, maxHP))
|
||||||
|
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s",
|
||||||
|
spell.Name, targetName, after-before, before, after, maxHP,
|
||||||
|
renderSlotsBrief(ctx.Sender)))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||||||
@@ -676,6 +755,7 @@ func renderCastHelp(c *DnDCharacter) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("**Cast a spell**\n\n")
|
b.WriteString("**Cast a spell**\n\n")
|
||||||
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
||||||
|
b.WriteString(" `!cast <heal> @friend` to heal someone on your expedition.\n")
|
||||||
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
||||||
b.WriteString("Run `!spells` for your full list.\n\n")
|
b.WriteString("Run `!spells` for your full list.\n\n")
|
||||||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||||||
|
|||||||
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
139
internal/plugin/dnd_cast_ally_e2e_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
internal/plugin/dnd_cast_target.go
Normal file
156
internal/plugin/dnd_cast_target.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Out-of-combat ally targeting for `!cast` — the other half of §1.
|
||||||
|
//
|
||||||
|
// splitCastTarget (combat_cmd.go) resolves a target against the people *in the
|
||||||
|
// fight*. Out of combat there is no fight, so the equivalent set is the people
|
||||||
|
// on your expedition: the party is who you are standing next to. Healing a
|
||||||
|
// stranger across the world is not a thing a cleric at camp can do, and scoping
|
||||||
|
// it to the party keeps the two `!cast` paths answering the same question.
|
||||||
|
//
|
||||||
|
// Only heals may name somebody else. Everything else `!cast` can do out of
|
||||||
|
// combat either resolves on the caster (UTILITY) or queues as a PendingCast for
|
||||||
|
// **the caster's** next fight — an ally target on those would be silently
|
||||||
|
// dropped, which is how `--target` came to be swallowed in the first place.
|
||||||
|
|
||||||
|
// splitOutOfCombatTarget peels an ally target off a `!cast` argument, using the
|
||||||
|
// same two spellings the combat path accepts: the `--target @alex` flag, and a
|
||||||
|
// plain trailing `@alex`. The bare form requires the `@` here (unlike in combat,
|
||||||
|
// where the roster disambiguates) so a trailing spell word is never mistaken for
|
||||||
|
// a name.
|
||||||
|
//
|
||||||
|
// Returns (remainingArgs, name). name is empty when no target was named.
|
||||||
|
func splitOutOfCombatTarget(args string) (string, string) {
|
||||||
|
fields := strings.Fields(args)
|
||||||
|
for i := 0; i < len(fields); i++ {
|
||||||
|
if !strings.EqualFold(fields[i], "--target") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(fields) {
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(fields[i+1], "@")
|
||||||
|
fields = append(fields[:i], fields[i+2:]...)
|
||||||
|
return strings.Join(fields, " "), name
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
if last := fields[len(fields)-1]; strings.HasPrefix(last, "@") {
|
||||||
|
if name := strings.TrimPrefix(last, "@"); name != "" {
|
||||||
|
return strings.Join(fields[:len(fields)-1], " "), name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCastTargetOnExpedition maps a named target to a human on the caster's
|
||||||
|
// active expedition. errMsg is non-empty and player-facing on any failure; a
|
||||||
|
// caster who names somebody must never fall through to healing themselves,
|
||||||
|
// because that quietly burns the slot on the wrong body.
|
||||||
|
func resolveCastTargetOnExpedition(caster id.UserID, name string) (id.UserID, string) {
|
||||||
|
exp, _, err := activeExpeditionFor(caster)
|
||||||
|
if err != nil {
|
||||||
|
return "", "Couldn't look up your expedition."
|
||||||
|
}
|
||||||
|
if exp == nil {
|
||||||
|
return "", "You're not travelling with anyone. `!cast` on yourself, or join a party first."
|
||||||
|
}
|
||||||
|
seats, err := partyHumans(exp.ID, exp.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "Couldn't look up your party."
|
||||||
|
}
|
||||||
|
for _, s := range seats {
|
||||||
|
uid := string(s.UserID)
|
||||||
|
if strings.EqualFold(uid, name) || strings.EqualFold(id.UserID(uid).Localpart(), name) {
|
||||||
|
if s.UserID == caster {
|
||||||
|
// Naming yourself is just casting it on yourself.
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return s.UserID, ""
|
||||||
|
}
|
||||||
|
// Character name, which is what players actually call each other.
|
||||||
|
if n := charName(s.UserID); n != "" && strings.EqualFold(n, name) {
|
||||||
|
if s.UserID == caster {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return s.UserID, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(name, companionDisplayName) {
|
||||||
|
return "", companionDisplayName + " patches himself up at camp. Save the slot."
|
||||||
|
}
|
||||||
|
return "", fmt.Sprintf("**%s** isn't on your expedition. You can only heal someone you're travelling with.", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errHealTargetFull / errHealTargetDown are the two ways an ally heal declines
|
||||||
|
// to spend the slot. Both are recoverable: the caller refunds.
|
||||||
|
var (
|
||||||
|
errHealTargetFull = errors.New("target already at full HP")
|
||||||
|
errHealTargetDown = errors.New("target is down")
|
||||||
|
)
|
||||||
|
|
||||||
|
// healPartyMember applies a heal to somebody else's sheet.
|
||||||
|
//
|
||||||
|
// It does NOT take the target's advUserLock. Gifting sets the precedent
|
||||||
|
// (adventure_gifting.go): mutate the other player's row with one guarded
|
||||||
|
// statement rather than a read-modify-write under two locks. Two clerics healing
|
||||||
|
// each other at the same instant would otherwise take those locks in opposite
|
||||||
|
// orders and deadlock the pair of them.
|
||||||
|
//
|
||||||
|
// The clamp lives in SQL for the same reason — read, add, cap and write are one
|
||||||
|
// statement, so a heal landing between a concurrent heal's read and write cannot
|
||||||
|
// push anybody past their maximum.
|
||||||
|
func healPartyMember(target id.UserID, amount int) (before, after, maxHP int, err error) {
|
||||||
|
tx, err := db.Get().Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
err = tx.QueryRow(
|
||||||
|
`SELECT hp_current, hp_max FROM dnd_character WHERE user_id = ?`,
|
||||||
|
string(target)).Scan(&before, &maxHP)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, 0, 0, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case before <= 0:
|
||||||
|
// A heal is not a resurrection — the same rule the combat path holds.
|
||||||
|
return before, before, maxHP, errHealTargetDown
|
||||||
|
case before >= maxHP:
|
||||||
|
return before, before, maxHP, errHealTargetFull
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec(
|
||||||
|
`UPDATE dnd_character
|
||||||
|
SET hp_current = MIN(hp_max, hp_current + ?),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ? AND hp_current > 0`,
|
||||||
|
amount, string(target)); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
if err = tx.QueryRow(
|
||||||
|
`SELECT hp_current FROM dnd_character WHERE user_id = ?`,
|
||||||
|
string(target)).Scan(&after); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return before, before, maxHP, err
|
||||||
|
}
|
||||||
|
return before, after, maxHP, nil
|
||||||
|
}
|
||||||
102
internal/plugin/dnd_cast_target_test.go
Normal file
102
internal/plugin/dnd_cast_target_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user