diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index 48aeba3..1080b75 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -6,6 +6,10 @@ // expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens] // [-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, // one JSON object per stdout line, log suppressed by default): // 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)") + 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()") ) flag.Parse() @@ -58,6 +65,10 @@ func main() { if *petLevel < 0 || *petLevel > 10 { 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.SetSimPetLevel(*petLevel) @@ -72,14 +83,40 @@ func main() { 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 } - 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 if dir == "" { var err error @@ -90,7 +127,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa 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 res != nil { if !includeLog { @@ -117,7 +154,7 @@ type matrixJob struct { 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) ls := parseLevels(levels) zs := splitNonEmpty(zones) @@ -148,7 +185,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days var wg sync.WaitGroup for i := 0; i < jobs; i++ { 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() { 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() for j := range in { 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, fmt.Sprintf("-log=%t", includeLog), 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 { 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) if err != nil { 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) } 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) { diff --git a/internal/plugin/expedition_party.go b/internal/plugin/expedition_party.go index ae441d2..782acfd 100644 --- a/internal/plugin/expedition_party.go +++ b/internal/plugin/expedition_party.go @@ -34,6 +34,10 @@ const ( // to 2–3 players; the combat roster and the supply pool are both sized off this. 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. var ( ErrPartyFull = errors.New("expedition party is full") diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index e43c9b4..f5d6a68 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -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). diff --git a/internal/plugin/expedition_sim_party_test.go b/internal/plugin/expedition_sim_party_test.go new file mode 100644 index 0000000..37a280f --- /dev/null +++ b/internal/plugin/expedition_sim_party_test.go @@ -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) + } + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 26ef770..39f7529 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -126,14 +126,21 @@ func (b *Base) IsAdmin(userID id.UserID) bool { // DisplayName returns the Matrix display name for a user, falling back // to the localpart extracted from the user ID (e.g., "@alice:server" -> "alice"). func (b *Base) DisplayName(userID id.UserID) string { - resp, err := b.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { + localpart := func() string { s := string(userID) if idx := strings.Index(s, ":"); idx > 0 { s = s[1:idx] } 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 }