diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index 7dd4547..046507c 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -1,7 +1,9 @@ package plugin import ( + "database/sql" "encoding/json" + "errors" "fmt" "log/slog" "math/rand/v2" @@ -27,9 +29,11 @@ import ( // - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with // a "Phase 11" note — they require the boss turn-based engine. // -// Cross-player targeting (--target @user) is deliberately deferred. SP2 -// supports self-target only. Heals on self work; ally buffs queue with the -// caster as the target until SP3 wires up the multi-player resolution. +// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human +// on your expedition — see dnd_cast_target.go. Only heals may name somebody else +// 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). // 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) } + // 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. tokens := strings.Fields(args) upcast := 0 @@ -111,11 +122,6 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro } i++ } - case "--target": - // Reserved for SP3 — accept and ignore for now. - if i+1 < len(tokens) { - i++ - } default: 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). if spell.MaterialCost > 0 { 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. switch spell.Effect { case EffectSpellHeal: + if targetUser != "" { + return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel) + } return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel) case EffectUtility: return p.resolveUtility(ctx, c, spell, slotLevel) @@ -231,7 +257,10 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro // ── 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) if dice == 0 { dice, faces = 1, 8 // safety fallback @@ -253,6 +282,11 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara } heal += abilityModifier(c.WIS) 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 c.HPCurrent = min(c.HPMax, c.HPCurrent+heal) @@ -272,6 +306,51 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara 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 ────────────────────────────────────────────────── 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 b.WriteString("**Cast a spell**\n\n") b.WriteString("Usage: `!cast [--upcast N]`\n") + b.WriteString(" `!cast @friend` to heal someone on your expedition.\n") b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n") b.WriteString("Run `!spells` for your full list.\n\n") if pc, ok := decodePendingCast(c.PendingCast); ok { diff --git a/internal/plugin/dnd_cast_ally_e2e_test.go b/internal/plugin/dnd_cast_ally_e2e_test.go new file mode 100644 index 0000000..3681832 --- /dev/null +++ b/internal/plugin/dnd_cast_ally_e2e_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_cast_target.go b/internal/plugin/dnd_cast_target.go new file mode 100644 index 0000000..a65d346 --- /dev/null +++ b/internal/plugin/dnd_cast_target.go @@ -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 +} diff --git a/internal/plugin/dnd_cast_target_test.go b/internal/plugin/dnd_cast_target_test.go new file mode 100644 index 0000000..c58cfdd --- /dev/null +++ b/internal/plugin/dnd_cast_target_test.go @@ -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") + } +}