mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
adventure: tell Pete this build knows what a party seat is
The party list is omitempty, so a solo run and a game box too old to push seats reach Pete as the same empty slice, and those two want opposite buttons. Pete's page was reading the empty one as "solo, therefore leader" and offering a party member the button that throws away everyone's day. One boolean, set on every sheet a build makes — in town, solo, or seated with three others. It says something about the sender, never about the character, so it is set unconditionally: a conditional flag would read as "this player is solo" and land straight back in the hole it closes. An older build sends no key at all, which decodes to false, which is the answer Pete wants when it cannot tell. Pete's half shipped already and withholds the button until this arrives. Verified end to end against a local Pete, not just in tests: a solo leader on a live expedition gets "Call the whole thing off" back, a member still gets "Turn back alone" and never the abandon, and stripping the flag from the same push takes the solo button away again.
This commit is contained in:
@@ -288,6 +288,18 @@ type RosterDetail struct {
|
|||||||
// run — a party of one is not a party, and the page should say nothing rather
|
// 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.
|
// than draw a roster with a single chair in it.
|
||||||
Party []PartySeatView `json:"party,omitempty"`
|
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
|
// PartySeatView is one body on a shared expedition, as the public page may see
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail {
|
|||||||
ArmorClass: c.ArmorClass,
|
ArmorClass: c.ArmorClass,
|
||||||
Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA},
|
Abilities: [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA},
|
||||||
Modifiers: c.Modifiers(),
|
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 {
|
if equip, err := loadAdvEquipment(uid); err == nil {
|
||||||
for _, slot := range allSlots {
|
for _, slot := range allSlots {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// 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
|
// snapshot, which is the only way to reach it — Party rides RosterDetail, so this
|
||||||
// also proves the wiring in buildRosterSnapshot and not just partySeatViews.
|
// also proves the wiring in buildRosterSnapshot and not just partySeatViews.
|
||||||
|
|||||||
Reference in New Issue
Block a user