adventure: keep the facts, not just the sentence we made of them
gogobee already sends boss/opponent/zone/outcome/level on every dispatch. renderAdventure melted them into prose and only the prose was persisted, so "how many bosses has she downed" was answerable only by parsing English back out of a headline. adventure_events keeps the fact as fact. It's the one adventure table that's a log rather than a snapshot: the roster answers where Josie is now and is replaced every tick; this answers what she has ever done, which no snapshot can. INSERT OR IGNORE on the guid because gogobee retries a fact whose ack it lost, and this is the only adventure store where a duplicate is permanently wrong — the roster forgives one by replacing itself, a double-counted kill is in the tally forever. On top of it the who page grows two public sections, counted from dispatches that were already public: the record (tallies, per-boss and per-zone, realm firsts, milestones) and the trail (the last 40 facts, each linking to the dispatch that told it). One read feeds both — the pool is MaxOpenConns(1), so six COUNT queries would serialize for an answer that fits in memory. Only the trail is capped. A limit on the read would truncate a *tally* rather than a list, and a veteran's kill count frozen at 40 reads as a fact instead of a missing page. Only the subject of a fact earns a trophy: a duel you lost still names you, and it belongs on your trail but not in your record. Trophies count forward only. Every adventurer's past is prose in the feed and can't be counted back out, so they show no record until they next do something — a clean absence rather than a wall of zeroes. No treasures: there's no loot fact on the wire, and inventory is current-state with no history, so counting the vault would score a bought sword as a trophy. Needs a fact type upstream.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
@@ -59,8 +60,35 @@ type whoPage struct {
|
||||
Abilities []abilityRow
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
// History. Unlike everything above, these are not a gogobee snapshot — they
|
||||
// are counted from the facts Pete has been keeping since adventure_events
|
||||
// landed. HasHistory is false for an adventurer who hasn't done anything since
|
||||
// then, which includes every veteran on the day this shipped: their past is in
|
||||
// the story feed as prose and cannot be counted back out.
|
||||
Trophies storage.TrophyCase
|
||||
HasHistory bool
|
||||
Timeline []timelineEntry
|
||||
MoreHistory bool // the trail was capped; there is older history than this
|
||||
}
|
||||
|
||||
// timelineEntry is one line of an adventurer's trail — a fact, rendered short,
|
||||
// pointing at the dispatch that told it.
|
||||
type timelineEntry struct {
|
||||
Emoji string
|
||||
Label string
|
||||
Line string // "The Rotmother, in holymachina"
|
||||
When string
|
||||
Permalink string
|
||||
Notable bool // a realm-first or a death: the lines worth an eye
|
||||
}
|
||||
|
||||
// timelineCap bounds the trail. An adventurer accrues facts for as long as they
|
||||
// play, and the page renders the whole list server-side — this is the point where
|
||||
// "their whole history" stops being a page and starts being a scroll nobody
|
||||
// reads. The count in the trophy case stays honest either way; only the trail is
|
||||
// clipped.
|
||||
const timelineCap = 40
|
||||
|
||||
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
|
||||
// token isn't on the current board — the same liveness gate the storefront uses,
|
||||
// so a stale or guessed token never resolves to a page.
|
||||
@@ -96,6 +124,24 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
page.Abilities = abil
|
||||
}
|
||||
|
||||
// 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
|
||||
// carries — see the adventure_events schema note on why there is no id to use.
|
||||
// Best-effort: a page that can't count trophies is still a page.
|
||||
// Unlimited on purpose: the trophy case counts these, and a limit would cap the
|
||||
// tally rather than the list. The trail is clipped below, after the counting.
|
||||
if events, err := storage.EventsBySubject(entry.Name, 0); err != nil {
|
||||
slog.Error("who: history lookup failed", "token", token, "err", err)
|
||||
} else if len(events) > 0 {
|
||||
page.HasHistory = true
|
||||
page.Trophies = storage.BuildTrophyCase(entry.Name, events)
|
||||
page.MoreHistory = len(events) > timelineCap
|
||||
if page.MoreHistory {
|
||||
events = events[:timelineCap]
|
||||
}
|
||||
page.Timeline = buildTimeline(s, events)
|
||||
}
|
||||
|
||||
// Owner enrichment: only when the signed-in user's localpart owns this exact
|
||||
// page token. The ownership is proven by a row gogobee pushed, never by
|
||||
// reversing the token, so no visitor can unlock another player's self extras.
|
||||
@@ -144,6 +190,62 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// buildTimeline renders facts into trail lines.
|
||||
//
|
||||
// The line is built from the fact, not from the dispatch's headline, on purpose:
|
||||
// the headline is a *news* sentence written for the moment it landed ("First
|
||||
// ever: Josie brings down the Rotmother"), and forty of those stacked in a column
|
||||
// read as a wall of shouting. The trail wants the noun, not the announcement. It
|
||||
// also saves a join back to stories for every row.
|
||||
func buildTimeline(s *Server, events []storage.AdvEvent) []timelineEntry {
|
||||
out := make([]timelineEntry, 0, len(events))
|
||||
for _, e := range events {
|
||||
label, emoji := advEventMeta(e.EventType)
|
||||
out = append(out, timelineEntry{
|
||||
Emoji: emoji,
|
||||
Label: label,
|
||||
Line: timelineLine(e),
|
||||
When: time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(e.GUID),
|
||||
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// timelineLine is the short "what" of a fact: the monster, the zone, the rival.
|
||||
// Empty is fine — the label and emoji already carry the event, and inventing
|
||||
// filler for a fact that carries no nouns would just be noise.
|
||||
func timelineLine(e storage.AdvEvent) string {
|
||||
inZone := func(s string) string {
|
||||
if e.Zone == "" {
|
||||
return s
|
||||
}
|
||||
if s == "" {
|
||||
return e.Zone
|
||||
}
|
||||
return s + ", in " + e.Zone
|
||||
}
|
||||
switch e.EventType {
|
||||
case "boss_first", "boss_kill", "siege_start", "siege_win", "siege_loss",
|
||||
"mischief_survived", "mischief_downed":
|
||||
return inZone(e.Boss)
|
||||
case "zone_first", "zone_clear", "retreat", "departure", "death":
|
||||
if e.Region != "" && e.Zone != "" {
|
||||
return e.Zone + " — " + e.Region
|
||||
}
|
||||
return e.Zone
|
||||
case "rival_result", "pete_duel_win", "pete_duel_loss":
|
||||
if e.Opponent != "" {
|
||||
return "vs " + e.Opponent
|
||||
}
|
||||
return ""
|
||||
case "milestone":
|
||||
return e.Milestone
|
||||
}
|
||||
return inZone("")
|
||||
}
|
||||
|
||||
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
|
||||
// Returns ok=false when there is no detail (a snapshot from before the detail
|
||||
// push), so the page can fall back to the summary the board already has.
|
||||
|
||||
Reference in New Issue
Block a user