mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
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.
198 lines
6.2 KiB
Go
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
|
|
}
|