Files
gogobee/internal/plugin/pete_roster_party_test.go
prosolis e5d4bd6dc5 adventure: tell Pete who else is on the expedition
Party seats ride the roster detail, beside the supply and threat numbers the
same expedition already publishes. Leader first, the hireling named without a
token he has no board row for, and an opted-out player's seat kept and
anonymised — dropping it would make a party of three read as a pair while the
burn rate and enemy scaling beside it still felt three bodies.

The board itself was wrong about this: it resolved an expedition with a lookup
keyed on dnd_expedition.user_id, which is blind to members, so for the whole
life of N3 parties a seated player has shown as idle in town while standing in
a dungeon.

Pets also send the engine's XP threshold for their current level band, so the
web can draw progress without keeping a copy of a curve that would drift.
2026-07-24 20:26:28 -07:00

198 lines
6.2 KiB
Go

package plugin
import (
"testing"
"time"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// TestSeatedMemberIsNotIdleInTown is the gap W7 closes. The board resolved an
// expedition with getActiveExpedition, which keys on dnd_expedition.user_id — so
// a party member, who owns no row of their own, read as "idle in town" while
// standing in a dungeon. The regression is silent: the page renders fine, it just
// says the wrong thing about where somebody is.
func TestSeatedMemberIsNotIdleInTown(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-30 * time.Hour)
leader := id.UserID("@leader:test")
member := id.UserID("@member:test")
seedRosterPlayer(t, leader, "Josie", &old, &old)
seedRosterPlayer(t, member, "Camcast", &old, &old)
seedExpedition(t, "exp-shared", leader, "active")
seatLeaderFixture(t, "exp-shared")
if err := joinParty("exp-shared", member); err != nil {
t.Fatalf("joinParty: %v", err)
}
snap, err := buildRosterSnapshot(now, nil)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
byName := map[string]int{}
for i, a := range snap.Adventurers {
byName[a.Name] = i
}
for _, name := range []string{"Josie", "Camcast"} {
i, ok := byName[name]
if !ok {
t.Fatalf("%s is not on the board", name)
}
if got := snap.Adventurers[i].Status; got != "expedition" {
t.Errorf("%s status = %q, want expedition", name, got)
}
if snap.Adventurers[i].Zone == "" {
t.Errorf("%s is on an expedition with no zone named", name)
}
}
}
// TestPartySeatsNameTheWholeRoster covers the shape of the seat list: leader
// first, every human named with a linkable token, and the hireling named without
// one (he has no board row to link to).
func TestPartySeatsNameTheWholeRoster(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-30 * time.Hour)
leader := id.UserID("@leader:test")
member := id.UserID("@member:test")
seedRosterPlayer(t, leader, "Josie", &old, &old)
seedRosterPlayer(t, member, "Camcast", &old, &old)
seedExpedition(t, "exp-shared", leader, "active")
seatLeaderFixture(t, "exp-shared")
if err := joinParty("exp-shared", member); err != nil {
t.Fatalf("joinParty: %v", err)
}
if err := joinParty("exp-shared", companionUserID()); err != nil {
t.Fatalf("hire companion: %v", err)
}
seats := seatsForOwner(t, now, "Josie")
if len(seats) != 3 {
t.Fatalf("party has %d seats, want 3: %+v", len(seats), seats)
}
if seats[0].Kind != "leader" || seats[0].Name != "Josie" {
t.Errorf("first seat = %+v, want the leader Josie", seats[0])
}
if seats[0].Token == "" || seats[0].Level == 0 {
t.Errorf("leader seat is unlinkable or levelless: %+v", seats[0])
}
var companion, human int
for _, s := range seats {
switch s.Kind {
case "companion":
companion++
if s.Name != companionDisplayName {
t.Errorf("companion seat named %q, want %q", s.Name, companionDisplayName)
}
if s.Token != "" {
t.Errorf("companion seat carries a board token %q; he has no board row", s.Token)
}
case "leader", "member":
human++
if s.Name == "" || s.Token == "" {
t.Errorf("human seat %+v is missing its name/token pair", s)
}
default:
t.Errorf("unknown seat kind %q", s.Kind)
}
}
if companion != 1 || human != 2 {
t.Errorf("seats = %d human + %d companion, want 2 + 1", human, companion)
}
}
// TestSoloRunPublishesNoParty: expeditionParty always hands back at least the
// leader, so a naive render would draw every solo player a party of one.
func TestSoloRunPublishesNoParty(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-30 * time.Hour)
solo := id.UserID("@solo:test")
seedRosterPlayer(t, solo, "Josie", &old, &old)
seedExpedition(t, "exp-solo", solo, "active")
if seats := seatsForOwner(t, now, "Josie"); seats != nil {
t.Errorf("solo run published a party of %d: %+v", len(seats), seats)
}
// And with only the leader seated, which is what the roster table looks like
// between materialising and the first invite landing.
seatLeaderFixture(t, "exp-solo")
if seats := seatsForOwner(t, now, "Josie"); seats != nil {
t.Errorf("leader-only roster published a party of %d: %+v", len(seats), seats)
}
}
// TestOptedOutSeatIsAnonymisedNotDropped is the privacy contract for this
// surface, and it is deliberately NOT the board's rule. The board omits an
// opted-out player outright; a party seat is anonymised, because a party of three
// that renders as a pair is a false statement about the run everyone can see the
// supply burn and threat level of.
func TestOptedOutSeatIsAnonymisedNotDropped(t *testing.T) {
newBoredomTestDB(t)
now := time.Now().UTC()
old := now.Add(-30 * time.Hour)
leader := id.UserID("@leader:test")
hidden := id.UserID("@hidden:test")
seedRosterPlayer(t, leader, "Josie", &old, &old)
seedRosterPlayer(t, hidden, "Quack", &old, &old)
setNewsOptout(hidden, true)
seedExpedition(t, "exp-shared", leader, "active")
seatLeaderFixture(t, "exp-shared")
if err := joinParty("exp-shared", hidden); err != nil {
t.Fatalf("joinParty: %v", err)
}
seats := seatsForOwner(t, now, "Josie")
if len(seats) != 2 {
t.Fatalf("party has %d seats, want 2 — an opted-out seat was dropped, not anonymised: %+v",
len(seats), seats)
}
for _, s := range seats {
if s.Name == "Quack" {
t.Error("an opted-out player is named on a party roster")
}
}
var blank int
for _, s := range seats {
if s.Name == "" {
blank++
if s.Token != "" || s.Level != 0 {
t.Errorf("anonymised seat still carries a token or level: %+v", s)
}
}
}
if blank != 1 {
t.Errorf("%d anonymous seats, want exactly 1", blank)
}
}
// seatsForOwner pulls one named adventurer's published party out of a whole
// snapshot, which is the only way to reach it — Party rides RosterDetail, so this
// also proves the wiring in buildRosterSnapshot and not just partySeatViews.
func seatsForOwner(t *testing.T, now time.Time, name string) []peteclient.PartySeatView {
t.Helper()
snap, err := buildRosterSnapshot(now, nil)
if err != nil {
t.Fatalf("buildRosterSnapshot: %v", err)
}
for _, a := range snap.Adventurers {
if a.Name == name && a.Detail != nil {
return a.Detail.Party
}
}
t.Fatalf("%s is not on the board with a detail sheet", name)
return nil
}