Files
gogobee/internal/plugin/dnd_abilities.go
prosolis d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00

462 lines
16 KiB
Go
Raw Permalink 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). 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 <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 ──────────────────────────────────────────────────────────────
// 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)
}