Files
Pete/code_review_findings_w9.md
T
prosolis c40ac1e673 adventure: write up the five review findings left for a follow-up
Same review, the half not fixed in place: the extract pre-check treating a
stale snapshot as the last word, offersToUndo's party guard not covering the
case its comment claims, a duplicated boss_id failing the whole war-room
replace, an "already out there" that Pete can't actually know, and the
siege_join check loading the entire siege to read one flag.

Each one has the direction already decided and the edits and tests spelled
out, so the follow-up session doesn't have to re-derive any of it. Delete the
file when they're done.
2026-07-24 22:36:42 -07:00

182 lines
9.5 KiB
Markdown

# Code review findings + fix plan — w9-verbs-and-polish
From a `/code-review --fix` pass on the branch diff, 2026-07-24. Two findings were
fixed in that session; the five below were left for a follow-up session, with the
direction chosen by the author. Work them in the order given — items 1/2 and 3
touch different files and are independent, item 4 is the only one with a
gogobee-side half.
Already fixed on this branch (don't redo):
- `internal/web/orders.go` verdict handler — non-`ErrNoSuchAdvOrder` storage
failures now return 500 instead of 400, via a new `storage.ErrBadAdvVerdict`.
A 400 makes gogobee park the row for a human, so a transient SQLite blip used
to permanently strand a resolvable order.
- `internal/web/push_adventure.go``advStoryURL` / `advRunOrStoryURL` now
path-escape the guid and run id like every other URL builder does.
---
## 1. Drop the extract pre-check — `internal/web/orders.go:129`
**Why.** The comment above the pre-checks says they read a snapshot up to two
minutes behind and that "neither is allowed to be the last word", and `who.html`
promises the button works even on an idle-looking mark. But
`if haveEntry && entry.Status != "expedition"` returns 409 and no order is ever
created. A player who set out via Matrix during a lagging roster push (staleness
is tolerated up to 12 minutes) gets told they're not on an expedition for a run
gogobee would happily have ended.
**Decision: drop it.** Same call `abandon` and `leave` already make one function
down — and the reasoning there (an *extracted* expedition is still abandonable
while its owner reads as idle) applies to extract too. Cost accepted: a genuine
mistake now comes back as a verdict rather than an instant 409.
**Do.**
- Delete the `case storage.AdvActionExtract:` arm. Replace it with a comment in
the shape of the `AdvActionAbandon, AdvActionLeave` one in
`resolveAdvOrderParams` — say that the snapshot is stale, that a Matrix
departure can outrun the roster push, and that `rejected_not_running` is the
honest answer.
- With extract gone the `switch req.Action` has one arm left; collapse it to an
`if req.Action == storage.AdvActionSiegeJoin` (see item 2, which rewrites the
body anyway).
- `characterName` is still needed for the insert, so keep the `RosterEntryByToken`
lookup and its error path; only the status test goes.
- Nothing to do client-side: `adventure-actions.js:28` already renders
`rejected_not_running` as "couldn't, you weren't on an expedition", which is
the string the 409 was standing in for.
**Tests.** `TestActionPreChecksAreCourtesyOnly` in `internal/web/orders_test.go`
pins the old behaviour — its first block asserts `extract` while idle is a 409.
Flip it to 200 and rewrite the doc comment, which currently describes an
asymmetry that no longer exists (both halves are now "let it through" except the
one positive siege case). Consider renaming it to match. Its two siege blocks
stay exactly as they are.
## 2. Make the siege_join pre-check cheap — `internal/web/orders.go:135`
**Why.** It calls `storage.LoadSiege()`, which loads every defender row and the
whole siege history, to read one `active` flag, on a pool that is
`MaxOpenConns(1)`. Once per click, so not urgent, but it is a one-column read.
**Keep the check itself.** Unlike a personal status, "is a boss 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 — the reason item 1 goes the other way doesn't apply.
**Do.**
- Add `SiegeIsCamped() (active, known bool, err error)` to
`internal/storage/siege.go`, next to `LoadSiege`. One
`SELECT active FROM adventure_siege WHERE id = 1`; `sql.ErrNoRows` means
known=false.
- The known/active distinction is load-bearing and already pinned by
`TestActionPreChecksAreCourtesyOnly`: 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. Keep `LoadSiege`'s doc note about that on the new
function.
- Swap the call in `orders.go`. No behaviour change, so no new test beyond the
existing one continuing to pass.
## 3. Make the war-room history replace collision-proof — `internal/storage/siege.go:116`
**Why.** `adventure_siege_history` has `boss_id` as PRIMARY KEY and `ReplaceSiege`
uses a bare `INSERT`, inside the transaction that also writes the live boss and
the muster. Two history rows sharing a `boss_id` fail the whole replace, so the
war room freezes on the previous snapshot indefinitely with no partial
degradation. It is genuinely unclear whether `boss_id` is the siege instance or
the boss type — `SiegeBarForBoss` matches history on `boss_name` + nearest
`ended_at` and its comment says "the same boss comes back month after month",
which reads like type.
**Decision: don't settle the question, make it survivable.** `INSERT OR REPLACE`
can never lose a row that would otherwise have landed; it just keeps the last of
a colliding pair instead of 500ing the ingest.
**Do.**
- `hstmt` becomes `INSERT OR REPLACE INTO adventure_siege_history (...)`.
- Comment why, at the statement: the whole table is deleted and rebuilt from
gogobee's list every push, so a duplicate key is a wire quirk and not data
loss, and a frozen war room is a worse failure than a dropped history row.
- Also note the open question in `schema.go` above the `boss_id INTEGER PRIMARY
KEY` line, so the next reader knows the key's meaning was never confirmed.
**Tests.** New one in `internal/web/siege_test.go` alongside the existing push
tests: post a snapshot whose history carries two rows with the same `boss_id`,
assert the push succeeds and the war room shows the new live boss — i.e. the
ingest degrades to one history row rather than freezing.
## 4. Close the party-guard hole — `internal/web/who.go:133` (+ gogobee)
**Why.** `offersToUndo` receives `page.HasDetail`, which only means "the blob
decoded". Its comment claims the guard also catches "a gogobee too old to push
seats", but such a gogobee pushes a *valid* blob with no `party` key: `HasDetail`
is true, `Party` is nil, and the code takes the "solo ⇒ this player is the
leader" branch and shows a party **member** "Call the whole thing off" — the
exact outcome the comment says it prevents. gogobee refuses with
`rejected_not_leader`, so today's cost is a misleading offer, not a lost
expedition.
A nil `Party` cannot be told apart from a solo run on the current wire, so this
needs a new field. **Decision: spec the wire change.**
**Note the asymmetry before writing any code:** only the `len(party) == 0` branch
is unsafe. The `len(party) > 0` branch reads the viewer's *own* seat and is
self-evidencing — it cannot mistake a member for a leader. So gate the empty
branch alone, and the rollout costs nothing but a solo leader's party-branch
abandon in the window before gogobee ships (the `self.Resume != nil` branch below
still covers the extracted case).
**Pete side.**
- `whoDetail` gains `PartyKnown bool \`json:"party_known"\`` with a comment
saying what nil-vs-empty could not express.
- `offersToUndo`'s third parameter changes from `haveParty` to `partyKnown`;
pass `page.HasDetail && page.Detail.PartyKnown` at the call site (`who.go:405`)
so a blob that failed to decode is still excluded.
- Move the guard: keep `status == rosterStatusExpedition` on the outer `if`, and
put `partyKnown` on the `len(party) == 0` branch only.
- Rewrite the doc comment — it is currently wrong about what the guard buys, and
the "found by getting a fixture wrong" note should stay but now point at the
real signal.
**gogobee side (separate repo).** Write it up as a contract the way
`adventure_ask7_equipment_mgmt.md` was: the public detail blob on the roster push
sets `"party_known": true` whenever the sheet was built by a gogobee that knows
about party seats — unconditionally, including on a solo run, and including when
the party list is omitted. It is a capability flag about the *sender*, not a fact
about the character, so it must never be conditional on there being a party.
**Tests.** `internal/web/orders_undo_test.go` already tables this function. Add
cases: `partyKnown=false` + empty party + expedition ⇒ no abandon (the old
gogobee); `partyKnown=true` + empty party ⇒ abandon (genuine solo);
`partyKnown=false` + a party naming the viewer as member ⇒ leave still offered
(the self-evidencing branch must not regress).
## 5. Stop asserting a fact Pete can't know — `internal/web/orders.go:210`
**Why.** `resolveAdvOrderParams` treats `len(detail.Zones) == 0` as proof the
player is on an expedition and says "you're already out there", but an empty
offer list is equally what an out-of-date game box sends. Only reachable via a
hand-crafted request today (with no offers the picker doesn't render), so this is
message honesty, not a live bug.
**Do.** Keep the refusal, reword it so it describes what Pete actually saw —
something like "nowhere is on offer for you right now" — and adjust the comment
above the branch, which states the "already out there" reading as fact. Then fix
the two places that echo it: the doc comment on
`TestNoZoneOffersMeansAlreadyOut` (`orders_test.go:331`) and, if you want them to
match, nothing else — `adventure-actions.js:33`'s `rejected_busy` string is
gogobee's own verdict and is correct as it stands.
---
## Wrap-up
- `go build ./...`, `go vet ./...`, `go test ./...` after each item.
- `gofmt -l` already flags `internal/storage/orders.go` on this branch (pre-existing
hand-aligned comment blocks); don't let that fool you into reformatting it.
- Delete this file once the five are done.