adventure: tell the story of a run, not just how it ended
Pete only ever heard that an expedition happened once it was over — a zone cleared, a retreat, a death. The run itself was narrated into one Matrix DM and thrown away. The map on the adventurer page has always shown where somebody is; this shows what happened there. Beats arrive on their own channel, append-only and idempotent on (run_id, seq). They are the one thing gogobee pushes that is history rather than state, so they accumulate instead of replacing — and they stay off the dispatch queue so a chatty run can never spend the retry budget a death dispatch depends on. The run header is derived from the beats rather than pushed: a run whose start beat never arrived still gets a readable, unattributed log instead of being dropped for want of a name. An unknown beat kind renders as its own noun rather than 400ing. That is the same lesson the dispatch ingest learned the hard way, and the regression test covers the class, not the case. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
+21
-4
@@ -83,8 +83,13 @@ type whoPage struct {
|
||||
Detail whoDetail
|
||||
Abilities []abilityRow
|
||||
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
// RunLog is the liveblog for the run the map is showing: what happened in
|
||||
// those rooms, in order. Deliberately independent of MapView — the map rides
|
||||
// the roster snapshot and the log rides the beat channel, so either can be
|
||||
// present without the other and the page must not assume they arrive together.
|
||||
RunLog RunLogView
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
||||
// only means something on a worn item — see itemRow.
|
||||
Worn []itemRow
|
||||
@@ -197,6 +202,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
page.Abilities = abil
|
||||
page.MapView = buildMapView(d.Map)
|
||||
}
|
||||
page.RunLog = runLogFor(token)
|
||||
|
||||
// History: one read feeds both the trophy case and the trail. Keyed on the
|
||||
// character *name* rather than the page token, because that is what a fact
|
||||
@@ -268,8 +274,15 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if !ok {
|
||||
// Gone from the board (expedition ended, opted out): tell the poller so it
|
||||
// can stop, rather than 404-ing an open tab into an error.
|
||||
// Gone from the board: tell the poller so it can stop, rather than 404-ing
|
||||
// an open tab into an error.
|
||||
//
|
||||
// No run log here, deliberately. Finishing a run does NOT take an
|
||||
// adventurer off the board — they stay on it as idle, and their last log
|
||||
// keeps rendering through this endpoint's normal path. What DOES take them
|
||||
// off it is opting out or being removed, and shipping a room-by-room
|
||||
// account of where somebody is from the one branch that means "this player
|
||||
// asked not to be listed" would make the API say what the page refuses to.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
|
||||
return
|
||||
}
|
||||
@@ -280,6 +293,10 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
"has_detail": hasDetail,
|
||||
"detail": d,
|
||||
"abilities": abil,
|
||||
// The liveblog rides the sheet's poll rather than a second timer: the two
|
||||
// move on the same 2-minute push and a separate request would just be a
|
||||
// second way to be out of step with the map beside it.
|
||||
"run_log": runLogFor(token),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user