diff --git a/internal/plugin/combat_armed_ability_test.go b/internal/plugin/combat_armed_ability_test.go new file mode 100644 index 0000000..b143d06 --- /dev/null +++ b/internal/plugin/combat_armed_ability_test.go @@ -0,0 +1,294 @@ +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) + } +} diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index 537fc3f..a1422bc 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -10,6 +10,30 @@ import ( "maunium.net/go/mautrix/id" ) +// postCombatBookkeeping is the close-out every fight owes its character, +// whatever surface it was fought on: achievements, and the subclass state that +// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal). +// +// It exists because there are four close-outs — two auto-resolve +// (runDungeonCombat, runZoneCombatRoster) and two turn-based +// (finishCombatSession, finishPartyCombatSession) — and for a long time only +// the auto-resolve pair ran any of this. The same Elite kill therefore paid out +// differently depending on whether the player let it auto-resolve or fought it +// a round at a time. Route all four through here and the divergence cannot +// silently reopen. +// +// raged is whether the character's Berserker rage was active for this fight; +// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is +// NOT done here — the turn-based paths already own their own HP writes. +func (p *AdventurePlugin) postCombatBookkeeping( + userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers, +) { + p.grantCombatAchievements(userID, result) + if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil { + slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err) + } +} + // grantCombatAchievements checks combat results for achievement-worthy moments. func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) { if p.achievements == nil { @@ -87,7 +111,7 @@ func (p *AdventurePlugin) runDungeonCombat( applySubclassPassives(&playerStats, &playerMods, dndChar) applyMagicItemEffects(&playerStats, &playerMods, userID) trySimAutoArm(dndChar) - if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { + if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired { slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) } applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier) @@ -129,12 +153,8 @@ func (p *AdventurePlugin) runDungeonCombat( // until a player-driven use command lands. consumeFiredHealingItems(userID, countHealEventsFired(result)) - p.grantCombatAchievements(userID, result) - persistDnDHPAfterCombat(userID, result.PlayerEndHP) - if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil { - slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err) - } + p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods) if xp := dungeonCombatXP(result, loc.Tier); xp > 0 { if _, err := p.grantDnDXP(userID, xp); err != nil { diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index a900636..f0b59e0 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -332,6 +332,10 @@ func continueHint(userID id.UserID) string { // the room's manual combat is done, and a fresh !zone advance clears the room. func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string { persistDnDHPAfterCombat(userID, sess.PlayerHP) + // Achievements and post-combat subclass state are owed on every terminal + // status, not just a win — a Berserker who rages and loses still comes out + // of it exhausted. The auto-resolve close-outs have always done this. + p.postCombatBookkeepingForSeat(sess, 0) run, _ := getZoneRun(sess.RunID) var zone ZoneDefinition diff --git a/internal/plugin/combat_party_finish.go b/internal/plugin/combat_party_finish.go index 86104f9..22c5528 100644 --- a/internal/plugin/combat_party_finish.go +++ b/internal/plugin/combat_party_finish.go @@ -51,9 +51,12 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string { // nat20/nat1 mood deltas from the whole fight's event log — the run's, so once. scanMoodEventsFromEvents(sess.RunID, sess.TurnLog) - // Every seat's HP lands on their sheet regardless of how the fight ended. + // Every seat's HP lands on their sheet regardless of how the fight ended, and + // so does the bookkeeping that outlives the fight — a Berserker who raged and + // lost is still exhausted. Both fan out; neither is the owner's alone. for seat := range sess.RosterSize() { persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat)) + p.postCombatBookkeepingForSeat(sess, seat) } switch sess.Status { diff --git a/internal/plugin/combat_party_start.go b/internal/plugin/combat_party_start.go index 97ed3ae..c2f715a 100644 --- a/internal/plugin/combat_party_start.go +++ b/internal/plugin/combat_party_start.go @@ -58,19 +58,10 @@ func (p *AdventurePlugin) buildFightSeats( } for i, uid := range roster { leader := i == 0 - player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood) - if err != nil { - if leader { - return nil, nil, "", "Couldn't set up the fight: " + err.Error() - } - slog.Warn("combat: party member left out of fight", "user", uid, "err", err) - skip(uid, "Couldn't bring you into the fight: "+err.Error()) - continue - } - if leader { - enemy = &e - } + // Both refusals below are cheap and neither needs the build, so they run + // before it: consuming a seat's armed ability and *then* sitting them out + // would spend their rage on a fight they never joined. hp, hpMax := dndHPSnapshot(uid) if hp <= 0 { if leader { @@ -96,7 +87,32 @@ func (p *AdventurePlugin) buildFightSeats( continue } - seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player}) + // Consumed exactly once for the fight, here. Every later rebuild + // re-applies this id off the seat's statuses rather than re-arming. + armed := "" + if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil { + trySimAutoArm(c) + armed = consumeArmedAbility(c) + } + player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed) + if err != nil { + if leader { + return nil, nil, "", "Couldn't set up the fight: " + err.Error() + } + slog.Warn("combat: party member left out of fight", "user", uid, "err", err) + skip(uid, "Couldn't bring you into the fight: "+err.Error()) + continue + } + if leader { + enemy = &e + } + if armed != "" { + slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed) + } + + seats = append(seats, CombatSeatSetup{ + UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed, + }) } return seats, enemy, senderSkip, "" } diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 75784e5..049f8b0 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -88,6 +88,13 @@ type ActorStatuses struct { // and druids with no sustained DPS once their burst landed. ConcentrationDmg int `json:"concentration_dmg,omitempty"` + // ArmedAbility is the id of the active ability this character armed and + // spent entering the fight (rage, second_wind, …). The resource is already + // debited and the character disarmed; this is the record that lets every + // rebuild of an in-flight fight re-apply the ability's mods, and lets the + // close-out know a rage fired. Empty when nothing was armed. + ArmedAbility string `json:"armed_ability,omitempty"` + // Once-per-fight class/race/subclass one-shots: the "already used" flags. // Without persistence these reset every round on resume, letting a Halfling // reroll a nat 1 or an Orc rage every single round of a turn-based fight. @@ -221,23 +228,26 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) { // the Abjuration Arcane Ward is normally non-zero at fight start — the // turn-based build deliberately omits pre-combat consumables and queued casts — // but the full set is seeded for robustness. Returns true if anything was set. -func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool { - return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods) +func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool { + return seedActorOneShots(&s.Statuses.ActorStatuses, seat) } // seedActorOneShots copies one character's fight-start one-shot resources onto // their persisted statuses. Seat 0's live on the session row; a party member's // live on their participant row, and each seat reads its own combatant's mods — // a party Abjurer brings their own Arcane Ward, not the leader's. -func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool { +func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool { + playerMods := seat.Mods st.WardCharges = playerMods.WardCharges st.SporeRounds = playerMods.SporeCloud st.ReflectFrac = playerMods.ReflectNext st.AutoCritFirst = playerMods.AutoCritFirst st.ArcaneWardHP = playerMods.ArcaneWardHP st.HealChargesLeft = playerMods.HealItemCharges + st.ArmedAbility = seat.ArmedAbility return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 || - st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 + st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 || + st.ArmedAbility != "" } // CombatParticipant is one party member's seat in a fight, from seat 1 up. @@ -477,13 +487,13 @@ func (p *AdventurePlugin) startPartyCombatSession( // Seat 0's one-shots live on the session row; seeding them is a mutation of // sess.Statuses that the save below flushes along with the participants. - dirty := seedCombatSessionOneShots(sess, owner.Mods) + dirty := seedCombatSessionOneShots(sess, owner) if len(seats) > 1 { ps := make([]CombatParticipant, 0, len(seats)-1) for i, s := range seats[1:] { var st ActorStatuses - seedActorOneShots(&st, s.Mods) + seedActorOneShots(&st, s) ps = append(ps, CombatParticipant{ Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st, }) @@ -525,6 +535,10 @@ type CombatSeatSetup struct { HPMax int Mods CombatModifiers C *Combatant + // ArmedAbility is the ability id this seat consumed entering the fight, "" + // if they armed nothing. It is persisted onto the seat's statuses so every + // later rebuild can re-apply the ability without re-spending it. + ArmedAbility string } // getActiveCombatSession returns the player's in-flight fight, or (nil, nil). diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 3cd8357..025ad0d 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -33,11 +33,18 @@ import ( // + armed ability) and the monster's bestiary stat block with tier scaling and // the DM-mood combat tilt folded in. The returned dndChar is handed back so // callers can run post-combat subclass persistence without reloading it. +// +// armed is the ability id already consumed for this fight (consumeArmedAbility, +// or armAbilityForFight's return on the auto-resolve paths); "" means none. The +// build re-applies it rather than consuming, because the turn-based path calls +// this once per player command and a consuming build would spend the ability on +// round 1 and drop it for the rest of the fight. func (p *AdventurePlugin) buildZoneCombatants( userID id.UserID, monster DnDMonsterTemplate, tier int, dmMood int, + armed string, ) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) { tilt := dmMoodCombatTilt(dmMood) char, err := loadAdvCharacter(userID) @@ -64,10 +71,7 @@ func (p *AdventurePlugin) buildZoneCombatants( applyRacePassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar) applyMagicItemEffects(&playerStats, &playerMods, userID) - trySimAutoArm(dndChar) - if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { - slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName) - } + applyAbilityByID(dndChar, armed, &playerMods) enemyStats, enemyMods := monster.toCombatStats() // Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a @@ -152,7 +156,11 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com players := make([]*Combatant, len(seats)) var enemy Combatant for seat, uid := range seats { - player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood) + // The ability this seat armed was consumed once, at fight start, and its + // id parked on their statuses. Re-applying it here — not re-consuming — + // is what makes a rage last the whole fight instead of one round. + st := sess.actorStatusesForSeat(seat) + player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility) if err != nil { return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err) } @@ -161,7 +169,7 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com // one-shots (ward/spore/…) live on their persisted statuses and flow // through the turn engine's resume/commit cycle, so only the persistent // stat deltas are applied here — and only that seat's own. - applySessionBuffs(&player, sess.actorStatusesForSeat(seat)) + applySessionBuffs(&player, st) players[seat] = &player if seat == 0 { // The enemy build reads only (monster, tier, dmMood): every seat @@ -173,6 +181,64 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com return players, &enemy, nil } +// seatFightStartMods re-derives the fight-start modifiers a finished fight's +// close-out still needs — today only the Berserker's rage flag, which decides +// whether the character owes a point of exhaustion. +// +// It re-applies the seat's armed ability to an empty mod set rather than +// rebuilding the whole combatant: no Apply writes to the character (they read +// level and HP and write only mods), so this is pure, and the passive/equipment +// layers a full build would add are not read by any post-combat hook. +// +// GrimHarvestSlot stays zero here, and that is not an oversight: the turn-based +// path never runs applyPendingCast, so a Necromancy Mage's spell is never +// stashed and Grim Harvest cannot fire on this surface at all. Wiring the mage +// spell hooks into the turn-based `!cast` is a separate change. +func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers { + var mods CombatModifiers + applyAbilityByID(c, sess.actorStatusesForSeat(seat).ArmedAbility, &mods) + return mods +} + +// seatCombatResult reconstructs, for one seat of a finished turn-based fight, +// the slice of CombatResult that the post-combat hooks actually read. The turn +// engine never builds a CombatResult — it persists a session and a shared event +// log — so the close-out has to assemble one. +// +// SniperKilled and MistyHealed stay false because the turn engine has no Arina +// or Misty proc to set them: those two live only in the auto-resolve engine. +// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max); +// its loss-side meaning is unused here, since the only hook that reads it gates +// on PlayerWon. +func seatCombatResult(sess *CombatSession, seat int) CombatResult { + hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) + won := sess.Status == CombatStatusWon + return CombatResult{ + PlayerWon: won, + Events: eventsForSeat(sess.TurnLog, seat), + PlayerEndHP: hp, + EnemyEndHP: sess.EnemyHP, + TotalRounds: sess.Round, + NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15, + } +} + +// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a +// terminal turn-based session — the bridge between what the turn engine +// persisted and what the shared close-out expects. +func (p *AdventurePlugin) postCombatBookkeepingForSeat(sess *CombatSession, seat int) { + uid := id.UserID(sess.seatUserID(seat)) + // Loaded after the caller's persistDnDHPAfterCombat, so a Grim-Harvest-style + // hook that heals off HPCurrent reads the fight's real ending HP. + c, err := LoadDnDCharacter(uid) + if err != nil || c == nil { + slog.Error("combat: post-combat bookkeeping skipped, no sheet", "user", uid, "err", err) + return + } + mods := seatFightStartMods(sess, seat, c) + p.postCombatBookkeeping(uid, c, mods.BerserkerRage, seatCombatResult(sess, seat), mods) +} + // applySessionBuffs re-derives the persistent stat effect of every mid-fight // buff onto one rebuilt character. The buffs are stored as accumulated deltas // (diffTurnBuff produced them against that character's state at cast time), so diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index 5cf19df..a8b8f90 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -454,15 +454,23 @@ func TestApplySessionBuffs(t *testing.T) { func TestSeedCombatSessionOneShots(t *testing.T) { // Arcane Ward is the one resource normally live at fight start. s := &CombatSession{} - if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) { + if !seedCombatSessionOneShots(s, CombatSeatSetup{Mods: CombatModifiers{ArcaneWardHP: 15}}) { t.Error("seed should report true when Arcane Ward is present") } if s.Statuses.ArcaneWardHP != 15 { t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP) } + // The armed ability rides along, so a rebuild mid-fight can re-apply it. + armed := &CombatSession{} + if !seedCombatSessionOneShots(armed, CombatSeatSetup{ArmedAbility: "rage"}) { + t.Error("seed should report true when an ability was armed") + } + if armed.Statuses.ArmedAbility != "rage" { + t.Errorf("ArmedAbility = %q, want rage", armed.Statuses.ArmedAbility) + } // Nothing to seed → false, statuses untouched. empty := &CombatSession{} - if seedCombatSessionOneShots(empty, CombatModifiers{}) { + if seedCombatSessionOneShots(empty, CombatSeatSetup{}) { t.Error("seed should report false with no fight-start resources") } } diff --git a/internal/plugin/dnd_abilities.go b/internal/plugin/dnd_abilities.go index 4b3a6b4..6b54a15 100644 --- a/internal/plugin/dnd_abilities.go +++ b/internal/plugin/dnd_abilities.go @@ -410,22 +410,52 @@ func trySimAutoArm(c *DnDCharacter) string { return ab.Name } -// applyArmedAbility checks for a pre-armed ability on the character and -// applies its effect to the player's CombatModifiers, then clears the armed -// flag. Called from combat_bridge.go before SimulateCombat. -func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) { +// 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[c.ArmedAbility] + ab, ok := dndActiveAbilities[abilityID] if !ok { - c.ArmedAbility = "" - _ = SaveDnDCharacter(c) return "", false } ab.Apply(c, mods) - firedName := ab.Name - c.ArmedAbility = "" - _ = SaveDnDCharacter(c) - return firedName, true + 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) } diff --git a/internal/plugin/dnd_abilities_test.go b/internal/plugin/dnd_abilities_test.go index f7d5245..386183d 100644 --- a/internal/plugin/dnd_abilities_test.go +++ b/internal/plugin/dnd_abilities_test.go @@ -146,7 +146,7 @@ func TestSpendAndRefreshResource(t *testing.T) { } } -func TestApplyArmedAbility_FighterSecondWind(t *testing.T) { +func TestArmAbilityForFight_FighterSecondWind(t *testing.T) { setupAbilitiesTestDB(t) uid := id.UserID("@arm_fighter:example") c := &DnDCharacter{ @@ -162,7 +162,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) { } mods := CombatModifiers{} - name, fired := applyArmedAbility(c, &mods) + name, fired := armAbilityForFight(c, &mods) if !fired { t.Fatal("ability did not fire") } @@ -181,7 +181,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) { } } -func TestApplyArmedAbility_MageMagicMissile(t *testing.T) { +func TestArmAbilityForFight_MageMagicMissile(t *testing.T) { setupAbilitiesTestDB(t) uid := id.UserID("@arm_mage:example") c := &DnDCharacter{ @@ -192,7 +192,7 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) { t.Fatal(err) } mods := CombatModifiers{} - _, fired := applyArmedAbility(c, &mods) + _, fired := armAbilityForFight(c, &mods) if !fired { t.Fatal("magic missile did not fire") } @@ -202,10 +202,10 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) { } } -func TestApplyArmedAbility_NoArm(t *testing.T) { +func TestArmAbilityForFight_NoArm(t *testing.T) { c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""} mods := CombatModifiers{} - _, fired := applyArmedAbility(c, &mods) + _, fired := armAbilityForFight(c, &mods) if fired { t.Error("fired with no armed ability") } diff --git a/internal/plugin/dnd_class_balance.go b/internal/plugin/dnd_class_balance.go index e6b5395..72dc255 100644 --- a/internal/plugin/dnd_class_balance.go +++ b/internal/plugin/dnd_class_balance.go @@ -25,7 +25,7 @@ import ( // // Bypassed deliberately (Phase 0 simplifying constraints, doc §2): // -// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and +// - DB-touching layers: applyMagicItemEffects, armAbilityForFight, and // the SaveDnDCharacter inside applyPendingCast. The harness is pure // Go; tests run without a sqlite instance. // - Race passives beyond Human (+1 all): neutral baseline, again per §2. diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 5b9298b..12b6fea 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -321,6 +321,42 @@ func updateSupplies(expID string, s ExpeditionSupplies) error { return err } +// withExpeditionSupplies serializes one read-modify-write of the shared supply +// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds +// its delta onto an *Expedition it read earlier silently discards anything that +// landed in between — a member's pooled packs, another writer's spend. Handlers +// run one goroutine per event, so those writers genuinely interleave. +// +// fn is handed a freshly-read expedition under the pool's own lock and returns +// the supplies to persist. Callers that keep using their own *Expedition +// afterwards must copy the returned pool back onto it. +// +// advUserLock cannot stand in here: it is keyed by sender, so two members +// racing the same expedition row take two different mutexes and exclude nobody. +func (p *AdventurePlugin) withExpeditionSupplies( + expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error), +) (ExpeditionSupplies, error) { + mu := p.advExpeditionLock(expID) + mu.Lock() + defer mu.Unlock() + + fresh, err := getExpedition(expID) + if err != nil { + return ExpeditionSupplies{}, err + } + if fresh == nil { + return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID) + } + next, err := fn(fresh) + if err != nil { + return ExpeditionSupplies{}, err + } + if err := updateSupplies(expID, next); err != nil { + return ExpeditionSupplies{}, err + } + return next, nil +} + // updateCamp persists camp state. Pass nil to break camp. func updateCamp(expID string, c *CampState) error { var arg any diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 23e3972..47b38e3 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -190,10 +190,18 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st } } - exp.Supplies.Current -= cost - if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + // The affordability check above ran against `exp` as it was read, and + // nightRolloverBurn may have moved the pool since. Deduct against the pool + // as it stands — the same arithmetic, just not onto a stale snapshot. + pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { + next := fresh.Supplies + next.Current -= cost + return next, nil + }) + if err != nil { return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error()) } + exp.Supplies = pooled camp := &CampState{ Active: true, Type: kind, diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index e4cec93..48c2b03 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -82,34 +82,39 @@ type nightRolloverResult struct { // to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp // between the two so a fortified camp's −5 lands before drift's +3. func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) { - // D5-c: Ranger forage runs before the daily burn so the +SU lands on - // today's supplies, not tomorrow's. Logged so the end-of-day digest - // can surface the gain; pure no-op for non-Ranger characters. - if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil { - if gain := applyRangerForage(e, c, nil); gain > 0 { - _ = appendExpeditionLog(e.ID, e.CurrentDay, "forage", - fmt.Sprintf("ranger forage +%g SU", gain), - flavor.Pick(flavor.HarvestForageSuccess)) - } - } - burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1) - var newSupplies ExpeditionSupplies var burn float32 - if burnOverride.Multiplier > 0 { - // The temporal override replaces the harsh/siege multiplier, not the - // roster's: a party still eats N × 0.8 rations inside a time-warped zone. - burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(e.ID)) / 100 - newSupplies = e.Supplies - newSupplies.Current -= burn - if newSupplies.Current < 0 { - newSupplies.Current = 0 + // Forage and burn land in one write, so they resolve together against the + // pool as it stands now — not as `e` last saw it. + newSupplies, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { + // D5-c: Ranger forage runs before the daily burn so the +SU lands on + // today's supplies, not tomorrow's. Logged so the end-of-day digest + // can surface the gain; pure no-op for non-Ranger characters. + if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil { + if gain := applyRangerForage(fresh, c, nil); gain > 0 { + _ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage", + fmt.Sprintf("ranger forage +%g SU", gain), + flavor.Pick(flavor.HarvestForageSuccess)) + } } - newSupplies.ForagedToday = false - } else { - harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e) - newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode) - } - if err := updateSupplies(e.ID, newSupplies); err != nil { + burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1) + if burnOverride.Multiplier > 0 { + // The temporal override replaces the harsh/siege multiplier, not the + // roster's: a party still eats N × 0.8 rations inside a time-warped zone. + burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100 + next := fresh.Supplies + next.Current -= burn + if next.Current < 0 { + next.Current = 0 + } + next.ForagedToday = false + return next, nil + } + harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh) + next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode) + burn = b + return next, nil + }) + if err != nil { return 0, err } if err := advanceExpeditionDay(e.ID); err != nil { diff --git a/internal/plugin/dnd_expedition_milestone.go b/internal/plugin/dnd_expedition_milestone.go index 2b20bcc..45bf6b1 100644 --- a/internal/plugin/dnd_expedition_milestone.go +++ b/internal/plugin/dnd_expedition_milestone.go @@ -176,20 +176,30 @@ const twoWeeksCacheSize = 3 func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string { var lines []string - if s := e.Supplies; s.DailyBurn > 0 { - s.Current += twoWeeksRestockDays * s.DailyBurn - if s.Max > 0 && s.Current > s.Max { - s.Current = s.Max - } - if s.Current > e.Supplies.Current { - if err := updateSupplies(e.ID, s); err != nil { - slog.Error("milestone: two weeks restock failed", - "expedition", e.ID, "err", err) - } else { - lines = append(lines, fmt.Sprintf( - "📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max)) - e.Supplies = s + if e.Supplies.DailyBurn > 0 { + var restocked bool + pooled, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { + s := fresh.Supplies + if s.DailyBurn <= 0 { + return s, nil } + s.Current += twoWeeksRestockDays * s.DailyBurn + if s.Max > 0 && s.Current > s.Max { + s.Current = s.Max + } + restocked = s.Current > fresh.Supplies.Current + return s, nil + }) + switch { + case err != nil: + slog.Error("milestone: two weeks restock failed", + "expedition", e.ID, "err", err) + case restocked: + lines = append(lines, fmt.Sprintf( + "📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max)) + e.Supplies = pooled + default: + e.Supplies = pooled } } diff --git a/internal/plugin/dnd_expedition_region_cmd.go b/internal/plugin/dnd_expedition_region_cmd.go index fe67b76..6ebe9a3 100644 --- a/internal/plugin/dnd_expedition_region_cmd.go +++ b/internal/plugin/dnd_expedition_region_cmd.go @@ -143,13 +143,17 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e // on both paths. func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) { // Burn one day of supplies (transit day). - siege := exp.SiegeMode - harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp) - newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege) - exp.Supplies = newSupplies - if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + var burned float32 + newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { + harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh) + next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode) + burned = b + return next, nil + }) + if err != nil { return "", fmt.Errorf("apply transit supply burn: %w", err) } + exp.Supplies = newSupplies if err := advanceExpeditionDay(exp.ID); err != nil { return "", fmt.Errorf("advance expedition day: %w", err) } diff --git a/internal/plugin/expedition_ambient.go b/internal/plugin/expedition_ambient.go index aa0c656..2101921 100644 --- a/internal/plugin/expedition_ambient.go +++ b/internal/plugin/expedition_ambient.go @@ -349,12 +349,14 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str return "Something out there is paying attention now." case "pack_rat": drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3 - ns := e.Supplies - ns.Current -= drain - if ns.Current < 0 { - ns.Current = 0 - } - if err := updateSupplies(e.ID, ns); err != nil { + if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { + ns := fresh.Supplies + ns.Current -= drain + if ns.Current < 0 { + ns.Current = 0 + } + return ns, nil + }); err != nil { slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err) return "" } diff --git a/internal/plugin/expedition_autocamp.go b/internal/plugin/expedition_autocamp.go index 55c6f67..bb0e159 100644 --- a/internal/plugin/expedition_autocamp.go +++ b/internal/plugin/expedition_autocamp.go @@ -250,10 +250,15 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision if exp.Supplies.Current < cost { return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost) } - exp.Supplies.Current -= cost - if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { + next := fresh.Supplies + next.Current -= cost + return next, nil + }) + if err != nil { return "", err } + exp.Supplies = pooled camp := &CampState{ Active: true, Type: d.Kind, diff --git a/internal/plugin/expedition_party_cmd.go b/internal/plugin/expedition_party_cmd.go index 3bd7955..01ee72b 100644 --- a/internal/plugin/expedition_party_cmd.go +++ b/internal/plugin/expedition_party_cmd.go @@ -178,21 +178,13 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte if isHol, _ := isHolidayToday(); isHol { suppliesPurchase.StandardPacks++ } - // updateSupplies overwrites supplies_json wholesale, so the pool has to be - // re-read under the expedition's own lock: `exp` was fetched before the coin - // debit, and a second invitee accepting — or the leader's day-burn tick — - // may have rewritten the row since. Folding onto that stale snapshot would - // silently drop their packs or resurrect spent SU. - expMu := p.advExpeditionLock(exp.ID) - expMu.Lock() - fresh, err := getExpedition(exp.ID) - if err != nil || fresh == nil { - expMu.Unlock() - return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.") - } - pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase) - err = updateSupplies(exp.ID, pooled) - expMu.Unlock() + // `exp` was fetched before the coin debit, and a second invitee accepting — + // or the leader's day-burn tick — may have rewritten the row since. Folding + // onto that stale snapshot would silently drop their packs or resurrect + // spent SU, so the fold happens against a fresh read under the pool's lock. + pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { + return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil + }) if err != nil { return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error()) } diff --git a/internal/plugin/zone_combat_party.go b/internal/plugin/zone_combat_party.go index 4a16643..4875019 100644 --- a/internal/plugin/zone_combat_party.go +++ b/internal/plugin/zone_combat_party.go @@ -86,7 +86,24 @@ func (p *AdventurePlugin) runZoneCombatRoster( continue } - player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood) + // A downed member sits it out. They own no close-out claim — and the + // check runs before the arm, so sitting out doesn't cost them the + // ability they had readied. + if !leader { + if hp, _ := dndHPSnapshot(uid); hp <= 0 { + continue + } + } + + // Auto-resolve builds and fights in one breath, so this seat can arm and + // apply together. The turn-based path has to split the two — see + // consumeArmedAbility. + armed := "" + if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil { + trySimAutoArm(c) + armed = consumeArmedAbility(c) + } + player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed) if err != nil { if leader { return bail(err) @@ -96,9 +113,6 @@ func (p *AdventurePlugin) runZoneCombatRoster( } if leader { enemy = e - } else if hp, _ := dndHPSnapshot(uid); hp <= 0 { - // A downed member sits it out. They own no close-out claim. - continue } // Pre-combat one-shots that the turn-based path does NOT share: a queued @@ -136,11 +150,8 @@ func (p *AdventurePlugin) runZoneCombatRoster( } } - p.grantCombatAchievements(b.uid, seatRes) persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP) - if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil { - slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err) - } + p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods) if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 { if _, err := p.grantDnDXP(b.uid, xp); err != nil {