Files
gogobee/internal/plugin/zone_combat_party.go
prosolis 27b9de5936 Companion: he carries his wounds, rations his slots, and heals himself
Three defects, all the same mistake, all found by sweep and not by tests: the
companion has no database row for a thing to persist onto, so the thing "arrives
fresh next time" — which for a resource means infinite.

1. His spell slots refilled every fight. The ledger went on his combat SEAT, and
   a seat is per-session. A human rations one pool across a 30-room run and gets
   it back at camp; rationing it IS the caster's game. Now on
   expedition_party.companion_slots_used, refreshed at camp. (Worth ~0pp alone —
   a run holds only ~2 real fights, so the pool never binds. I predicted this was
   the whole answer. It was not.)

2. His BODY refilled every fight. buildFightSeats seated him at Stats.MaxHP and
   the close-out skipped him — "he arrives fresh next time", said the comment.
   That is an infinite body: he soaked a share of every fight's incoming and then
   reset, while the humans beside him bled all the way to camp. THIS was the
   carry. Now expedition_party.companion_hp; healed at camp; a dropped companion
   returns on 1 HP rather than as a corpse, because there is no companion-death
   rule and inventing one inside a bug fix would be a second feature.

3. No autopiloted caster had ever healed ITSELF. simPickAllyHeal skipped
   `i == seat` and bailed on !IsParty(), so a solo cleric carried cure_wounds for
   a whole run and never once cast it. Now simPickHeal: heal whoever is worst off,
   which is sometimes you.

Measured, 640 runs/arm, like-for-like (the leaders whose role-fill gives Pete a
Cleric, against a human Cleric follower of the leader's own level):

  solo                    69.0%
  + a human cleric        77.6%   (+8.6pp)
  + Pete                  66.1%   (-2.9pp)

The reference arm is the point. Against SOLO even a mace-only Pete looked like a
carry — but parties are designed to be safer, so solo is the wrong yardstick.
Against a human peer the real bug appeared: a gearless, level-penalized hireling
was out-clearing a fully-geared human cleric of the leader's own level by 15pp,
because he was the only combatant in the game who healed to full between fights.

With the free lunches gone he is honest, and honestly a net negative — which is
exactly the plan's §2 diagnosis, unmasked: a below-median seat cannot pay for its
own enemy scaling (+15% boss HP and 2.4 enemy actions a round instead of 1).
§2(a) is next, and the sweep now argues FOR it; before this commit it would have
made things worse.

Self-heal moved solo 66.1% -> 66.2%, so the balance corpus is undisturbed and no
re-baseline is owed. It is also NOT the answer to §6 — casters reach for a healing
consumable first and the sim stocks them, so a human rarely falls through to the
spell. Pete carries no consumables, so it is his only heal.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 14:56:19 -07:00

296 lines
11 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
// companion marks the hired NPC seat: it fights, but it owns none of the
// character-scoped effects the close-out loop applies, and its dndChar /
// advChar / equip are nil precisely so a missed guard panics loudly here
// rather than silently writing rows for a bot.
companion bool
}
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
}
// The hired companion fights here too — the auto-resolve path is where
// most expedition rooms are actually decided. He is synthesized rather
// than loaded, and his seatBuild is flagged so the close-out loop below
// gives him no XP, no loot, and no post-combat persistence.
if isCompanionSeat(uid) {
expID := companionExpeditionFor(roster[0])
class, level := companionLoadout(expID)
player, e, _ := p.companionCombatant(class, level, monster, tier, dmMood)
// The wounds he carries into this fight. His synthetic sheet is always
// written at full HP, so applyDnDHPScaling left StartHP at the
// "enter at MaxHP" sentinel and he healed himself for free between every
// room of the run. See companionSeatHP.
player.Stats.StartHP = companionSeatHP(expID, player.Stats.MaxHP)
if leader {
enemy = e
}
players = append(players, player)
builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true})
seated = append(seated, uid)
continue
}
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]
// The companion swings and then goes back to filing copy: no inventory to
// deduct from, no sheet to persist to, no XP to earn. Every call below
// would either write rows for a bot or log an error about the rows it
// hasn't got.
//
// His HP is the exception, and it is not bookkeeping — it is the fight's
// result. Without it he re-enters the next room at full while the humans
// beside him carry every wound to camp.
if b.companion {
_ = setCompanionHP(companionExpeditionFor(roster[0]), seatRes.PlayerEndHP)
continue
}
// 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 {
// The companion takes no cut and cannot die. He is not in the downed list
// either: that list is what the room narration mourns, and the party did
// not lose a friend when the hireling took a nap.
if isCompanionSeat(uid) {
continue
}
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 {
// The companion is not killed and is not counted among the dead — he is
// not in the graveyard, and a wipe that lists him would have the news bot
// reporting its own funeral.
if isCompanionSeat(uid) {
continue
}
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."
}