diff --git a/internal/storage/adventure.go b/internal/storage/adventure.go new file mode 100644 index 0000000..20d0914 --- /dev/null +++ b/internal/storage/adventure.go @@ -0,0 +1,253 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "sort" +) + +// The durable record of what has actually happened in the realm. +// +// Every other adventure table is a snapshot gogobee replaces wholesale. This one +// accumulates, because the questions it answers are historical: what has this +// adventurer killed, where have they been, how many times have they died. The +// story feed technically holds the same information — but as English, inside a +// headline, which you cannot count. +// +// Pete still computes nothing about the *game*. It only counts facts gogobee +// already told it. No row here is ever authored by Pete or edited after insert. + +// AdvEvent is one game fact, kept as fact rather than as the sentence it was +// rendered into. Mirrors web.AdvFact minus the transport-only fields (no_push, +// stakes, class_race) that describe delivery rather than the event. +type AdvEvent struct { + GUID string `json:"guid"` + EventType string `json:"event_type"` + Tier string `json:"tier"` + Subject string `json:"subject"` + Opponent string `json:"opponent"` + Boss string `json:"boss"` + Zone string `json:"zone"` + Region string `json:"region"` + Level int `json:"level"` + Tally int `json:"tally"` + Outcome string `json:"outcome"` + Milestone string `json:"milestone"` + Actors []string `json:"actors"` + OccurredAt int64 `json:"occurred_at"` +} + +// InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE: +// gogobee retries a fact whose ack it lost, and a retried siege must not add a +// second kill to anybody's tally. The story insert upstream is guarded by +// IsGUIDSeen for the same reason; this is the same guarantee enforced by the +// table rather than by a check-then-act that a concurrent retry could race. +func InsertAdventureEvent(e *AdvEvent) error { + actors, err := json.Marshal(e.Actors) + if err != nil { + return err + } + _, err = Get().Exec(` + INSERT OR IGNORE INTO adventure_events + (guid, event_type, tier, subject, opponent, boss, zone, region, + level, tally, outcome, milestone, actors, occurred_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone, + e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, string(actors), e.OccurredAt) + return err +} + +// BossTally is one monster and how many times this adventurer has put it down. +type BossTally struct { + Boss string `json:"boss"` + Kills int `json:"kills"` + First bool `json:"first"` // they were the first in the realm to ever clear it +} + +// ZoneTally is one zone and how many times this adventurer has cleared it. +type ZoneTally struct { + Zone string `json:"zone"` + Region string `json:"region"` + Clears int `json:"clears"` + First bool `json:"first"` +} + +// TrophyCase is an adventurer's whole history, counted. +// +// Note what is *not* here: treasure. The event vocabulary gogobee sends has no +// loot fact — items appear only in the owner-private inventory snapshot, which +// is a current-state list with no history and no "found it in X on day 3". So +// "treasures found" is not derivable from anything on the wire today; it needs a +// new fact type upstream rather than a query here. Deliberately absent instead of +// faked out of the vault contents, which would silently count a bought sword as +// a trophy. +type TrophyCase struct { + Name string `json:"name"` + Bosses []BossTally `json:"bosses,omitempty"` + Zones []ZoneTally `json:"zones,omitempty"` + BossKills int `json:"boss_kills"` // total, including firsts + BossFirsts int `json:"boss_firsts"` // realm-firsts among them + ZoneClears int `json:"zone_clears"` + ZoneFirsts int `json:"zone_firsts"` + Deaths int `json:"deaths"` + Retreats int `json:"retreats"` + RivalWins int `json:"rival_wins"` + Milestones []string `json:"milestones,omitempty"` + Survived int `json:"survived"` // mischief contracts walked away from + Downed int `json:"downed"` // mischief contracts that landed + Events int `json:"events"` // total facts on file + FirstSeen int64 `json:"first_seen"` + LastSeen int64 `json:"last_seen"` +} + +// EventsBySubject returns every fact about a character, newest first. This is the +// timeline *and* the trophy source: one read, aggregated in Go, because the pool +// is MaxOpenConns(1) and six COUNT queries against it would serialize behind each +// other for an answer that fits comfortably in memory (a busy adventurer +// accumulates tens of facts, not millions). +// +// limit <= 0 means no limit, and callers who count should use it. A limit here +// silently truncates a *tally*, not just a list: read 40 facts for a display cap +// and a veteran's kill count quietly stops at 40 and stays wrong forever. Cap the +// trail in the caller, after the counting is done. +// +// Facts where the character is the *opponent* are included: a duel they lost is +// part of their history, and only the subject field would otherwise carry it. +func EventsBySubject(name string, limit int) ([]AdvEvent, error) { + if name == "" { + return nil, nil + } + q := ` + SELECT guid, event_type, tier, subject, opponent, boss, zone, region, + level, tally, outcome, milestone, actors, occurred_at + FROM adventure_events + WHERE subject = ? OR opponent = ? + ORDER BY occurred_at DESC` + args := []any{name, name} + if limit > 0 { + q += ` LIMIT ?` + args = append(args, limit) + } + rows, err := Get().Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdvEvent + for rows.Next() { + var e AdvEvent + var tier, subject, opponent, boss, zone, region, outcome, milestone, actors sql.NullString + if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent, + &boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone, + &actors, &e.OccurredAt); err != nil { + return nil, err + } + e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String + e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String + e.Outcome, e.Milestone = outcome.String, milestone.String + if actors.String != "" { + _ = json.Unmarshal([]byte(actors.String), &e.Actors) + } + out = append(out, e) + } + return out, rows.Err() +} + +// BuildTrophyCase counts a character's history out of their facts. Pure: it takes +// the rows EventsBySubject already read rather than querying again, so the who +// page pays for one database read and gets both the timeline and the trophies. +// +// Only facts where the character is the *subject* count toward trophies. They +// appear in an ally's dispatch as an opponent too, and crediting those would let +// a duel someone lost show up as a kill in their own case. +func BuildTrophyCase(name string, events []AdvEvent) TrophyCase { + tc := TrophyCase{Name: name} + bosses := map[string]*BossTally{} + zones := map[string]*ZoneTally{} + + for _, e := range events { + if tc.LastSeen == 0 || e.OccurredAt > tc.LastSeen { + tc.LastSeen = e.OccurredAt + } + if tc.FirstSeen == 0 || e.OccurredAt < tc.FirstSeen { + tc.FirstSeen = e.OccurredAt + } + tc.Events++ + + if e.Subject != name { + continue // they're the other party in someone else's dispatch + } + switch e.EventType { + case "boss_kill", "boss_first": + tc.BossKills++ + if e.Boss != "" { + b := bosses[e.Boss] + if b == nil { + b = &BossTally{Boss: e.Boss} + bosses[e.Boss] = b + } + b.Kills++ + if e.EventType == "boss_first" { + b.First = true + } + } + if e.EventType == "boss_first" { + tc.BossFirsts++ + } + case "zone_clear", "zone_first": + tc.ZoneClears++ + if e.Zone != "" { + z := zones[e.Zone] + if z == nil { + z = &ZoneTally{Zone: e.Zone, Region: e.Region} + zones[e.Zone] = z + } + z.Clears++ + if e.EventType == "zone_first" { + z.First = true + } + } + if e.EventType == "zone_first" { + tc.ZoneFirsts++ + } + case "death": + tc.Deaths++ + case "retreat": + tc.Retreats++ + case "rival_result": + tc.RivalWins++ // the fact's subject is the winner; see renderAdventure + case "milestone": + if e.Milestone != "" { + tc.Milestones = append(tc.Milestones, e.Milestone) + } + case "mischief_survived": + tc.Survived++ + case "mischief_downed": + tc.Downed++ + } + } + + for _, b := range bosses { + tc.Bosses = append(tc.Bosses, *b) + } + for _, z := range zones { + tc.Zones = append(tc.Zones, *z) + } + // Deterministic order: most-fought first, then alphabetical. The tiebreak is + // not cosmetic — map iteration is randomized in Go, so without it the panel + // reshuffles on every request and a cached page and a live one disagree. + sort.Slice(tc.Bosses, func(i, j int) bool { + if tc.Bosses[i].Kills != tc.Bosses[j].Kills { + return tc.Bosses[i].Kills > tc.Bosses[j].Kills + } + return tc.Bosses[i].Boss < tc.Bosses[j].Boss + }) + sort.Slice(tc.Zones, func(i, j int) bool { + if tc.Zones[i].Clears != tc.Zones[j].Clears { + return tc.Zones[i].Clears > tc.Zones[j].Clears + } + return tc.Zones[i].Zone < tc.Zones[j].Zone + }) + return tc +} diff --git a/internal/storage/adventure_test.go b/internal/storage/adventure_test.go new file mode 100644 index 0000000..38784c3 --- /dev/null +++ b/internal/storage/adventure_test.go @@ -0,0 +1,120 @@ +package storage + +import "testing" + +// adventure_events is the only adventure store that accumulates, so it is the +// only one where a duplicate delivery is permanently wrong: the roster forgives +// a double push by replacing itself, a double-counted boss kill is in the tally +// forever. These tests pin that, and pin what the trophy case will and won't +// credit an adventurer for. + +func ev(guid, typ, subject string, at int64) *AdvEvent { + return &AdvEvent{GUID: guid, EventType: typ, Subject: subject, OccurredAt: at} +} + +// TestInsertAdventureEventIdempotent: gogobee retries a fact whose ack it lost. +// The retry must be a no-op, not a second kill. +func TestInsertAdventureEventIdempotent(t *testing.T) { + setupTestDB(t) + e := ev("boss_kill:abc:1", "boss_kill", "Josie", 1000) + e.Boss = "The Rotmother" + + for i := 0; i < 3; i++ { + if err := InsertAdventureEvent(e); err != nil { + t.Fatalf("insert %d: %v", i, err) + } + } + events, err := EventsBySubject("Josie", 0) + if err != nil { + t.Fatalf("EventsBySubject: %v", err) + } + if len(events) != 1 { + t.Fatalf("after 3 deliveries of one fact: got %d rows, want 1", len(events)) + } + if tc := BuildTrophyCase("Josie", events); tc.BossKills != 1 { + t.Fatalf("boss kills: got %d, want 1 — a retry inflated the tally", tc.BossKills) + } +} + +// TestTrophyCaseCounts walks a full history and checks each tally. +func TestTrophyCaseCounts(t *testing.T) { + setupTestDB(t) + + first := ev("boss_first:1:1", "boss_first", "Josie", 100) + first.Boss = "The Rotmother" + repeat := ev("boss_kill:2:2", "boss_kill", "Josie", 200) + repeat.Boss = "The Rotmother" + other := ev("boss_kill:3:3", "boss_kill", "Josie", 300) + other.Boss = "Gravebloom" + zone := ev("zone_clear:4:4", "zone_clear", "Josie", 400) + zone.Zone, zone.Region = "holymachina", "the Reach" + death := ev("death:5:5", "death", "Josie", 500) + mile := ev("milestone:6:6", "milestone", "Josie", 600) + mile.Milestone = "level 20" + + for _, e := range []*AdvEvent{first, repeat, other, zone, death, mile} { + if err := InsertAdventureEvent(e); err != nil { + t.Fatalf("insert %s: %v", e.GUID, err) + } + } + + events, err := EventsBySubject("Josie", 0) + if err != nil { + t.Fatalf("EventsBySubject: %v", err) + } + tc := BuildTrophyCase("Josie", events) + + if tc.BossKills != 3 { + t.Errorf("boss kills: got %d, want 3 (a first is still a kill)", tc.BossKills) + } + if tc.BossFirsts != 1 { + t.Errorf("boss firsts: got %d, want 1", tc.BossFirsts) + } + if tc.ZoneClears != 1 || tc.Deaths != 1 { + t.Errorf("zones/deaths: got %d/%d, want 1/1", tc.ZoneClears, tc.Deaths) + } + if len(tc.Milestones) != 1 || tc.Milestones[0] != "level 20" { + t.Errorf("milestones: got %v, want [level 20]", tc.Milestones) + } + // Ordering is by kills desc: the twice-killed Rotmother outranks Gravebloom. + if len(tc.Bosses) != 2 || tc.Bosses[0].Boss != "The Rotmother" || tc.Bosses[0].Kills != 2 { + t.Fatalf("boss tallies: got %+v, want Rotmother×2 first", tc.Bosses) + } + if !tc.Bosses[0].First { + t.Error("Rotmother should be flagged as a realm-first") + } + if tc.FirstSeen != 100 || tc.LastSeen != 600 { + t.Errorf("span: got %d..%d, want 100..600", tc.FirstSeen, tc.LastSeen) + } +} + +// TestTrophyCaseIgnoresOpponentCredit is the one that matters for honesty. A +// duel Josie *lost* still names her, as the opponent in Quack's dispatch. It +// belongs on her timeline but must never be credited to her trophy case. +func TestTrophyCaseIgnoresOpponentCredit(t *testing.T) { + setupTestDB(t) + + lost := ev("rival_result:1:1", "rival_result", "Quack", 100) + lost.Opponent = "Josie" // Quack won; Josie is the one who got beaten + won := ev("rival_result:2:2", "rival_result", "Josie", 200) + won.Opponent = "Quack" + + for _, e := range []*AdvEvent{lost, won} { + if err := InsertAdventureEvent(e); err != nil { + t.Fatalf("insert: %v", err) + } + } + + events, err := EventsBySubject("Josie", 0) + if err != nil { + t.Fatalf("EventsBySubject: %v", err) + } + // Both facts are hers to *see* — the loss is part of her story. + if len(events) != 2 { + t.Fatalf("timeline: got %d events, want 2 (a loss is still history)", len(events)) + } + // But only the one she won is hers to *claim*. + if tc := BuildTrophyCase("Josie", events); tc.RivalWins != 1 { + t.Fatalf("rival wins: got %d, want 1 — a loss was credited as a win", tc.RivalWins) + } +} diff --git a/internal/storage/schema.go b/internal/storage/schema.go index d173304..b60b335 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -52,6 +52,46 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta ( snapshot_at INTEGER NOT NULL DEFAULT 0 ); +-- adventure_events is the structured residue of a dispatch, and it is the one +-- adventure table that is a *log* rather than a snapshot. That inversion is the +-- point. The roster answers "where is Josie now" and is replaced every tick; +-- this answers "what has Josie ever done", which no snapshot can, because each +-- push throws the last one away. +-- +-- It exists because the facts were already arriving and we were burning them. +-- gogobee sends boss/opponent/zone/outcome on every fact; renderAdventure melted +-- them into a sentence and only the sentence was kept, so "how many bosses has +-- she downed" was answerable only by parsing English back out of a headline. +-- These rows are that fact, kept as fact. The story row remains the thing people +-- read; this is the thing we can count. +-- +-- Keyed on guid, the same idempotency key as stories: a gogobee retry must not +-- double-count a kill. subject/opponent are *character names*, not roster tokens, +-- because that is what a fact carries and what the feed already prints in public. +-- Names are therefore the join back to a board row (roster.name -> token), which +-- is weaker than an id: a renamed or recycled character takes their history with +-- them. gogobee doesn't put a stable character id on the wire today, and inventing +-- one Pete-side would only be a guess at which two names were the same person. +CREATE TABLE IF NOT EXISTS adventure_events ( + guid TEXT PRIMARY KEY, -- == stories.guid; the dispatch this fact rendered into + event_type TEXT NOT NULL, + tier TEXT, -- "priority" | "bulletin" + subject TEXT, -- character name: the one it happened to + opponent TEXT, -- character name: the other player, when there is one + boss TEXT, -- game-authored monster name, not player-controlled + zone TEXT, + region TEXT, + level INTEGER NOT NULL DEFAULT 0, + tally INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ... + outcome TEXT, + milestone TEXT, + actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit + occurred_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> ''; +CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC); + -- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed. -- Keyed by localpart (== Authentik preferred_username == the session's Username), -- a *separate keyspace* from the anonymous roster tokens on purpose: it is only diff --git a/internal/web/adventure.go b/internal/web/adventure.go index e66d31b..ddb5b79 100644 --- a/internal/web/adventure.go +++ b/internal/web/adventure.go @@ -132,6 +132,30 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { http.Error(w, "insert failed", http.StatusInternalServerError) return } + // Keep the fact itself, not just the sentence we made out of it. The story row + // above is what people read; this is what the trophy case and timeline can + // count. Best-effort on purpose: a dispatch that published is published, and + // losing its structured twin costs a tally, not the news. Failing the request + // here would make gogobee retry a fact we already have a story for. + if err := storage.InsertAdventureEvent(&storage.AdvEvent{ + GUID: f.GUID, + EventType: f.EventType, + Tier: f.Tier, + Subject: f.Subject, + Opponent: f.Opponent, + Boss: f.Boss, + Zone: f.Zone, + Region: f.Region, + Level: f.Level, + Tally: f.Count, + Outcome: f.Outcome, + Milestone: f.Milestone, + Actors: f.Actors, + OccurredAt: occurredAt, + }); err != nil { + slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err) + } + slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier) // NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only diff --git a/internal/web/templates/who.html b/internal/web/templates/who.html index 1d2df75..c6b4a87 100644 --- a/internal/web/templates/who.html +++ b/internal/web/templates/who.html @@ -79,6 +79,107 @@ {{end}} + {{if .HasHistory}} + +
+

