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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user