Files
gogobee/internal/plugin/zone_combat_party.go
prosolis 055f07d3c0 Combat engine §2(a): the enemy charges for what a seat brings
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any
roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a
round. A seat COUNT charges the same for an under-levelled friend, a hired NPC,
and a true peer — so a below-median body cost a full seat's worth of boss and did
not give a full seat's worth back.

Measured, once the companion's free full-heal was taken away and he became honest:
hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this
bug, and it has been live for every under-levelled friend anyone has ever invited.

Seats now carry a SeatWeight, and both levers scale on the summed weight of the
LIVING seats rather than on a head count. The weight is level-based, priced against
the leader, times a discount for a hireling (no subclass, no magic items, gear that
is never Masterwork — the layers a player accrues and a hireling never will).

Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric
below a fighter and quietly make every mixed HUMAN party easier, which is a
difficulty regression smuggled in under a bug fix.

The safety argument is one property: **a peer weighs exactly 1.0**. So the curves
interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4),
2n-1 from 3 up — and every integer input returns exactly what it always returned.
Solo is byte-identical, a party of same-level humans is byte-identical, both
goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is
the entire point of the change.

It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed
the head-count half; a corpse still carried its full weight until this.

Measured, 640 runs/arm, same grid:

  solo                    69.0%   (unchanged — corpus intact)
  + Pete                  76.8%   (+7.8pp)
  + a human cleric peer   77.6%   (+8.6pp)

  band                 solo    +Pete     lift
  trailing (<40%)     10.0%    31.0%   +21.0pp
  middle              58.9%    76.8%   +17.9pp
  leading (>=70%)     93.5%    99.2%    +5.7pp

Help, never a carry: he rescues the players who were drowning and barely moves the
ones who were already fine — and he stays below a real human of the leader's level,
which is the invariant a hireling must never break.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:20:49 -07:00

314 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
// Parallel to players: what each seat is worth, priced once the roster is
// final. A member who sat the encounter out is in none of these.
levels []int
companions []bool
)
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)
levels, companions = append(levels, level), append(companions, true)
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)
lvl := 0
if dndChar != nil {
lvl = dndChar.Level
}
levels, companions = append(levels, lvl), append(companions, false)
}
// What each seat costs the enemy, priced against the leader — over the seats
// that were actually seated, so a member left behind is not charged.
ptrs := make([]*Combatant, len(players))
for i := range players {
ptrs[i] = &players[i]
}
applySeatWeights(ptrs, levels, companions)
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."
}