diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 144e2e0..dce99d6 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -288,6 +288,18 @@ type RosterDetail struct { // run — a party of one is not a party, and the page should say nothing rather // than draw a roster with a single chair in it. Party []PartySeatView `json:"party,omitempty"` + // PartyKnown says this sender knows what a party seat is. It is a fact about + // the build, never about the character, so it is set unconditionally on every + // sheet — in town, solo, or seated with three others — and must never be + // computed from whether Party has anything in it. + // + // Party being omitempty is why it exists: a solo run and a game box too old to + // push seats both arrive at Pete as an empty slice, and Pete's page has to + // decide from that whether the viewer may throw away everybody else's day. An + // old build sends no key, which lands as false, and Pete withholds the button. + // No omitempty here for the same reason — an absent field is the fail-closed + // answer and this one is never absent on purpose. + PartyKnown bool `json:"party_known"` } // PartySeatView is one body on a shared expedition, as the public page may see diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index bc7e2fb..a7de38c 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -144,6 +144,10 @@ func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail { ArmorClass: c.ArmorClass, Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA}, Modifiers: c.Modifiers(), + // Unconditional, and this is the whole point of the field: it says this + // build knows about party seats, not that this character has any. Making + // it conditional would put it straight back in the hole it closes. + PartyKnown: true, } if equip, err := loadAdvEquipment(uid); err == nil { for _, slot := range allSlots { diff --git a/internal/plugin/pete_roster_party_test.go b/internal/plugin/pete_roster_party_test.go index 6aabb5b..cfa0289 100644 --- a/internal/plugin/pete_roster_party_test.go +++ b/internal/plugin/pete_roster_party_test.go @@ -1,6 +1,8 @@ package plugin import ( + "encoding/json" + "strings" "testing" "time" @@ -178,6 +180,71 @@ func TestOptedOutSeatIsAnonymisedNotDropped(t *testing.T) { } } +// TestPartyKnownIsSetOnEverySheet pins the capability flag Pete's abandon button +// hangs off. Party is omitempty, so a solo run and a game box too old to know what +// a seat is both reach Pete as an empty slice; the flag is what tells them apart. +// It is a fact about this build, so it must be true on a sheet with no party on it +// at all — a conditional party_known reads as "this player is solo" and puts the +// flag back in the hole it was added to close. +func TestPartyKnownIsSetOnEverySheet(t *testing.T) { + newBoredomTestDB(t) + now := time.Now().UTC() + old := now.Add(-30 * time.Hour) + + intown := id.UserID("@intown:test") + solo := id.UserID("@solo:test") + leader := id.UserID("@leader:test") + member := id.UserID("@member:test") + seedRosterPlayer(t, intown, "Nonk", &old, &old) + seedRosterPlayer(t, solo, "Quack", &old, &old) + seedRosterPlayer(t, leader, "Josie", &old, &old) + seedRosterPlayer(t, member, "Camcast", &old, &old) + + seedExpedition(t, "exp-solo", solo, "active") + 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) + } + var seen int + for _, a := range snap.Adventurers { + if a.Detail == nil { + t.Fatalf("%s has no detail sheet to carry the flag", a.Name) + } + seen++ + if !a.Detail.PartyKnown { + t.Errorf("%s (%s, %d seats) published party_known=false; this build knows what a seat is", + a.Name, a.Status, len(a.Detail.Party)) + } + } + if seen != 4 { + t.Fatalf("checked %d sheets, want 4 — a case went missing", seen) + } + + // The wire name is the contract: Pete decodes party_known and withholds the + // abandon button when it is absent, so a rename here fails silently and only + // on the far side. + blob, err := json.Marshal(&peteclient.RosterDetail{PartyKnown: true}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(blob), `"party_known":true`) { + t.Errorf("detail sheet serialised without party_known: %s", blob) + } + // And it must not be omitempty: an absent key is Pete's fail-closed answer, + // which a false flag has to keep meaning. + if blob, err = json.Marshal(&peteclient.RosterDetail{}); err != nil { + t.Fatalf("marshal: %v", err) + } else if !strings.Contains(string(blob), `"party_known":false`) { + t.Errorf("party_known is omitempty; false must stay on the wire: %s", blob) + } +} + // 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.