The record

+ +
+
+
{{.Trophies.BossKills}}
+
bosses down
+
+
+
{{.Trophies.ZoneClears}}
+
zones cleared
+
+
+
{{.Trophies.Deaths}}
+
death{{if ne .Trophies.Deaths 1}}s{{end}}
+
+
+
{{.Trophies.Retreats}}
+
walked out
+
+
+ + {{if or .Trophies.BossFirsts .Trophies.ZoneFirsts}} +

+ ★ {{.Trophies.BossFirsts}} realm-first boss{{if ne .Trophies.BossFirsts 1}}es{{end}}{{if .Trophies.ZoneFirsts}}, {{.Trophies.ZoneFirsts}} first clear{{if ne .Trophies.ZoneFirsts 1}}s{{end}}{{end}} — nobody had done it before. +

+ {{end}} + +
+ {{if .Trophies.Bosses}} +
+

Bosses fought

+
    + {{range .Trophies.Bosses}} +
  • + {{.Boss}}{{if .First}} {{end}} + ×{{.Kills}} +
  • + {{end}} +
+
+ {{end}} + + {{if .Trophies.Zones}} +
+

Ground covered

+
    + {{range .Trophies.Zones}} +
  • + {{.Zone}}{{if .First}} {{end}}{{if .Region}} {{.Region}}{{end}} + ×{{.Clears}} +
  • + {{end}} +
