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

@@ -6,6 +6,10 @@
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens] // expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
// [-bank 1000] [-cap 50] [-log] [-data DIR] // [-bank 1000] [-cap 50] [-log] [-data DIR]
// //
// Party run (N3/P7 — the leader is -class; followers clone it unless named):
// expedition-sim -party 3 -level 15 -zone dragons_lair
// expedition-sim -party 2 -class cleric -party-classes fighter -level 16 ...
//
// Matrix mode (cartesian sweep over classes × levels × zones × N runs, // Matrix mode (cartesian sweep over classes × levels × zones × N runs,
// one JSON object per stdout line, log suppressed by default): // one JSON object per stdout line, log suppressed by default):
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \ // expedition-sim -matrix -classes fighter,mage -levels 5,10 \
@@ -51,6 +55,9 @@ func main() {
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)") petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies")
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()") jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
) )
flag.Parse() flag.Parse()
@@ -58,6 +65,10 @@ func main() {
if *petLevel < 0 || *petLevel > 10 { if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel) fail("pet-level must be 0-10, got", *petLevel)
} }
if *party < 1 || *party > plugin.ExpeditionPartyMax {
fail("party must be 1-", plugin.ExpeditionPartyMax, ", got", *party)
}
followers := followerClasses(*class, *party, *partyClasses)
plugin.SetSimIncludeTrace(*trace) plugin.SetSimIncludeTrace(*trace)
plugin.SetSimPetLevel(*petLevel) plugin.SetSimPetLevel(*petLevel)
@@ -72,14 +83,40 @@ func main() {
includeLog = *logFlag includeLog = *logFlag
} }
}) })
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel) runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses)
return return
} }
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag) runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
} }
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool) { // followerClasses expands -party / -party-classes into the class of each
// follower seat. An explicit list must name every seat: a party of 3 whose
// second follower silently defaulted to the leader's class would quietly
// answer a different question than the one asked.
func followerClasses(leaderClass string, party int, spec string) []string {
n := party - 1
if n == 0 {
if strings.TrimSpace(spec) != "" {
fail("-party-classes given but -party is 1")
}
return nil
}
if strings.TrimSpace(spec) == "" {
out := make([]string, n)
for i := range out {
out[i] = leaderClass
}
return out
}
out := splitNonEmpty(spec)
if len(out) != n {
fail(fmt.Sprintf("-party %d needs %d follower classes, got %d", party, n, len(out)))
}
return out
}
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) {
dir := dataDir dir := dataDir
if dir == "" { if dir == "" {
var err error var err error
@@ -90,7 +127,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
} }
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days) res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
if err != nil { if err != nil {
if res != nil { if res != nil {
if !includeLog { if !includeLog {
@@ -117,7 +154,7 @@ type matrixJob struct {
rep int rep int
} }
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel int) { func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses string) {
cs := splitNonEmpty(classes) cs := splitNonEmpty(classes)
ls := parseLevels(levels) ls := parseLevels(levels)
zs := splitNonEmpty(zones) zs := splitNonEmpty(zones)
@@ -148,7 +185,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < jobs; i++ { for i := 0; i < jobs; i++ {
wg.Add(1) wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel) go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
} }
go func() { go func() {
for _, j := range work { for _, j := range work {
@@ -167,7 +204,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
} }
} }
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel int) { func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses string) {
defer wg.Done() defer wg.Done()
for j := range in { for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep) uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
@@ -187,6 +224,11 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
"-user", uid, "-user", uid,
fmt.Sprintf("-log=%t", includeLog), fmt.Sprintf("-log=%t", includeLog),
fmt.Sprintf("-pet-level=%d", petLevel), fmt.Sprintf("-pet-level=%d", petLevel),
fmt.Sprintf("-party=%d", party),
}
// Left empty, each cell's followers clone that cell's own -class.
if partyClasses != "" {
args = append(args, "-party-classes", partyClasses)
} }
if trace { if trace {
args = append(args, "-trace") args = append(args, "-trace")
@@ -228,7 +270,7 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
} }
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int) (*plugin.SimResult, error) { func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string) (*plugin.SimResult, error) {
runner, err := plugin.NewSimRunner(dataDir) runner, err := plugin.NewSimRunner(dataDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("init runner: %w", err) return nil, fmt.Errorf("init runner: %w", err)
@@ -239,7 +281,32 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
return nil, fmt.Errorf("build character: %w", err) return nil, fmt.Errorf("build character: %w", err)
} }
runner.Euro.Credit(uid, bank, "expedition-sim bankroll") runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
return runner.RunExpedition(uid, zone, cap, days)
// Followers share the leader's level — a party is not a carry (the tier
// gate refuses an under-levelled invitee anyway) — and each buys their own
// loadout, so each needs their own bankroll.
var members []id.UserID
for i, fc := range followers {
muid := followerUserID(uid, i)
if _, err := runner.BuildCharacter(muid, plugin.DnDClass(fc), level); err != nil {
return nil, fmt.Errorf("build follower %d (%s): %w", i+1, fc, err)
}
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
members = append(members, muid)
}
return runner.RunPartyExpedition(uid, members, zone, cap, days)
}
// followerUserID derives a follower's Matrix id from the leader's. It must keep
// the ":" — ResolveUser treats a colon as "this is already a full mxid" and
// short-circuits the room-membership lookup the sim has no client for.
func followerUserID(leader id.UserID, i int) id.UserID {
s := string(leader)
local, server, ok := strings.Cut(strings.TrimPrefix(s, "@"), ":")
if !ok {
local, server = strings.TrimPrefix(s, "@"), "sim"
}
return id.UserID(fmt.Sprintf("@%s-m%d:%s", local, i+1, server))
} }
func emitIndented(res *plugin.SimResult) { func emitIndented(res *plugin.SimResult) {

View File

@@ -34,6 +34,10 @@ const (
// to 23 players; the combat roster and the supply pool are both sized off this. // to 23 players; the combat roster and the supply pool are both sized off this.
const expeditionPartyMax = 3 const expeditionPartyMax = 3
// ExpeditionPartyMax exports the ceiling for cmd/expedition-sim's -party flag,
// so the harness cannot ask for a roster the seating layer will refuse.
const ExpeditionPartyMax = expeditionPartyMax
// Errors returned by the party layer. // Errors returned by the party layer.
var ( var (
ErrPartyFull = errors.New("expedition party is full") ErrPartyFull = errors.New("expedition party is full")

View File

@@ -64,6 +64,13 @@ func (s *SimRunner) Close() {
// up so handleDnDExpeditionCmd accepts them. Stats are class-flavored // up so handleDnDExpeditionCmd accepts them. Stats are class-flavored
// but otherwise vanilla (no race/subclass perks, no equipment). // but otherwise vanilla (no race/subclass perks, no equipment).
func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*DnDCharacter, error) { 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 { if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil {
return nil, fmt.Errorf("createAdvCharacter: %w", err) return nil, fmt.Errorf("createAdvCharacter: %w", err)
} }
@@ -263,7 +270,7 @@ type SimResult struct {
Class string Class string
Level int Level int
Zone string 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 StopCode string // sim-stopReason label of the last autopilot walk
Rooms int // total rooms walked across all autopilot bursts Rooms int // total rooms walked across all autopilot bursts
Walks int // autopilot walk invocations (each = up to autopilotRoomCap rooms) 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 // without re-running the matrix. Populated from combat_sessions
// rows + their TurnLog at end-of-run. // rows + their TurnLog at end-of-run.
Combats []SimCombatSummary 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 // DaySnapshots traces HP/SU/threat/rooms at every day rollover
// (Night camp) plus the start (Day 0) and the final state. Used by // (Night camp) plus the start (Day 0) and the final state. Used by
// D7-c long-expedition baselining to see how the trajectory bends // 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 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 // SimCombatSummary is a compact per-fight trace: the entry stats, the
// per-round damage dealt by each side, and the outcome. Lets J-phase // per-round damage dealt by each side, and the outcome. Lets J-phase
// analysis ask "did Fighter L12 hit the manor boss often enough?" // 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 // Pre-state: uid must own a synthetic character via BuildCharacter and
// have a coin balance sufficient for outfitting (caller's responsibility). // have a coin balance sufficient for outfitting (caller's responsibility).
func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays int) (*SimResult, error) { 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) c, err := LoadDnDCharacter(uid)
if err != nil || c == nil { if err != nil || c == nil {
return nil, fmt.Errorf("LoadDnDCharacter: %w", err) 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" res.Outcome = "halted"
return res, fmt.Errorf("expedition did not persist after start") 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 res.SUStart = exp.Supplies.Current
// Day-0 baseline so the snapshot stream always opens with a known // Day-0 baseline so the snapshot stream always opens with a known
// starting state, even if the run halts before the first rollover. // 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. // Hard exits: walk says we're done.
switch walk.reason { switch walk.reason {
case stopEnded: 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 i = walkCap // exit
case stopComplete: case stopComplete:
// A stopComplete that reaches the sim is normally a full zone // 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 break
} }
if !killed { if !killed {
// Player lost or fled — autopilot can't continue past the // The party lost or fled — autopilot can't continue past the
// gate. Record outcome and stop. // gate.
if c2, _ := LoadDnDCharacter(uid); c2 == nil || c2.HPCurrent <= 0 { res.Outcome = simRunEndOutcome(roster)
res.Outcome = "tpk"
} else {
res.Outcome = "fled"
}
i = walkCap i = walkCap
} else { } else {
// Combat won — a competent prod player would short-rest // Combat won — a competent prod player would short-rest
// between fights when wounded or down on slots (H5 added // between fights when wounded or down on slots (H5 added
// the partial slot refresh). Without this the sim // the partial slot refresh). Without this the sim
// under-counts caster mid-expedition staying power. // under-counts caster mid-expedition staying power. Every
s.maybeShortRest(ctx, uid) // 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 // Boss kill closes the run via combat resolution → continue
// the loop so the next walk picks up the cleared-run state. // 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 { if c2, _ := LoadDnDCharacter(uid); c2 != nil {
res.EndHP = c2.HPCurrent 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) res.EndCoin = s.Euro.GetBalance(uid)
if exp2, _ := getActiveExpedition(uid); exp2 != nil { if exp2, _ := getActiveExpedition(uid); exp2 != nil {
res.SUEnd = exp2.Supplies.Current res.SUEnd = exp2.Supplies.Current
@@ -639,6 +722,86 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
return res, nil 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 // firstUnlockedForkChoice returns the 1-based index of the first
// traversable option at the pending fork, or 0 if every edge is locked // 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). // (a graph soft-lock — see feywild fork1, which had no LockNone exit).

View File

@@ -0,0 +1,190 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// newPartySimRunner wires a runner the way cmd/expedition-sim does, minus the
// temp DB (the caller has already called setupZoneRunTestDB).
func newPartySimRunner() *SimRunner {
euro := &EuroPlugin{}
return &SimRunner{P: &AdventurePlugin{euro: euro}, Euro: euro}
}
// seatPartyFixture builds a leader and n followers at the given level, banks
// them, and starts the leader's expedition. Returns the roster.
func seatPartyFixture(t *testing.T, s *SimRunner, tag string, followers int, bank float64) (id.UserID, []id.UserID) {
t.Helper()
leader := id.UserID("@" + tag + ":example")
if _, err := s.BuildCharacter(leader, ClassFighter, 3); err != nil {
t.Fatalf("build leader: %v", err)
}
s.Euro.Credit(leader, 1000, "test")
var members []id.UserID
for i := 0; i < followers; i++ {
m := id.UserID("@" + tag + "-m" + string(rune('1'+i)) + ":example")
if _, err := s.BuildCharacter(m, ClassFighter, 3); err != nil {
t.Fatalf("build follower %d: %v", i, err)
}
s.Euro.Credit(m, bank, "test")
members = append(members, m)
}
ctx := MessageContext{Sender: leader}
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(ZoneGoblinWarrens)+" heavy"); err != nil {
t.Fatalf("start: %v", err)
}
return leader, members
}
// N3/P7: seatParty drives the real `!expedition invite` / `!expedition accept`
// pair, so a seated follower's packs land in the shared pool. Pooling raises
// Current *and* Max — supplyDepletion reads the ratio, so lifting only Current
// would read as the party starving the moment it set off (P6a).
func TestSeatParty_PoolsSuppliesAndSeatsEveryone(t *testing.T) {
setupZoneRunTestDB(t)
s := newPartySimRunner()
leader, members := seatPartyFixture(t, s, "sim-seat-ok", 2, 1000)
defer cleanupExpeditions(leader)
solo, err := getActiveExpedition(leader)
if err != nil || solo == nil {
t.Fatalf("expedition did not start: %v", err)
}
soloSU, soloMax := solo.Supplies.Current, solo.Supplies.Max
if err := s.seatParty(leader, members); err != nil {
t.Fatalf("seatParty: %v", err)
}
exp, _ := getActiveExpedition(leader)
if exp == nil {
t.Fatal("expedition vanished while seating")
}
size, err := partySize(exp.ID)
if err != nil {
t.Fatalf("partySize: %v", err)
}
if size != 3 {
t.Fatalf("roster = %d, want 3 (leader + 2)", size)
}
// Three identical "heavy" purchases: the pool is their sum.
if want := soloSU * 3; exp.Supplies.Current != want {
t.Errorf("pooled Current = %v, want %v", exp.Supplies.Current, want)
}
if want := soloMax * 3; exp.Supplies.Max != want {
t.Errorf("pooled Max = %v, want %v", exp.Supplies.Max, want)
}
}
// A follower who cannot pay is refused by expeditionCmdAccept — which answers
// with a DM and a nil error. seatParty must not read that as a seat: a party
// run that quietly walked as a solo would report a T5 clear rate for a roster
// that never existed.
func TestSeatParty_RefusedFollowerHaltsTheRun(t *testing.T) {
setupZoneRunTestDB(t)
s := newPartySimRunner()
leader, members := seatPartyFixture(t, s, "sim-seat-broke", 1, 0)
defer cleanupExpeditions(leader)
err := s.seatParty(leader, members)
if err == nil {
t.Fatal("seatParty accepted a follower who could not afford a loadout")
}
if !strings.Contains(err.Error(), "roster seated 1 of 2") {
t.Errorf("error = %q, want the roster-count refusal", err)
}
}
// A misspelled -class / -party-classes token used to build a character at the
// unknown-class fallback — 1 HP — and the run reported a perfectly normal
// outcome for it. Nothing downstream treats an unknown class as an error, so
// BuildCharacter has to.
func TestBuildCharacter_RejectsUnknownClass(t *testing.T) {
setupZoneRunTestDB(t)
s := newPartySimRunner()
if _, err := s.BuildCharacter(id.UserID("@sim-badclass:example"), DnDClass("fightr"), 8); err == nil {
t.Fatal("BuildCharacter accepted the class 'fightr'")
}
// The real thing still builds.
if _, err := s.BuildCharacter(id.UserID("@sim-goodclass:example"), ClassFighter, 8); err != nil {
t.Fatalf("BuildCharacter(fighter): %v", err)
}
}
// simRunEndOutcome separates the three ways a run can be over. The middle case
// is the one N3/P7 exists to surface: inline room and patrol combat is fought
// by the leader alone, so a party reaches "run over" with a dead leader and a
// roster that never drew a weapon. Calling that a TPK would hide it.
func TestSimRunEndOutcome_DistinguishesLeaderDeathFromAWipe(t *testing.T) {
setupZoneRunTestDB(t)
s := newPartySimRunner()
mk := func(tag string) id.UserID {
uid := id.UserID("@" + tag + ":example")
if _, err := s.BuildCharacter(uid, ClassFighter, 3); err != nil {
t.Fatalf("build %s: %v", tag, err)
}
return uid
}
leader, m1 := mk("sim-end-leader"), mk("sim-end-m1")
roster := []id.UserID{leader, m1}
if got := simRunEndOutcome(roster); got != "fled" {
t.Errorf("everyone alive: got %q, want %q", got, "fled")
}
markAdventureDead(leader, "zone", "Test")
if got := simRunEndOutcome(roster); got != "leader_down" {
t.Errorf("leader dead, member standing: got %q, want %q", got, "leader_down")
}
// A solo roster has no members to survive the leader, so leader-dead is a
// wipe — the label the balance corpus has always seen.
if got := simRunEndOutcome(roster[:1]); got != "tpk" {
t.Errorf("solo, dead: got %q, want %q", got, "tpk")
}
markAdventureDead(m1, "zone", "Test")
if got := simRunEndOutcome(roster); got != "tpk" {
t.Errorf("whole roster dead: got %q, want %q", got, "tpk")
}
}
// The solo path must not touch the party layer at all: no invite, no roster
// row, RosterSize 1 all the way into the turn engine. This is the property the
// d8prereq_corpus baselines rest on.
func TestRunPartyExpedition_SoloWritesNoRoster(t *testing.T) {
setupZoneRunTestDB(t)
s := newPartySimRunner()
uid := id.UserID("@sim-solo-noroster:example")
if _, err := s.BuildCharacter(uid, ClassFighter, 3); err != nil {
t.Fatalf("build: %v", err)
}
s.Euro.Credit(uid, 1000, "test")
defer cleanupExpeditions(uid)
// One walk is enough to prove the seating step was skipped; we care about
// the roster, not the outcome.
res, err := s.RunPartyExpedition(uid, nil, ZoneGoblinWarrens, 1, 0)
if err != nil {
t.Fatalf("RunPartyExpedition: %v", err)
}
if res.PartySize != 1 {
t.Errorf("PartySize = %d, want 1", res.PartySize)
}
if len(res.Members) != 0 {
t.Errorf("Members = %v, want empty for a solo run", res.Members)
}
if id := mostRecentExpeditionID(uid); id != "" {
size, err := partySize(id)
if err != nil {
t.Fatalf("partySize: %v", err)
}
if size != 0 {
t.Errorf("solo run seated a roster of %d, want no rows at all", size)
}
}
}

View File

@@ -126,14 +126,21 @@ func (b *Base) IsAdmin(userID id.UserID) bool {
// DisplayName returns the Matrix display name for a user, falling back // DisplayName returns the Matrix display name for a user, falling back
// to the localpart extracted from the user ID (e.g., "@alice:server" -> "alice"). // to the localpart extracted from the user ID (e.g., "@alice:server" -> "alice").
func (b *Base) DisplayName(userID id.UserID) string { func (b *Base) DisplayName(userID id.UserID) string {
resp, err := b.Client.GetDisplayName(context.Background(), userID) localpart := func() string {
if err != nil || resp.DisplayName == "" {
s := string(userID) s := string(userID)
if idx := strings.Index(s, ":"); idx > 0 { if idx := strings.Index(s, ":"); idx > 0 {
s = s[1:idx] s = s[1:idx]
} }
return s return s
} }
// The headless sim and unit tests construct plugins without a client.
if b == nil || b.Client == nil {
return localpart()
}
resp, err := b.Client.GetDisplayName(context.Background(), userID)
if err != nil || resp.DisplayName == "" {
return localpart()
}
return resp.DisplayName return resp.DisplayName
} }