Merge party-known-flag: tell Pete this build knows what a party seat is

This commit is contained in:
prosolis
2026-07-24 23:16:25 -07:00
3 changed files with 83 additions and 0 deletions
+12
View File
@@ -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
+4
View File
@@ -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 {
+67
View File
@@ -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.