Files
gogobee/internal/plugin/dnd_abilities.go
prosolis a733d52166 Adv 2.0 D&D Phase 10 SUB2c: Cleric subclasses (Life/War/Trickery)
Channel Divinity resource pool (2/long-rest) shared across all three
Cleric domains. One armed expression per fight, mirroring Battle Master.

Life Domain
  - L5 Disciple of Life: heal spells restore +(2 + slot level) extra HP
    (lifeDomainHealBonus, hooked into resolveHealOutOfCombat).
  - L5 Preserve Life: Channel Divinity heal — sets HealItem to 5×Cleric
    level, capped at HPMax/2; fires when player drops below 50%.
  - L7 Blessed Healer skipped — no allies in 1v1 combat to "heal an ally".

War Domain
  - L5 War Priest passive: +1 attack bonus + 0.15 DamageBonus, proxy for
    bonus-action extra-attack throughput one-shot combat can't model
    discretely.
  - L5 Guided Strike: Channel Divinity → +10 to first attack roll
    (FirstAttackBonus).
  - L7 War God's Blessing skipped — ally reaction, no allies.

Trickery Domain
  - L5 Invoke Duplicity: Channel Divinity → AssassinateAdvantage (reroll
    first miss) + 0.10 DamageBonus, proxy for "advantage on attacks vs.
    creatures adjacent to the duplicate".
  - L7 Cloak of Shadows passive: +2 SporeCloud rounds (15% enemy miss)
    proxy for the brief invisibility window.
  - L5 Blessing of the Trickster skipped — non-combat Stealth flavor.

15 new tests, all green. Same pre-existing
TestSimulateCombat_FirstAttackBonusImprovesEarlyHits flake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

