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
+6
View File
@@ -138,6 +138,12 @@ CREATE TABLE IF NOT EXISTS adventure_siege_defenders (
fought_today INTEGER NOT NULL DEFAULT 0
);
-- Open question, never confirmed with gogobee: whether boss_id identifies the
-- siege instance or the boss TYPE. SiegeBarForBoss matches history on boss_name
-- plus the nearest ended_at and its comment says "the same boss comes back month
-- after month", which reads like a type — in which case this key collides on the
-- second visit. ReplaceSiege inserts OR REPLACE so a collision costs one history
-- row instead of the whole push; settle the meaning before relying on the key.
CREATE TABLE IF NOT EXISTS adventure_siege_history (
boss_id INTEGER PRIMARY KEY,
boss_name TEXT NOT NULL,
+26 -1
View File
@@ -114,8 +114,14 @@ func ReplaceSiege(s Siege, snapshotAt int64) error {
}
}
// OR REPLACE, because boss_id is the primary key and a duplicate in gogobee's
// list would otherwise fail this whole transaction — the live boss and the
// muster with it, freezing the war room on the previous snapshot indefinitely.
// The table is deleted and rebuilt from the pushed list every time, so a
// collision is a wire quirk rather than data loss, and keeping the last of a
// colliding pair is a far smaller failure than a war room that stops moving.
hstmt, err := tx.Prepare(`
INSERT INTO adventure_siege_history
INSERT OR REPLACE INTO adventure_siege_history
(boss_id, boss_name, tier, outcome, hp_remaining, hp_max, defenders, mvp, mvp_fights, ended_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
@@ -174,6 +180,25 @@ func SiegeBarForBoss(boss string, at int64) (current, max int, ok bool) {
return hpCur, hpMax, true
}
// SiegeIsCamped answers the one question the siege_join pre-check asks, without
// LoadSiege's defender rows and whole history behind it — a one-column read on a
// pool that is MaxOpenConns(1).
//
// known is false when gogobee has never pushed a war room at all, which is NOT
// the same as a pushed snapshot saying no Siege is camped. The caller has to keep
// the two apart: a fresh deploy that has not been pushed to yet must still queue
// the order rather than show a dead button.
func SiegeIsCamped() (active, known bool, err error) {
err = Get().QueryRow(`SELECT active FROM adventure_siege WHERE id = 1`).Scan(&active)
if err == sql.ErrNoRows {
return false, false, nil
}
if err != nil {
return false, false, err
}
return active, true, nil
}
// LoadSiege returns the war room as last pushed. ok is false when gogobee has
// never pushed one at all — distinct from a pushed snapshot that says no Siege
// is camped, which is a real answer the page can render.
+24 -14
View File
@@ -111,10 +111,11 @@ func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
return
}
// Per-verb pre-checks, all courtesy only. Both read Pete's snapshot copy, which
// is up to two minutes behind the game box, so neither is authoritative and
// neither is allowed to be the last word — a run that ended in that window comes
// back from gogobee as rejected_not_running, which is the honest answer.
// The roster lookup is for the character name the order carries; the one
// surviving pre-check below is courtesy only. Anything read here is Pete's
// snapshot copy, up to two minutes behind the game box, so it is never
// authoritative and is only allowed the last word where being two minutes late
// cannot make it wrong.
characterName := ""
entry, haveEntry, err := storage.RosterEntryByToken(token)
if err != nil {
@@ -125,20 +126,25 @@ func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
if haveEntry {
characterName = entry.Name
}
switch req.Action {
case storage.AdvActionExtract:
if haveEntry && entry.Status != "expedition" {
writeAdvOrderError(w, http.StatusConflict, "you're not on an expedition")
return
}
case storage.AdvActionSiegeJoin:
snap, known, err := storage.LoadSiege()
// No pre-check on extract, deliberately, and it is the same call abandon and
// leave make in resolveAdvOrderParams: the mark's status is up to two minutes
// stale here and a Matrix departure can outrun the roster push, so "reads idle"
// would refuse a run gogobee would happily have ended. The cost accepted is
// that a genuine mistake comes back as rejected_not_running rather than as an
// instant refusal, which is the honest answer anyway.
if req.Action == storage.AdvActionSiegeJoin {
// This one stays, because it is not a personal status: whether a boss is
// camped outside town is a town-wide fact on a day-or-longer clock, so a
// two-minute-old copy is almost never wrong about it. Note the known/active
// split — no snapshot at all must queue the order (a fresh deploy must not
// have a dead button); only a snapshot that positively says active=0 refuses.
active, known, err := storage.SiegeIsCamped()
if err != nil {
slog.Error("orders: siege lookup", "err", err)
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
return
}
if known && !snap.Active {
if known && !active {
writeAdvOrderError(w, http.StatusConflict, "no Siege is camped outside town")
return
}
@@ -207,7 +213,11 @@ func resolveAdvOrderParams(owner, token string, req advOrderReq) (*storage.AdvOr
return nil, "pick somewhere to go first"
}
if len(detail.Zones) == 0 {
return nil, "you're already out there"
// An empty offer list usually means they are already out there, but Pete
// cannot tell that from a game box too old to push offers at all, so say
// only what was actually seen. Unreachable from the page either way — with
// no offers the picker doesn't render — so this is a hand-crafted request.
return nil, "nowhere is on offer for you right now"
}
for _, z := range detail.Zones {
if z.ID != req.Zone {
+16 -11
View File
@@ -108,15 +108,18 @@ func TestOnlyOneOutstandingOrderPerVerb(t *testing.T) {
}
}
// TestActionPreChecksAreCourtesyOnly pins both halves of a deliberate asymmetry.
// Pete refuses what its own snapshot says is impossible — but the snapshot is up
// to two minutes old, so the refusal must be cheap and local (a 409 the button
// shows immediately), never a queued order gogobee has to answer.
func TestActionPreChecksAreCourtesyOnly(t *testing.T) {
// Idle mark: extract refused up front.
// TestOnlyTownWideFactsArePreChecked. Pete's copy of the board is up to two
// minutes behind the game box, so what it may refuse locally turns on whether
// being two minutes late could make the answer wrong. A personal status can:
// somebody who set out over Matrix still reads as idle here, and refusing their
// extract would deny a run gogobee would have ended. A boss camped outside town
// cannot: that is town-wide and runs on a day-or-longer clock.
func TestOnlyTownWideFactsArePreChecked(t *testing.T) {
// Idle mark: extract goes through anyway, and rejected_not_running is the
// answer if the mark really was standing in town.
s := seedActions(t, "holymachina", "idle")
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 409 {
t.Fatalf("extract while idle = %d, want 409", w.Code)
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
t.Fatalf("extract while idle = %d, want it queued (%s)", w.Code, w.Body.String())
}
// No Siege pushed at all: unknown, not "inactive". Pete has never heard from
@@ -328,10 +331,12 @@ func TestExpeditionParamsAreResolvedAgainstTheOwnersOffers(t *testing.T) {
}
}
// An empty zone list means "already out there", not "nowhere to go" — gogobee
// omits the offers entirely while the adventurer is on an expedition. Refusing
// An empty zone list is a refusal, and the message says only what Pete saw. It
// usually means the adventurer is already out — gogobee omits the offers
// entirely while they are down there — but a game box too old to push offers
// sends the same empty list, so the copy claims nothing about which. Refusing
// cheaply here beats a verdict thirty seconds later saying the same thing.
func TestNoZoneOffersMeansAlreadyOut(t *testing.T) {
func TestNoZoneOffersMeansNothingOnOffer(t *testing.T) {
s := seedOffers(t, "holymachina", "expedition", storage.PlayerDetail{})
w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
+25 -9
View File
@@ -36,19 +36,19 @@ func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
cases := []struct {
name string
token, status string
haveParty bool
partyKnown bool
party []partySeat
self storage.PlayerDetail
abandon, leave, cancel bool
}{
{
name: "leader of a party is offered the abandon",
token: "tok-josie", status: "expedition", haveParty: true, party: party,
token: "tok-josie", status: "expedition", partyKnown: true, party: party,
abandon: true,
},
{
name: "member of a party is offered the exit, never the abandon",
token: "tok-cam", status: "expedition", haveParty: true, party: party,
token: "tok-cam", status: "expedition", partyKnown: true, party: party,
leave: true,
},
{
@@ -56,7 +56,7 @@ func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
// two seats), so an empty list on a live run means "nobody else", not
// "we don't know" — and the one body down there is the leader.
name: "solo run is offered the abandon",
token: "tok-josie", status: "expedition", haveParty: true,
token: "tok-josie", status: "expedition", partyKnown: true,
abandon: true,
},
{
@@ -64,19 +64,35 @@ func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
// snapshot we do not understand, and the safe answer is to offer
// nothing rather than guess which of the two buttons applies.
name: "a party with no seat for the viewer offers nothing",
token: "tok-nobody", status: "expedition", haveParty: true, party: party,
token: "tok-nobody", status: "expedition", partyKnown: true, party: party,
},
{
// The one that came out of running it. An undecodable public sheet
// gives the same empty slice as a solo run, and treating the two alike
// offered a party MEMBER the abandon — convincingly, with the rest of
// the page looking fine. haveParty is what tells them apart.
// the page looking fine.
name: "a run whose sheet did not decode offers nothing",
token: "tok-cam", status: "expedition", haveParty: false,
token: "tok-cam", status: "expedition", partyKnown: false,
},
{
// The same empty slice again, this time from a gogobee too old to push
// seats at all. It decodes fine, so only the sender's own flag tells it
// apart from the solo case two rows up.
name: "an empty party from a sender that never pushes seats offers nothing",
token: "tok-cam", status: "expedition", partyKnown: false, party: nil,
},
{
// The asymmetry: the seat list is self-evidencing, so it keeps working
// against a sender whose capability we cannot confirm. A seat saying
// "member" is not a guess, and refusing the exit here would strand
// somebody in a party for the length of the rollout.
name: "a seated member is offered the exit even without the flag",
token: "tok-cam", status: "expedition", partyKnown: false, party: party,
leave: true,
},
{
name: "standing in town with nothing open offers nothing",
token: "tok-josie", status: "idle", haveParty: true, party: nil,
token: "tok-josie", status: "idle", partyKnown: true, party: nil,
},
{
// The case the roster status cannot see: an extracted expedition is
@@ -101,7 +117,7 @@ func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
abandon, leave, cancel := offersToUndo(tc.token, tc.status, tc.haveParty, tc.party, tc.self)
abandon, leave, cancel := offersToUndo(tc.token, tc.status, tc.partyKnown, tc.party, tc.self)
if abandon != tc.abandon || leave != tc.leave || cancel != tc.cancel {
t.Fatalf("offers = abandon:%v leave:%v cancel:%v, want abandon:%v leave:%v cancel:%v",
abandon, leave, cancel, tc.abandon, tc.leave, tc.cancel)
+31
View File
@@ -236,6 +236,37 @@ func TestSiegeHistoryRendersEndedBar(t *testing.T) {
}
}
// TestSiegeHistoryCollisionDoesNotFreezeTheWarRoom. boss_id is the history's
// primary key and it is not settled whether gogobee means the siege instance or
// the boss type by it, so two rows can arrive sharing one. Under a bare INSERT
// that failed the transaction carrying the live boss and the muster too, and the
// war room stopped moving on the last good snapshot with nothing to say why. The
// ingest has to degrade to a lost history row instead.
func TestSiegeHistoryCollisionDoesNotFreezeTheWarRoom(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
push := liveSiege(now, 650)
push.Siege.History = []storage.SiegePast{
{BossID: 3, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "survived",
HPRemaining: 300, HPMax: 1200, Defenders: 3, EndedAt: now - 86400},
{BossID: 3, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "defeated",
HPRemaining: 0, HPMax: 1200, Defenders: 6, EndedAt: now - 30*86400},
}
if w := postSiege(t, s, "tok", push); w.Code != 200 {
t.Fatalf("push with a duplicated boss_id = %d, want 200 (%s)", w.Code, w.Body.String())
}
v := s.siege()
if !v.Active || v.HPCurrent != 650 {
t.Fatalf("war room = active %v at %d hp, want the pushed live boss — the collision took the whole push down",
v.Active, v.HPCurrent)
}
if len(v.History) != 1 {
t.Errorf("history = %d rows, want 1 (the last of the colliding pair)", len(v.History))
}
}
// TestSiegeAPIFeedsTheBar. The bar only moves because this endpoint answers, so
// the field names it emits are load-bearing: the page's JS reads hp_percent to
// set the width and active to decide whether to keep polling at all.
+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)
}
}
}