N3/P7: a party that only fights together twice

Adds -party N / -party-classes to expedition-sim. Followers are seated
through the real !expedition invite / !expedition accept pair, so the
harness measures the tier gate, the busy guard and the supply pooling
rather than a roster hand-built to succeed. A follower who is refused
halts the run: a party reading taken from a walk that was secretly solo
is worse than no reading.

It immediately found what it was built to find. Only elite and boss
doorways seat the roster; exploration rooms, patrols and harvest
interrupts all resolve through SimulateCombat against ctx.Sender, and
P6d made the walk commands leader-only. So on a 38-room T5 expedition
the party fights 2-3 rooms together and the leader solos the rest -
then dies alone, tearing down the run rows only they own.

Measured at L15/16 over dragons_lair + abyss_portal, party of 3, n=15
per cell: zero TPKs and zero member deaths across 240 seats. Every
failure is the leader falling while two untouched members stand at full
HP. Fighter clears 100% (solo: 47-67%), cleric 33-53% (solo: 13-47%).
The band is unreadable until inline combat seats the party, so the C1
contingency - +35% monster HP per member - stays on the shelf; it would
punish the trash the leader already fights alone.

Hence the outcome vocabulary grows a third word. "tpk" used to mean any
run-ending event, which is how a leader dying beside a healthy party
stayed invisible through P5 and P6. Now: tpk (roster dead),
leader_down, fled (run over, leader alive) - read off the death flag,
not off HP, which the close-out leaves anywhere. A one-seat roster
makes leader-dead and all-dead the same predicate, so solo keeps its
labels for a real death.

Two bugs found in review before this landed:

  - An unknown class was not an error anywhere: -class fightr built a
    1-HP character and reported an ordinary outcome for it. Guarded in
    BuildCharacter, not the flag parser, since the leader had the bug
    long before parties did.
  - The roster short-rest healed the dead - handleDnDShortRest does not
    gate on the death flag, so a member killed in a won boss fight got
    rested back above 0 and stopped counting as a casualty.

Golden byte-identical; go test ./... green.
This commit is contained in:
prosolis
2026-07-10 00:37:59 -07:00
parent a063e0ccd0
commit 32e3148755
5 changed files with 453 additions and 22 deletions

View File

