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 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). Phase 10 SUB3a-ii: at // L15 Relentless gives "regen 1 die when initiative rolled with empty pool" — // proxied here as a +1 max (5 dice total) so the player always has at least // one extra encounter's worth across the long-rest cycle. Returns ("", 0) if // the subclass has no extra pool. func subclassResourceMax(sub DnDSubclass, level int) (string, int) { switch sub { case SubclassBattleMaster: max := 4 if level >= 15 { max = 5 } return "superiority", max 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, or grows // the existing pool's max if the level-gated cap increased (e.g. Battle // Master L15 Relentless bumps the cap from 4 → 5). Called from // applySubclassChoice and the level-up loop; 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, level int) error { resType, max := subclassResourceMax(sub, level) if resType == "" { return nil } if _, err := db.Get().Exec(` INSERT OR IGNORE INTO dnd_resources (user_id, resource_type, current_value, max_value) VALUES (?, ?, ?, ?)`, string(userID), resType, max, max); err != nil { return err } // Grow max (and current) if the cap rose since the row was first written. // Don't shrink — players who somehow have a higher cap stored should keep it. _, err := db.Get().Exec(` UPDATE dnd_resources SET current_value = current_value + (? - max_value), max_value = ? WHERE user_id = ? AND resource_type = ? AND max_value < ?`, max, max, string(userID), resType, 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 `)\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 ────────────────────────────────────────────────────────────── // simAutoArmEnabled, when true, lets the combat builders pre-arm a // class-default ability for any character entering combat with an empty // ArmedAbility slot. The expedition-sim flips this on so synthetic // players model a competent real player (who would `!arm` Second Wind // before each fight) instead of a player who never touches the // !arm command. Untouched in production code paths. var simAutoArmEnabled = false // simAutoArmDefaultFor returns the class-default ability id the sim // should pre-arm. Defensive heals (Second Wind, Healing Word) only — // burning a Mage's spell slot every fight would mask J2, not help it. func simAutoArmDefaultFor(class DnDClass) string { switch class { case ClassFighter: return "second_wind" case ClassCleric: return "healing_word" } return "" } // trySimAutoArm pre-arms the class-default ability if the global toggle // is on, no ability is currently armed, and the resource pool has at // least one charge. Mirrors handleDnDArmCmd's spend-and-save flow. // Returns the ability name (or "" when nothing was armed). func trySimAutoArm(c *DnDCharacter) string { if !simAutoArmEnabled || c == nil || c.ArmedAbility != "" { return "" } id := simAutoArmDefaultFor(c.Class) if id == "" { return "" } ab, ok := dndActiveAbilities[id] if !ok { return "" } cur, _, err := getResource(c.UserID, ab.Resource) if err != nil || cur < 1 { return "" } c.ArmedAbility = ab.ID if err := SaveDnDCharacter(c); err != nil { return "" } if ok, _ := spendResource(c.UserID, ab.Resource, 1); !ok { c.ArmedAbility = "" _ = SaveDnDCharacter(c) return "" } return ab.Name } // consumeArmedAbility disarms the character, returning the ability id that was // armed. It is the *mutating* half of arming: the flag is cleared and saved, so // it must run exactly once per fight, at fight start. // // It is split from applyAbilityByID because a turn-based fight rebuilds its // combatants from the DB on every player command. A rebuild that consumed would // fire the ability on round 1, clear the flag, and then hand every later round a // character with no ability at all — the player pays the resource for one round // of a buff that is supposed to span the fight. So the fight consumes once and // persists the id on the session; each rebuild re-applies it from there. // // An id that is no longer in the ability table is disarmed and reported as "". func consumeArmedAbility(c *DnDCharacter) string { if c == nil || c.ArmedAbility == "" { return "" } armed := c.ArmedAbility c.ArmedAbility = "" _ = SaveDnDCharacter(c) if _, ok := dndActiveAbilities[armed]; !ok { return "" } return armed } // applyAbilityByID folds an ability's effect into a freshly-derived set of // CombatModifiers. It is pure with respect to persistence — no DB write, no // disarm — so it is safe to call on every rebuild of an in-flight fight. // // abilityID is what consumeArmedAbility returned at fight start. Empty (nobody // armed anything) and unknown ids are both no-ops. func applyAbilityByID(c *DnDCharacter, abilityID string, mods *CombatModifiers) (string, bool) { if c == nil || abilityID == "" { return "", false } ab, ok := dndActiveAbilities[abilityID] if !ok { return "", false } ab.Apply(c, mods) return ab.Name, true } // armAbilityForFight consumes whatever the character armed and applies it in one // step, for the auto-resolve callers that build a combatant and immediately // fight with it. A turn-based fight must not use this — see consumeArmedAbility. func armAbilityForFight(c *DnDCharacter, mods *CombatModifiers) (string, bool) { return applyAbilityByID(c, consumeArmedAbility(c), mods) }