+
+ {{end}} +
+ + {{if .Trophies.Milestones}} +
+

Milestones

+
+ {{range .Trophies.Milestones}} + 🏅 {{.}} + {{end}} +
+
+ {{end}} +
+ + +
+

The trail

+
    + {{range .Timeline}} +
  1. + + +
    + {{.Label}} + {{.When}} +
    + {{if .Line}}

    {{.Line}}

    {{end}} +
    +
  2. + {{end}} +
+ {{if .MoreHistory}} +

+ Showing the most recent {{len .Timeline}}. The counts above cover everything. +

+ {{end}} +
+ {{end}} + {{if .HasSelf}} diff --git a/internal/web/who.go b/internal/web/who.go index 1f22f45..f7520f0 100644 --- a/internal/web/who.go +++ b/internal/web/who.go @@ -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. diff --git a/internal/web/who_test.go b/internal/web/who_test.go index 305a76f..67db831 100644 --- a/internal/web/who_test.go +++ b/internal/web/who_test.go @@ -81,6 +81,33 @@ func seedWho(t *testing.T, owner string) *Server { return s } +// seedHistory pushes facts about Josie through the real ingest path, so the +// history panels are driven by rows that arrived the way gogobee sends them +// rather than by a hand-built struct that could drift from the contract. +func seedHistory(t *testing.T, s *Server) { + t.Helper() + facts := []AdvFact{ + {GUID: "boss_first:a:1", EventType: "boss_first", Tier: "priority", Actors: []string{"Josie"}, + Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", Region: "the Reach", OccurredAt: 100}, + {GUID: "boss_kill:b:2", EventType: "boss_kill", Tier: "bulletin", Actors: []string{"Josie"}, + Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", OccurredAt: 200}, + {GUID: "death:c:3", EventType: "death", Tier: "priority", Actors: []string{"Josie"}, + Subject: "Josie", Zone: "holymachina", Level: 14, OccurredAt: 300}, + {GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"}, + Subject: "Josie", Milestone: "level 14", OccurredAt: 400}, + } + for _, f := range facts { + body, _ := json.Marshal(f) + req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + s.handleAdventureIngest(w, req) + if w.Code != 200 { + t.Fatalf("seed fact %s = %d: %s", f.GUID, w.Code, w.Body.String()) + } + } +} + func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder { t.Helper() var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil) @@ -197,3 +224,45 @@ func TestDetailIngestReplacesAndAuth(t *testing.T) { t.Error("public page vanished when only the private detail was dropped") } } + +// TestWhoHistoryPanels: the record and the trail render for an adventurer with +// facts on file. Driven through the real template and the real ingest path, so a +// field slip in either 500s here rather than in prod. +func TestWhoHistoryPanels(t *testing.T) { + s := seedWho(t, "josie") + seedHistory(t, s) + + w := getWho(t, s, "tok-josie", "") + if w.Code != 200 { + t.Fatalf("who = %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + for _, want := range []string{ + "The record", + "The Rotmother", // the boss tally + "bosses down", + "realm-first", // the boss_first got flagged + "level 14", // the milestone chip + "The trail", + "/adventure/boss_first:a:1", // the trail links back to the dispatch + } { + if !strings.Contains(body, want) { + t.Errorf("history render missing %q", want) + } + } +} + +// TestWhoHistoryAbsent: an adventurer with no facts on file gets no empty +// scaffolding. Every veteran looks like this the day this ships — their past is +// prose in the feed and was never counted — so the blank state has to be a clean +// absence, not a wall of zeroes. +func TestWhoHistoryAbsent(t *testing.T) { + s := seedWho(t, "josie") + + body := getWho(t, s, "tok-josie", "").Body.String() + for _, leak := range []string{"The record", "The trail", "bosses down"} { + if strings.Contains(body, leak) { + t.Errorf("no-history page should not render %q", leak) + } + } +}