Files
gogobee/internal/plugin/dnd_abilities_test.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

265 lines
6.7 KiB
Go

package plugin
import (
"io"
"os"
"path/filepath"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func setupAbilitiesTestDB(t *testing.T) {
t.Helper()
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
if _, err := os.Stat(src); err != nil {
t.Skip("prod db not present")
}
dir := t.TempDir()
dst := filepath.Join(dir, "gogobee.db")
in, _ := os.Open(src)
defer in.Close()
out, _ := os.Create(dst)
defer out.Close()
io.Copy(out, in)
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
func TestActiveAbilityCatalogue(t *testing.T) {
required := map[string]DnDClass{
"second_wind": ClassFighter,
"magic_missile": ClassMage,
"healing_word": ClassCleric,
}
for id, class := range required {
ab, ok := dndActiveAbilities[id]
if !ok {
t.Errorf("missing active ability: %s", id)
continue
}
if ab.Class != class {
t.Errorf("%s class = %s, want %s", id, ab.Class, class)
}
if ab.Apply == nil {
t.Errorf("%s has nil Apply func", id)
}
}
}
func TestParseAbility(t *testing.T) {
cases := []struct {
in string
id string
ok bool
}{
{"second_wind", "second_wind", true},
{"second wind", "second_wind", true},
{"second-wind", "second_wind", true},
{"Second Wind", "second_wind", true},
{"magic_missile", "magic_missile", true},
{"frostbolt", "", false},
}
for _, c := range cases {
ab, ok := parseAbility(c.in)
if ok != c.ok {
t.Errorf("parseAbility(%q) ok = %v, want %v", c.in, ok, c.ok)
}
if ok && ab.ID != c.id {
t.Errorf("parseAbility(%q) id = %q, want %q", c.in, ab.ID, c.id)
}
}
}
func TestClassResourceMax(t *testing.T) {
cases := []struct {
class DnDClass
typ string
max int
}{
{ClassFighter, "stamina", 3},
{ClassRogue, "focus", 2},
{ClassMage, "spell_slot", 1},
{ClassCleric, "favor", 3},
{ClassRanger, "focus", 2},
}
for _, c := range cases {
typ, max := classResourceMax(c.class)
if typ != c.typ || max != c.max {
t.Errorf("%s: got (%s, %d), want (%s, %d)", c.class, typ, max, c.typ, c.max)
}
}
}
func TestInitResources_Idempotent(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@init_res:example")
if err := initResources(uid, ClassFighter); err != nil {
t.Fatal(err)
}
cur, max, _ := getResource(uid, "stamina")
if cur != 3 || max != 3 {
t.Errorf("post-init: cur=%d max=%d, want 3/3", cur, max)
}
// Second call should not bump.
if err := initResources(uid, ClassFighter); err != nil {
t.Fatal(err)
}
cur2, _, _ := getResource(uid, "stamina")
if cur2 != cur {
t.Errorf("init not idempotent: cur went %d → %d", cur, cur2)
}
}
func TestSpendAndRefreshResource(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@spend_test:example")
if err := initResources(uid, ClassFighter); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
ok, err := spendResource(uid, "stamina", 1)
if err != nil || !ok {
t.Fatalf("spend %d failed: ok=%v err=%v", i, ok, err)
}
}
// 4th spend should fail (drained).
ok, _ := spendResource(uid, "stamina", 1)
if ok {
t.Error("4th spend succeeded; should drain at 0")
}
if err := refreshAllResources(uid); err != nil {
t.Fatal(err)
}
cur, max, _ := getResource(uid, "stamina")
if cur != max || cur != 3 {
t.Errorf("post-refresh: cur=%d max=%d, want 3/3", cur, max)
}
}
func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@arm_fighter:example")
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 12,
ArmedAbility: "second_wind",
}
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
c.HPCurrent = c.HPMax
c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX))
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
mods := CombatModifiers{}
name, fired := armAbilityForFight(c, &mods)
if !fired {
t.Fatal("ability did not fire")
}
if name != "Second Wind" {
t.Errorf("name = %q, want Second Wind", name)
}
wantHeal := 5 + c.Level // 5 + 5 = 10
if mods.HealItem != wantHeal {
t.Errorf("HealItem = %d, want %d", mods.HealItem, wantHeal)
}
// Armed flag cleared, persisted.
got, _ := LoadDnDCharacter(uid)
if got.ArmedAbility != "" {
t.Errorf("ArmedAbility not cleared: %q", got.ArmedAbility)
}
}
func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@arm_mage:example")
c := &DnDCharacter{
UserID: uid, Class: ClassMage, Race: RaceHuman, Level: 1,
INT: 14, ArmedAbility: "magic_missile", HPMax: 10, HPCurrent: 10,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
mods := CombatModifiers{}
_, fired := armAbilityForFight(c, &mods)
if !fired {
t.Fatal("magic missile did not fire")
}
wantDmg := 9 + abilityModifier(c.INT) // 9 + 2 = 11
if mods.FlatDmgStart != wantDmg {
t.Errorf("FlatDmgStart = %d, want %d", mods.FlatDmgStart, wantDmg)
}
}
func TestArmAbilityForFight_NoArm(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""}
mods := CombatModifiers{}
_, fired := armAbilityForFight(c, &mods)
if fired {
t.Error("fired with no armed ability")
}
}
// TestArmCommand_FullFlow integrates !arm via the handler: spends resource,
// sets armed_ability, surfaces in combat, clears.
func TestArmCommand_FullFlow(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@arm_flow:example")
if err := createAdvCharacter(uid, "arm_flow"); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 3,
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12,
}
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
c.HPCurrent = c.HPMax
c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX))
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if err := initResources(uid, c.Class); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleDnDArmCmd(MessageContext{Sender: uid}, "second_wind"); err != nil {
t.Fatal(err)
}
got, _ := LoadDnDCharacter(uid)
if got.ArmedAbility != "second_wind" {
t.Errorf("ArmedAbility = %q, want second_wind", got.ArmedAbility)
}
cur, max, _ := getResource(uid, "stamina")
if cur != 2 || max != 3 {
t.Errorf("stamina = %d/%d, want 2/3", cur, max)
}
// Trying to arm again with one already armed should fail (without
// changing state).
if err := p.handleDnDArmCmd(MessageContext{Sender: uid}, "second_wind"); err != nil {
t.Fatal(err)
}
got2, _ := LoadDnDCharacter(uid)
if got2.ArmedAbility != "second_wind" {
t.Errorf("double-arm changed state: %q", got2.ArmedAbility)
}
cur2, _, _ := getResource(uid, "stamina")
if cur2 != 2 {
t.Errorf("double-arm spent extra resource: cur=%d", cur2)
}
}