mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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
462 lines
16 KiB
Go
462 lines
16 KiB
Go
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)
|
||
}
|