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

244 lines
8.4 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"time"
"maunium.net/go/mautrix/id"
)
// runZoneCombatRoster auto-resolves one zone encounter for a whole roster.
//
// It is the seam P6e opens: before it, only elite and boss doorways seated a
// party (through the turn engine), and every other fight in a zone — exploration
// rooms, patrol encounters, harvest interrupts — resolved as the leader alone.
// On a 38-room expedition that meant a party of three fought together twice and
// the leader soloed the other ~35, which is why P7 measured zero member deaths
// and zero TPKs across 240 seats: the members were never in the fight.
//
// Roster order is the seating order, and roster[0] is the leader. Callers get
// back the seats that actually sat down, aligned index-for-index with
// res.Seats, so the character-scoped close-out (loot, death) can fan out and the
// run-scoped one (threat, kill record) can stay with the leader.
//
// A member who is down sits the encounter out. A leader who is down is a
// caller-side error — the run is theirs and the walk should not have reached
// here.
//
// Effects applied here are exactly the character-scoped ones runZoneCombat
// always applied, now per seat: HP, XP, achievements, the subclass persist, the
// heal items actually burned, and Misty's condition repair. Loot, threat, kill
// records and death remain the caller's, because only the caller knows the room.
func (p *AdventurePlugin) runZoneCombatRoster(
roster []id.UserID,
monster DnDMonsterTemplate,
tier int,
phases []CombatPhase,
dmMood int,
) (PartyCombatResult, []id.UserID, error) {
if len(roster) == 0 {
return PartyCombatResult{}, nil, fmt.Errorf("runZoneCombatRoster: empty roster")
}
if phases == nil {
phases = dungeonCombatPhases
}
type seatBuild struct {
uid id.UserID
dndChar *DnDCharacter
advChar *AdventureCharacter
equip map[EquipmentSlot]*AdvEquipment
mods CombatModifiers
}
var (
players []Combatant
builds []seatBuild
seated []id.UserID
enemy Combatant
)
for i, uid := range roster {
leader := i == 0
// The leader's failures are the encounter's failures; a member's only
// cost them their seat.
bail := func(err error) (PartyCombatResult, []id.UserID, error) {
return PartyCombatResult{}, nil, err
}
advChar, err := loadAdvCharacter(uid)
if err != nil || advChar == nil {
if leader {
return bail(fmt.Errorf("load adv character: %w", err))
}
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
continue
}
equip, err := loadAdvEquipment(uid)
if err != nil {
if leader {
return bail(fmt.Errorf("load equipment: %w", err))
}
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
continue
}
// 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)
}
slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err)
continue
}
if leader {
enemy = e
}
// Pre-combat one-shots that the turn-based path does NOT share: a queued
// spell and the panic-heal consumable trigger. Both mutate this seat's
// Combatant once, before the fight runs. The queued spell can also debuff
// the shared enemy, so every seat's cast lands on the one stat block.
applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods)
players = append(players, player)
builds = append(builds, seatBuild{
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
})
seated = append(seated, uid)
}
res := simulateParty(players, enemy, phases)
dumpCombatEventsIfDebug(
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
res.Seats[0])
for i, b := range builds {
seatRes := res.Seats[i]
// Remove the actual heal items consumed during combat (one inventory item
// per heal_item event this seat fired). Cheapest-tier first.
consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes))
// Misty condition repair (post-combat, same 20% chance as the arena and
// encounter paths in combat_bridge.go). Gourmet food keeps her gear in
// shape on long expeditions, not just in the arena.
if b.advChar.MistyBuffExpires != nil && time.Now().UTC().Before(*b.advChar.MistyBuffExpires) {
if rand.Float64() < 0.20 {
npcRepairMostDamaged(b.uid, b.equip, 5)
}
}
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
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 {
slog.Error("dnd: grantDnDXP zone", "user", b.uid, "err", err)
}
}
}
return res, seated, nil
}
// zoneCombatRoster is the roster a zone encounter seats for this walker: their
// expedition's party if they lead one, otherwise just them. Downed members are
// filtered by runZoneCombatRoster, not here — this answers "who is on the
// expedition", not "who can stand up".
//
// It must not touch getActiveZoneRun: that lookup carries the §4.3 idle reap,
// and the callers are mid-walk on the run it would reap. fightRoster observes
// the same rule, for the same reason.
func zoneCombatRoster(walker id.UserID) []id.UserID { return fightRoster(walker) }
// closeOutZoneWin hands loot to every seat still standing and the hospital to
// every seat that isn't. It returns the leader's own loot line — the only one
// that belongs in the leader's room narration — and who went down.
//
// The one asymmetry is deliberate. A *solo* player can win at 0 HP: a retaliate
// aura kills the swinger on the same blow that drops the enemy, and
// resolvePlayerAttack returns before enemyDown is consumed. Today that player is
// not marked dead, and P6e is not the place to change it — it would move the
// sim's outcome labels. A *party* marks its downed seats dead on a win, which is
// exactly what the turn-engine close-out has always done (finishPartyWin).
func (p *AdventurePlugin) closeOutZoneWin(
res PartyCombatResult,
seated []id.UserID,
zone ZoneDefinition,
monster DnDMonsterTemplate,
isBoss, elite bool,
deathSource string,
) (leaderDrop string, downed []id.UserID) {
party := len(seated) > 1
for i, uid := range seated {
if res.Seats[i].PlayerEndHP > 0 {
drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite)
if i == 0 {
leaderDrop = drop
}
continue
}
// Down when the enemy fell. No loot — they never saw it drop.
downed = append(downed, uid)
if party {
markAdventureDead(uid, deathSource, zone.Display)
}
}
return leaderDrop, downed
}
// closeOutZoneLoss marks every seat the fight actually killed. A loss is not the
// same as a death: the engine's timeout is a retreat ("ran out the clock"), and
// it leaves HP where it was. So death is read per seat off HP, never off the
// fight's terminal status — which for one seat is exactly the old
// `if !result.TimedOut` rule, since a solo player at 0 HP has already ended the
// fight.
func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) {
for i, uid := range seated {
if res.Seats[i].PlayerEndHP <= 0 {
markAdventureDead(uid, deathSource, zone.Display)
killed = append(killed, uid)
}
}
return killed
}
// partyCasualtyLine names the members a fight put on the floor, for the leader's
// narration. Empty for a solo walker, and empty when everyone walked away.
func partyCasualtyLine(downed []id.UserID) string {
if len(downed) == 0 {
return ""
}
names := make([]string, 0, len(downed))
for _, uid := range downed {
name, _ := loadDisplayName(uid)
if name == "" {
name = uid.Localpart()
}
names = append(names, "**"+name+"**")
}
return "💀 " + joinNames(names) + " went down."
}