N3/P6e: a party that fights every room

Only elite and boss doorways seated a roster. Everything else -- exploration
rooms, patrol encounters, harvest interrupts -- resolved through SimulateCombat
against ctx.Sender, and P6d made the walk commands leader-only. So on a 38-room
T5 expedition a party of three fought together twice and the leader soloed the
other ~35, then died alone while two untouched members stood at full HP.

The plan said the N-body core was already there and only the callers passed one
player. It wasn't: SimulateCombat built a one-seat roster internally. But the
resolution primitives already read st.c -- the cursor's Combatant -- because the
turn engine has called them that way since P3. Only the round loop needed
widening.

combat_engine_party.go carries it: simulateParty, simulatePartyRound,
roundInitiative, enemyTargetSeat. Every roster short-circuit collapses for one
seat, copying P3's solo exemptions, so the RNG draw order is unchanged and
SimulateCombat is now simulateParty([]Combatant{p}, ...).Seats[0].
TestCombatCharacterization is byte-identical; TestSimulateCombat_IsTheOneSeatPartyCase
pins the delegation event-for-event across 40 seeds.

zone_combat_party.go carries the callers' half: runZoneCombatRoster fans out the
character-scoped close-out (HP, XP, achievements, subclass, heal items burned,
Misty's repair) per seat, while loot, threat, kill records and death stay with
whoever knows the room. runZoneCombat remains the explicit solo entry point --
the arena calls it, and an arena bout must never drag in a party.

Death is read per seat off HP, never off the fight's terminal status: a timed-out
party can still have lost somebody, and a solo player at 0 HP has already ended
the fight, so PlayerEndHP <= 0 is exactly the old !TimedOut rule.

Preserved deliberately: a solo player can win at 0 HP (a retaliate aura kills the
swinger on the killing blow, and resolvePlayerAttack returns before enemyDown is
consumed) and is not marked dead. A party marks its downed seats dead on a win,
which is what finishPartyWin always did.

Solo T5 re-sweep is unregressed (fighter 47-73%, cleric 20-33%). Party of 3 now
clears 100% of every T5 cell, which is P8's problem: the enemy takes one turn per
round and swings at one seat, so a party of N deals xN damage and each member
takes ~1/N^2 of the solo incoming. An HP scalar cannot close that -- it restores
the fight's duration, not the enemy's action economy.
This commit is contained in:
prosolis
2026-07-10 06:32:29 -07:00
parent 32e3148755
commit 08d3053368
8 changed files with 1155 additions and 435 deletions

View File

@@ -0,0 +1,249 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"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
}
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
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
} 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
// 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)
}
}
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)
}
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) }
// partySurvivors splits a resolved roster into who is still standing and who
// went down, in seating order.
func partySurvivors(res PartyCombatResult, seated []id.UserID) (up, down []id.UserID) {
for i, uid := range seated {
if res.Seats[i].PlayerEndHP > 0 {
up = append(up, uid)
} else {
down = append(down, uid)
}
}
return up, down
}
// 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+"**")
}
if len(names) == 1 {
return "💀 " + names[0] + " went down."
}
return "💀 " + strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1] + " went down."
}