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

295 lines
9.8 KiB
Go

package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// An armed ability is consumed once, at fight start, and its id is parked on the
// seat. Everything here defends that split.
//
// Before it existed, buildZoneCombatants consumed the ability itself — and the
// turn-based engine calls that builder again on every !attack. So a Berserker
// raged on round 1, the builder cleared their armed flag and saved, and round 2
// rebuilt them with no rage at all. They paid stamina for one round of a buff
// that is supposed to span the fight, and the close-out could not see the rage
// it was supposed to charge exhaustion for.
// ragingBerserker is a Fighter who has already `!arm`ed rage.
func ragingBerserker(t *testing.T, uid id.UserID) *DnDCharacter {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBerserker, Level: 5,
STR: 16, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
ArmedAbility: "rage",
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
return c
}
// ── the split ────────────────────────────────────────────────────────────────
// consumeArmedAbility disarms and reports; applyAbilityByID applies and does not
// disarm. Calling the second one twice must be indistinguishable from once.
func TestApplyAbilityByID_IsPureAndRepeatable(t *testing.T) {
setupEmptyTestDB(t)
c := ragingBerserker(t, "@pure:example.org")
armed := consumeArmedAbility(c)
if armed != "rage" {
t.Fatalf("consumeArmedAbility = %q, want rage", armed)
}
if c.ArmedAbility != "" {
t.Errorf("character still armed after consume: %q", c.ArmedAbility)
}
if again := consumeArmedAbility(c); again != "" {
t.Errorf("second consume returned %q, want \"\" — the ability is spent", again)
}
// Same id, applied to two independent mod sets, yields the same rage.
for i, want := range []bool{true, true} {
var mods CombatModifiers
name, fired := applyAbilityByID(c, armed, &mods)
if !fired || name != "Rage" {
t.Fatalf("apply #%d: fired=%v name=%q", i+1, fired, name)
}
if mods.BerserkerRage != want {
t.Errorf("apply #%d: BerserkerRage = %v, want %v", i+1, mods.BerserkerRage, want)
}
if mods.RageMeleeDmg != 2 || !mods.PhysicalResistRage {
t.Errorf("apply #%d: rage did not carry its full mod set: %+v", i+1, mods)
}
}
// And it never writes back to the sheet.
got, _ := LoadDnDCharacter(c.UserID)
if got.ArmedAbility != "" {
t.Errorf("applyAbilityByID re-armed the sheet: %q", got.ArmedAbility)
}
}
func TestApplyAbilityByID_UnknownAndEmptyAreNoOps(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter}
for _, id := range []string{"", "no_such_ability"} {
var mods CombatModifiers
if _, fired := applyAbilityByID(c, id, &mods); fired {
t.Errorf("applyAbilityByID(%q) fired", id)
}
if mods.BerserkerRage {
t.Errorf("applyAbilityByID(%q) set rage", id)
}
}
}
// ── the fight-start seat ─────────────────────────────────────────────────────
// The seat carries the id forward, and the sheet is disarmed exactly once.
func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@berserk:example.org")
ragingBerserker(t, uid)
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
if len(seats) != 1 {
t.Fatalf("seats = %d, want 1", len(seats))
}
if seats[0].ArmedAbility != "rage" {
t.Errorf("seat.ArmedAbility = %q, want rage", seats[0].ArmedAbility)
}
if !seats[0].Mods.BerserkerRage {
t.Error("seat built without rage in its mods")
}
got, _ := LoadDnDCharacter(uid)
if got.ArmedAbility != "" {
t.Errorf("sheet still armed after fight start: %q", got.ArmedAbility)
}
}
// The regression itself: rebuilding the combatant — what every !attack does —
// must reproduce the rage from the persisted id, not from the (now cleared)
// sheet. Two rebuilds in a row, both raging.
func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@rebuild:example.org")
ragingBerserker(t, uid)
p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
armed := seats[0].ArmedAbility
for round := 2; round <= 3; round++ {
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, armed)
if err != nil {
t.Fatalf("round %d rebuild: %v", round, err)
}
if !player.Mods.BerserkerRage {
t.Errorf("round %d: rage evaporated on rebuild", round)
}
if player.Mods.RageMeleeDmg != 2 {
t.Errorf("round %d: RageMeleeDmg = %d, want 2", round, player.Mods.RageMeleeDmg)
}
}
// A seat that armed nothing rebuilds without rage — the id is the only source.
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, "")
if err != nil {
t.Fatal(err)
}
if player.Mods.BerserkerRage {
t.Error("unarmed rebuild raged anyway")
}
}
// A member who is down sits the fight out. They must not be charged the ability
// they had readied for it — the refusal is checked before the arm.
func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
downed := id.UserID("@downed:example.org")
fightTestChar(t, leader, 30)
ragingBerserker(t, downed)
// Drop the member to 0 HP with rage still armed.
c, _ := LoadDnDCharacter(downed)
c.HPCurrent = 0
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
if len(seats) != 1 || seats[0].UserID != leader {
t.Fatalf("seats = %+v, want the leader alone", seats)
}
got, _ := LoadDnDCharacter(downed)
if got.ArmedAbility != "rage" {
t.Errorf("downed member was disarmed for a fight they never joined: %q", got.ArmedAbility)
}
}
// ── the close-out ────────────────────────────────────────────────────────────
func TestSeatFightStartMods_ReadsTheRageOffTheSeatsStatuses(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 5}
raging := &CombatSession{}
raging.Statuses.ArmedAbility = "rage"
if !seatFightStartMods(raging, 0, c).BerserkerRage {
t.Error("seat 0 armed rage, mods say otherwise")
}
// Seat 1 reads its own statuses, not the leader's.
party := &CombatSession{Participants: []CombatParticipant{{Seat: 1}}}
party.Statuses.ArmedAbility = "rage"
if seatFightStartMods(party, 1, c).BerserkerRage {
t.Error("a member inherited the leader's rage")
}
}
func TestSeatCombatResult_ReadsWinAndNearDeathOffTheSession(t *testing.T) {
sess := &CombatSession{
Status: CombatStatusWon, Round: 4, PlayerHP: 5, PlayerHPMax: 40, EnemyHP: 0,
TurnLog: []CombatEvent{
{Action: "attack", Seat: 0},
{Action: "use_consumable", Seat: 1},
},
}
got := seatCombatResult(sess, 0)
if !got.PlayerWon {
t.Error("PlayerWon = false on a won session")
}
if !got.NearDeath {
t.Error("5/40 HP is under the 15% near-death line")
}
if len(got.Events) != 1 || got.Events[0].Action != "attack" {
t.Errorf("seat 0 got seat 1's events: %+v", got.Events)
}
sess.PlayerHP = 30
if seatCombatResult(sess, 0).NearDeath {
t.Error("30/40 HP is not near death")
}
sess.Status = CombatStatusLost
sess.PlayerHP = 0
if seatCombatResult(sess, 0).PlayerWon {
t.Error("PlayerWon = true on a lost session")
}
}
// Item A: the manual kill now costs the Berserker their exhaustion, exactly as
// the auto-resolved one always has.
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnTheTurnBasedPath(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@exhausted:example.org")
ragingBerserker(t, uid)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 3,
PlayerHP: 12, PlayerHPMax: 40,
}
sess.Statuses.ArmedAbility = "rage"
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 1 {
t.Errorf("Exhaustion = %d after a raging win, want 1", got.Exhaustion)
}
}
// A fight nobody raged in leaves the sheet alone.
func TestPostCombatBookkeepingForSeat_NoRageNoExhaustion(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@calm:example.org")
fightTestChar(t, uid, 30)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 2,
PlayerHP: 20, PlayerHPMax: 30,
}
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 0 {
t.Errorf("Exhaustion = %d with no rage, want 0", got.Exhaustion)
}
}
// Losing while raging still exhausts you — the close-out runs on every terminal
// status, not just the win. This is the half the turn-based path used to skip.
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnALoss(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@dead:example.org")
ragingBerserker(t, uid)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusLost, Round: 6,
PlayerHP: 0, PlayerHPMax: 40,
}
sess.Statuses.ArmedAbility = "rage"
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 1 {
t.Errorf("Exhaustion = %d after a raging loss, want 1", got.Exhaustion)
}
}