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:
prosolis
2026-07-16 23:57:23 -07:00
parent b814f936a8
commit cbe9e67b3e
7 changed files with 709 additions and 0 deletions

View File

@@ -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.