mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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).
|
||||
|
||||
190
internal/plugin/expedition_sim_party_test.go
Normal file
190
internal/plugin/expedition_sim_party_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user