adventure: work the five review findings the last pass left open

The extract pre-check is gone. It read a snapshot up to two minutes behind and
still got the last word, so somebody who set out over Matrix during a lagging
roster push was told they weren't on an expedition for a run gogobee would
happily have ended. Same call abandon and leave already made: let it through and
let rejected_not_running be the answer.

The siege_join check stays, because whether a boss is camped outside town is
town-wide and runs on a day-or-longer clock, but it now reads one column through
SiegeIsCamped instead of loading every defender row and the whole history to
look at one flag.

The war-room history insert is OR REPLACE. boss_id is the primary key and it was
never settled whether gogobee means the siege instance or the boss type by it, so
a duplicate pair used to fail the transaction carrying the live boss and the
muster too and freeze the war room on the last good snapshot. A dropped history
row is the smaller failure; the open question is noted in the schema.

offersToUndo's guard didn't cover the case its comment claimed. A gogobee too old
to push seats sends a valid blob with no party key, which decodes to the same
empty slice as a solo run, and a party member got shown the button that throws
away everyone's day. That needs a new field, so whoDetail gains party_known and
the flag gates the empty-list branch alone; the branch that reads the viewer's
own seat is self-evidencing and keeps working against any sender. gogobee's half
is written up in adventure_party_known_flag.md.

And an empty offer list no longer claims "you're already out there", which Pete
can't actually know from a game box too old to push offers at all.
This commit is contained in:
prosolis
2026-07-24 22:45:26 -07:00
parent c40ac1e673
commit aac6c3e127
9 changed files with 232 additions and 232 deletions
+29 -16
View File
@@ -38,6 +38,13 @@ type whoDetail struct {
Map *whoMap `json:"map"`
// Party is who else is down there, leader first. Absent on a solo run.
Party []partySeat `json:"party"`
// PartyKnown says the sheet was built by a gogobee that knows about party
// seats, which an absent Party cannot: nil means "solo" and "this sender never
// pushes seats" identically, and those two want opposite buttons. It is a
// capability flag about the SENDER, set unconditionally by any gogobee new
// enough — including on a solo run, including when the seat list is omitted —
// so it must never be read as a fact about the character.
PartyKnown bool `json:"party_known"`
}
// partySeat is one body on a shared expedition as gogobee described it. Kind is
@@ -120,28 +127,34 @@ const rosterStatusExpedition = "expedition"
// its refusal is the real answer. What this buys is a page that does not put
// "Leave the party" in front of somebody standing in town.
//
// Nothing new crosses the wire for it. Leadership is already legible in the party
// seats gogobee pushes (W7), and the sitter's standing is already in the babysit
// offer (W5b) — so the two facts the buttons need were both already here.
// haveParty says whether the public detail blob decoded at all, and it is
// load-bearing rather than defensive. An empty seat list means "solo" ONLY when
// we have actually read the sheet; a blob that did not decode — a gogobee too old
// to push seats, a truncated column, a shape change — produces the same empty
// slice, and treating that as solo would offer a party MEMBER the button that
// throws away everyone's day. Found by getting a fixture wrong: the page did
// exactly that, silently and convincingly.
func offersToUndo(token, status string, haveParty bool, party []partySeat, self storage.PlayerDetail) (abandon, leave, cancelSitter bool) {
// Leadership is legible in the party seats gogobee pushes (W7) and the sitter's
// standing is in the babysit offer (W5b), so the facts the buttons need are on
// the wire already — except for one, which nil could not express. partyKnown is
// the sender's "I know about party seats" flag, and it gates the empty-list
// branch alone. An empty list means "solo, so this player is the leader" ONLY
// from a sender that would have listed seats if there were any; from a blob that
// did not decode, or from a gogobee too old to push seats at all, the same empty
// slice would offer a party MEMBER the button that throws away everyone's day.
// Found by getting a fixture wrong: the page did exactly that, silently and
// convincingly.
//
// The asymmetry is deliberate: the len(party) > 0 branch reads the viewer's own
// seat and is self-evidencing — a seat that says "member" cannot be mistaken for
// leadership — so it needs no flag and keeps working against every sender.
func offersToUndo(token, status string, partyKnown bool, party []partySeat, self storage.PlayerDetail) (abandon, leave, cancelSitter bool) {
// The sitter first, because it is the only one of the three whose fact does
// not go stale: an engagement is a property of the character, not of where
// they are standing, so a two-minute-old snapshot is still right about it.
cancelSitter = self.Babysit != nil && self.Babysit.Active
if status == rosterStatusExpedition && haveParty {
if status == rosterStatusExpedition {
if len(party) == 0 {
// A SOLO run publishes no party at all — partySeatViews returns nil
// below two seats — so an empty list here is not "we don't know", it is
// "there is nobody else", which makes this player the leader.
abandon = true
// below two seats — so from a sender that knows about seats an empty list
// is not "we don't know", it is "there is nobody else", which makes this
// player the leader. Without the flag it is exactly "we don't know", and
// the button stays off.
abandon = partyKnown
} else {
// With a party, offer strictly on the viewer's own seat, and offer
// nothing at all if we cannot find it. Guessing in that case would mean
@@ -402,7 +415,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
}
}
page.CanAbandon, page.CanLeave, page.CanCancelSitter =
offersToUndo(token, entry.Status, page.HasDetail, page.Detail.Party, self)
offersToUndo(token, entry.Status, page.HasDetail && page.Detail.PartyKnown, page.Detail.Party, self)
}
}
}