359 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Phase 6 — active abilities via pre-arm model.
//
// The combat engine is one-shot, so player-activated abilities use a pre-arm
// pattern: !arm <ability> reserves it (consumes one resource immediately)
// and the next combat auto-fires it via existing CombatModifiers fields.
// The armed flag is cleared on combat completion regardless of outcome.
//
// Resources refresh on long rest (Phase 6 simplification — short-rest classes
// also use long-rest refresh until we split short/long resource pools).
// ── Ability definitions ──────────────────────────────────────────────────────
type DnDAbility struct {
ID string
Name string
Class DnDClass
// Subclass: if non-empty, ability is only available when the player's
// subclass matches. Phase 10 adds the first such ability (Berserker
// rage). Used by parseAbility/arm gating and by classActiveAbilities
// when listing for !arm / !abilities.
Subclass DnDSubclass
Resource string // "stamina", "spell_slot", "favor", etc.
Description string
// Effect — applied to mods at combat start when armed
Apply func(c *DnDCharacter, mods *CombatModifiers)
}
var dndActiveAbilities = map[string]DnDAbility{
"second_wind": {
ID: "second_wind",
Name: "Second Wind",
Class: ClassFighter,
Resource: "stamina",
Description: "Restore HP at low health: 1d10 + level (consumes 1 stamina).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
// HealItem fires once when player drops below 50% HP.
// Avg of 1d10 + level: 5.5 + level.
mods.HealItem += 5 + c.Level
},
},
"magic_missile": {
ID: "magic_missile",
Name: "Magic Missile",
Class: ClassMage,
Resource: "spell_slot",
Description: "Three darts strike unerringly at the start of combat: 3 × (1d4+1) force damage.",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
// 3 darts × avg 3.5 = ~10. Auto-hit pre-combat damage.
mods.FlatDmgStart += 9 + abilityModifier(c.INT)
},
},
"healing_word": {
ID: "healing_word",
Name: "Healing Word",
Class: ClassCleric,
Resource: "favor",
Description: "Restore 1d4 + WIS HP at low health (consumes 1 divine favor).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.HealItem += 3 + abilityModifier(c.WIS)
},
},
}
func parseAbility(s string) (DnDAbility, bool) {
s = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(s, " ", "_")))
s = strings.ReplaceAll(s, "-", "_")
if a, ok := dndActiveAbilities[s]; ok {
return a, true
}
for _, a := range dndActiveAbilities {
if strings.EqualFold(a.Name, s) {
return a, true
}
}
return DnDAbility{}, false
}
// classActiveAbilities returns the active abilities a class can know,
// excluding subclass-gated entries (those are listed by characterActiveAbilities).
func classActiveAbilities(class DnDClass) []DnDAbility {
var out []DnDAbility
for _, a := range dndActiveAbilities {
if a.Class == class && a.Subclass == "" {
out = append(out, a)
}
}
return out
}
// characterActiveAbilities returns abilities available to a given character,
// including their subclass-gated abilities. Used for !arm listing so the
// Berserker sees `rage` once they've chosen the subclass.
func characterActiveAbilities(c *DnDCharacter) []DnDAbility {
var out []DnDAbility
for _, a := range dndActiveAbilities {
if a.Class != c.Class {
continue
}
if a.Subclass != "" && a.Subclass != c.Subclass {
continue
}
out = append(out, a)
}
return out
}
// ── Resource pool ────────────────────────────────────────────────────────────
// classResourceMax returns (resource_type, max_value) for a class.
// Phase 6: each class has one active resource pool.
func classResourceMax(class DnDClass) (string, int) {
switch class {
case ClassFighter:
return "stamina", 3
case ClassRogue:
return "focus", 2
case ClassMage:
return "spell_slot", 1
case ClassCleric:
return "favor", 3
case ClassRanger:
return "focus", 2
}
return "", 0
}
// initResources writes a fresh resource row for the class. Idempotent —
// won't overwrite an existing pool. Called on !setup confirm and on
// auto-migration.
func initResources(userID id.UserID, class DnDClass) error {
resType, max := classResourceMax(class)
if resType == "" {
return nil
}
_, err := db.Get().Exec(`
INSERT OR IGNORE INTO dnd_resources (user_id, resource_type, current_value, max_value)
VALUES (?, ?, ?, ?)`,
string(userID), resType, max, max)
return err
}
// subclassResourceMax — resources granted by a subclass on top of the class
// pool. Phase 10 SUB2a-ii: Battle Master gets 4 superiority dice (5e
// long-rest refresh in our model; spec says short-rest, but we keep parity
// with the existing class pools' refresh cadence). Returns ("", 0) if the
// subclass has no extra pool.
func subclassResourceMax(sub DnDSubclass) (string, int) {
switch sub {
case SubclassBattleMaster:
return "superiority", 4
case SubclassLifeDomain, SubclassWarDomain, SubclassTrickeryDomain:
// Phase 10 SUB2c — shared Channel Divinity pool for Cleric domains.
// 5e is 1/short-rest at L2 and 2/short-rest at L6; long-rest refresh
// in our model.
return "channel_divinity", 2
}
return "", 0
}
// initSubclassResources adds the subclass-specific resource pool. Called
// from applySubclassChoice; idempotent. If a player switches subclasses,
// the prior pool's row is left in place — refreshAllResources still touches
// it but no ability references it once the subclass changes, so it's inert.
func initSubclassResources(userID id.UserID, sub DnDSubclass) error {
resType, max := subclassResourceMax(sub)
if resType == "" {
return nil
}
_, err := db.Get().Exec(`
INSERT OR IGNORE INTO dnd_resources (user_id, resource_type, current_value, max_value)
VALUES (?, ?, ?, ?)`,
string(userID), resType, max, max)
return err
}
// getResource returns (current, max). Returns (0, 0, true) if no row exists.
func getResource(userID id.UserID, resType string) (int, int, error) {
var cur, max int
err := db.Get().QueryRow(
`SELECT current_value, max_value FROM dnd_resources
WHERE user_id = ? AND resource_type = ?`,
string(userID), resType,
).Scan(&cur, &max)
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, nil
}
return cur, max, err
}
// spendResource decrements current_value if at least amount is available.
// Returns true on success.
func spendResource(userID id.UserID, resType string, amount int) (bool, error) {
res, err := db.Get().Exec(`
UPDATE dnd_resources
SET current_value = current_value - ?,
last_reset_at = last_reset_at -- no-op, keep timestamp
WHERE user_id = ? AND resource_type = ?
AND current_value >= ?`,
amount, string(userID), resType, amount)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// refreshAllResources sets current_value = max_value for every resource of a user.
// Called on long rest.
func refreshAllResources(userID id.UserID) error {
_, err := db.Get().Exec(`
UPDATE dnd_resources SET current_value = max_value, last_reset_at = CURRENT_TIMESTAMP
WHERE user_id = ?`,
string(userID))
return err
}
// ── !arm command ─────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDnDArmCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
args = strings.TrimSpace(args)
advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your character.")
}
if args == "" || strings.ToLower(args) == "list" {
return p.SendDM(ctx.Sender, renderArmList(c))
}
if strings.ToLower(args) == "clear" {
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return p.SendDM(ctx.Sender, "Disarmed. Resource is not refunded.")
}
ab, ok := parseAbility(args)
if !ok {
return p.SendDM(ctx.Sender, "Unknown ability. Run `!arm` to see your options.")
}
if ab.Class != c.Class {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a %s ability — your class is %s.", ab.Name, titleClass(ab.Class), titleClass(c.Class)))
}
if ab.Subclass != "" && ab.Subclass != c.Subclass {
needed, _ := subclassInfo(ab.Subclass)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a %s subclass ability — choose that subclass via `!subclass`.",
ab.Name, needed.Display))
}
if c.ArmedAbility != "" {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You already have **%s** armed. Run `!arm clear` to disarm first.",
displayAbility(c.ArmedAbility)))
}
cur, _, err := getResource(ctx.Sender, ab.Resource)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load resources.")
}
if cur < 1 {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"No %s remaining. Take a long rest to refresh.", ab.Resource))
}
// Audit fix C: save the armed-ability flag FIRST. If save fails, no
// resource was spent. If save succeeds and spend fails (rare — single
// writer SQLite makes it nearly impossible after a successful save in
// the same connection), revert the armed flag.
c.ArmedAbility = ab.ID
if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save armed state: "+err.Error())
}
ok, err = spendResource(ctx.Sender, ab.Resource, 1)
if err != nil || !ok {
// Roll back the armed flag.
c.ArmedAbility = ""
if rerr := SaveDnDCharacter(c); rerr != nil {
slog.Error("dnd: failed to revert armed flag after spend failure",
"user", ctx.Sender, "err", rerr)
}
return p.SendDM(ctx.Sender, "Couldn't spend resource — armed state reverted.")
}
curAfter, max, _ := getResource(ctx.Sender, ab.Resource)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"⚡ **%s** armed for next combat. (%s: %d/%d remaining)",
ab.Name, ab.Resource, curAfter, max))
}
func renderArmList(c *DnDCharacter) string {
abilities := characterActiveAbilities(c)
var b strings.Builder
b.WriteString("**Active Abilities** (pre-arm via `!arm <name>`)\n\n")
if len(abilities) == 0 {
b.WriteString("_No active abilities for your class yet._\n")
} else {
resType, _ := classResourceMax(c.Class)
cur, max, _ := getResource(c.UserID, resType)
b.WriteString(fmt.Sprintf("Resource: **%s** %d/%d\n\n", resType, cur, max))
for _, a := range abilities {
b.WriteString(fmt.Sprintf(" • **%s** (1 %s) — %s\n", a.Name, a.Resource, a.Description))
}
}
if c.ArmedAbility != "" {
b.WriteString(fmt.Sprintf("\n_Currently armed: **%s** (will fire on next combat)_\n", displayAbility(c.ArmedAbility)))
}
b.WriteString("\nResources refresh on long rest.")
return b.String()
}
func displayAbility(id string) string {
if a, ok := dndActiveAbilities[id]; ok {
return a.Name
}
return id
}
// ── Combat hook ──────────────────────────────────────────────────────────────
// applyArmedAbility checks for a pre-armed ability on the character and
// applies its effect to the player's CombatModifiers, then clears the armed
// flag. Called from combat_bridge.go before SimulateCombat.
func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
if c == nil || c.ArmedAbility == "" {
return "", false
}
ab, ok := dndActiveAbilities[c.ArmedAbility]
if !ok {
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return "", false
}
ab.Apply(c, mods)
firedName := ab.Name
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return firedName, true
}