@@ -64,6 +64,13 @@ func (s *SimRunner) Close() {
// up so handleDnDExpeditionCmd accepts them. Stats are class-flavored
// but otherwise vanilla (no race/subclass perks, no equipment).
func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*DnDCharacter, error) {
// An unknown class is not an error anywhere downstream: applyClassBaselineStats
// and computeMaxHP both fall through to a default, and the run walks off with a
// 1-HP character that dies on turn one. A typo in -class or -party-classes then
// reports a normal outcome for a character nobody asked to simulate.
if _, ok := classInfo(class); !ok {
return nil, fmt.Errorf("unknown class %q", class)
}
if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil {
return nil, fmt.Errorf("createAdvCharacter: %w", err)
}
@@ -263,7 +270,7 @@ type SimResult struct {
Class string
Level int
Zone string
Outcome string // "extracted" | "cleared" | "tpk" | "boss_doorway" | "fork" | "halted" | "ongoing"
Outcome string // "extracted" | "cleared" | "tpk" | "leader_down" | "fled" | "boss_doorway" | "fork" | "halted" | "ongoing"
StopCode string // sim-stopReason label of the last autopilot walk
Rooms int // total rooms walked across all autopilot bursts
Walks int // autopilot walk invocations (each = up to autopilotRoomCap rooms)
@@ -289,6 +296,14 @@ type SimResult struct {
// without re-running the matrix. Populated from combat_sessions
// rows + their TurnLog at end-of-run.
Combats []SimCombatSummary
// PartySize is the seated roster (1 for a solo run). Members carries a
// per-seat projection for the followers; the leader's own numbers stay
// on the top-level fields so a solo row is byte-shaped as it always was.
PartySize int
// LeaderAlive is read off the AdventureCharacter death flag, not HP — the
// close-out after a loss leaves HP in no particular state.
LeaderAlive bool
Members []SimMemberStat `json:",omitempty"`
// DaySnapshots traces HP/SU/threat/rooms at every day rollover
// (Night camp) plus the start (Day 0) and the final state. Used by
// D7-c long-expedition baselining to see how the trajectory bends
@@ -310,6 +325,19 @@ type SimDaySnapshot struct {
Rooms int // cumulative autopilot rooms walked at snapshot time
}
// SimMemberStat is one follower's slice of a party run. The leader is not
// listed here — their numbers are the SimResult's own top-level fields.
// Alive is what a T5 band reading actually turns on: a party "clears" when
// the boss dies, but a member who died on the way still paid for it.
type SimMemberStat struct {
UserID string
Class string
Level int
StartHP int
EndHP int
Alive bool
}
// SimCombatSummary is a compact per-fight trace: the entry stats, the
// per-round damage dealt by each side, and the outcome. Lets J-phase
// analysis ask "did Fighter L12 hit the manor boss often enough?"
@@ -399,6 +427,21 @@ const simWalkInterval = autoRunCooldown
// Pre-state: uid must own a synthetic character via BuildCharacter and
// have a coin balance sufficient for outfitting (caller's responsibility).
func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays int) (*SimResult, error) {
return s.RunPartyExpedition(uid, nil, zoneID, walkCap, maxDays)
}
// RunPartyExpedition is RunExpedition with followers. uid leads; every id in
// members is invited and accepts on Day 1, through the same
// `!expedition invite` / `!expedition accept` commands a player types — so the
// sim measures the seating rules (tier gate, busy guard, supply pooling) rather
// than a hand-built roster that cannot fail. Each member must already own a
// character and a bankroll.
//
// A nil members list is the solo path, unchanged and bit-identical: no invite is
// sent, no roster row is written, and `sess.IsParty()` stays false all the way
// down into the turn engine. That is what keeps the d8prereq_corpus baselines
// replayable.
func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneID ZoneID, walkCap, maxDays int) (*SimResult, error) {
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
@@ -424,6 +467,28 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
res.Outcome = "halted"
return res, fmt.Errorf("expedition did not persist after start")
}
// Seat the followers before the first walk — the invite window is Day 1,
// and their packs have to reach the pool before SUStart is read.
if len(members) > 0 {
if err := s.seatParty(uid, members); err != nil {
res.Outcome = "halted"
res.StopCode = "party:" + err.Error()
return res, err
}
if exp, _ = getActiveExpedition(uid); exp == nil {
res.Outcome = "halted"
return res, fmt.Errorf("expedition vanished while seating the party")
}
}
roster := append([]id.UserID{uid}, members...)
res.PartySize = len(roster)
for _, m := range members {
stat := SimMemberStat{UserID: string(m)}
if mc, _ := LoadDnDCharacter(m); mc != nil {
stat.Class, stat.Level, stat.StartHP = string(mc.Class), mc.Level, mc.HPCurrent
}
res.Members = append(res.Members, stat)
}
res.SUStart = exp.Supplies.Current
// Day-0 baseline so the snapshot stream always opens with a known
// starting state, even if the run halts before the first rollover.
@@ -449,7 +514,11 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
// Hard exits: walk says we're done.
switch walk.reason {
case stopEnded:
res.Outcome = "tpk"
// stopEnded means "the run is over", which is not the same as
// "everyone died": resolveCombatRoom ends the run on a *timeout*
// loss too, and deliberately does not mark that player dead (it is
// mechanically a retreat). Ask the death flags rather than assume.
res.Outcome = simRunEndOutcome(roster)
i = walkCap // exit
case stopComplete:
// A stopComplete that reaches the sim is normally a full zone
@@ -488,20 +557,27 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
break
}
if !killed {
// Player lost or fled — autopilot can't continue past the
// gate. Record outcome and stop.
if c2, _ := LoadDnDCharacter(uid); c2 == nil || c2.HPCurrent <= 0 {
res.Outcome = "tpk"
} else {
res.Outcome = "fled"
}
// The party lost or fled — autopilot can't continue past the
// gate.
res.Outcome = simRunEndOutcome(roster)
i = walkCap
} else {
// Combat won — a competent prod player would short-rest
// between fights when wounded or down on slots (H5 added
// the partial slot refresh). Without this the sim
// under-counts caster mid-expedition staying power.
s.maybeShortRest(ctx, uid)
// under-counts caster mid-expedition staying power. Every
// seat rests: charges and slots are character-scoped, and a
// member who never rested would drag the party's numbers. A seat
// that died in the fight the party just won is skipped:
// handleDnDShortRest does not gate on the death flag, so resting
// a corpse heals it back above 0 and the casualty disappears
// from res.Members[i].EndHP.
for _, m := range roster {
if !simSeatAlive(m) {
continue
}
s.maybeShortRest(MessageContext{Sender: m}, m)
}
}
// Boss kill closes the run via combat resolution → continue
// the loop so the next walk picks up the cleared-run state.
@@ -608,6 +684,13 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
if c2, _ := LoadDnDCharacter(uid); c2 != nil {
res.EndHP = c2.HPCurrent
}
for i, m := range members {
if mc, _ := LoadDnDCharacter(m); mc != nil {
res.Members[i].EndHP = mc.HPCurrent
}
res.Members[i].Alive = simSeatAlive(m)
}
res.LeaderAlive = simSeatAlive(uid)
res.EndCoin = s.Euro.GetBalance(uid)
if exp2, _ := getActiveExpedition(uid); exp2 != nil {
res.SUEnd = exp2.Supplies.Current
@@ -639,6 +722,86 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
return res, nil
}
// seatParty walks each follower through `!expedition invite` → `!expedition
// accept heavy`, as the leader and then as themselves. Both commands answer a
// refusal with a DM and a nil error — a no-op against the sim's nil client — so
// the seat is verified against the roster afterwards rather than the return
// value. A follower who cannot sit down (tier gate, empty purse, a run of their
// own) is a halted run, not a quietly smaller party: a two-player T5 reading
// taken from a solo walk is worse than no reading.
func (s *SimRunner) seatParty(leader id.UserID, members []id.UserID) error {
exp, err := getActiveExpedition(leader)
if err != nil || exp == nil {
return fmt.Errorf("no expedition to seat: %w", err)
}
for _, m := range members {
if err := s.P.handleDnDExpeditionCmd(MessageContext{Sender: leader},
"invite "+string(m)); err != nil {
return fmt.Errorf("invite %s: %w", m, err)
}
if err := s.P.handleDnDExpeditionCmd(MessageContext{Sender: m},
"accept "+simPartyLoadout); err != nil {
return fmt.Errorf("accept %s: %w", m, err)
}
}
size, err := partySize(exp.ID)
if err != nil {
return fmt.Errorf("read roster: %w", err)
}
if want := len(members) + 1; size != want {
return fmt.Errorf("roster seated %d of %d — a follower was refused", size, want)
}
return nil
}
// simPartyLoadout mirrors the leader's own "heavy" purchase in RunExpedition:
// each member buys the tier-max pack, and the pool is their sum. Anything less
// and the party starves before the boss, which would read as a difficulty
// finding rather than the supply-budget artifact it is.
const simPartyLoadout = "heavy"
// simSeatAlive reports whether a seat is still standing. Death is recorded on
// the AdventureCharacter (markAdventureDead → Kill), not as HPCurrent <= 0: the
// close-out that follows a loss can leave HP anywhere, and a player who merely
// fled at 0 HP is not dead. Reading the death flag is the only honest answer.
func simSeatAlive(uid id.UserID) bool {
c, err := loadAdvCharacter(uid)
if err != nil || c == nil {
return false
}
return c.Alive
}
// simRunEndOutcome names how a run ended once the walk says it is over. The
// three cases are distinct and the distinction is the whole point of a party
// reading:
//
// tpk — every seat is dead. The turn engine only reports Lost once
// anyAlive() goes false, so a real party loss marks the roster.
// leader_down — the leader died and members are still standing. Only the
// leader owns the run and expedition rows, so their death tears
// both down; inline room/patrol combat is fought by the leader
// alone, which is how a party reaches this state with untouched
// members (N3/P7 finding).
// fled — the run ended with the leader alive: a timeout loss (a
// mechanical retreat, deliberately not a death) or a forced
// extract.
//
// For a solo roster leader-dead and all-dead are the same predicate, so a solo
// run still scores exactly "tpk" or "fled" as it always did.
func simRunEndOutcome(roster []id.UserID) string {
leaderAlive := simSeatAlive(roster[0])
if leaderAlive {
return "fled"
}
for _, m := range roster[1:] {
if simSeatAlive(m) {
return "leader_down"
}
}
return "tpk"
}
// firstUnlockedForkChoice returns the 1-based index of the first
// traversable option at the pending fork, or 0 if every edge is locked
// (a graph soft-lock — see feywild fork1, which had no LockNone exit).