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:
253
internal/storage/adventure.go
Normal file
253
internal/storage/adventure.go
Normal file
@@ -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
|
||||
}
|
||||
120
internal/storage/adventure_test.go
Normal file
120
internal/storage/adventure_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,6 +79,107 @@
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasHistory}}
|
||||
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||||
same information the /adventure feed already printed, only as numbers
|
||||
instead of as forty separate sentences. Not a gogobee snapshot: Pete
|
||||
counted these itself out of the facts it kept. -->
|
||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">The record</h2>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.BossKills}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">bosses down</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.ZoneClears}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">zones cleared</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Deaths}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">death{{if ne .Trophies.Deaths 1}}s{{end}}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Retreats}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">walked out</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if or .Trophies.BossFirsts .Trophies.ZoneFirsts}}
|
||||
<p class="mt-3 text-sm text-theme-adventure font-semibold">
|
||||
★ {{.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.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<div class="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
{{if .Trophies.Bosses}}
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-bold mb-3">Bosses fought</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||
{{range .Trophies.Bosses}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Boss}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to clear it">★</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Kills}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Trophies.Zones}}
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-bold mb-3">Ground covered</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||
{{range .Trophies.Zones}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Zone}}{{if .First}} <span class="text-theme-adventure" title="first clear in the realm">★</span>{{end}}{{if .Region}} <span class="text-[color:var(--ink)]/45">{{.Region}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Clears}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Trophies.Milestones}}
|
||||
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
|
||||
<h3 class="font-display text-lg font-bold mb-3">Milestones</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{range .Trophies.Milestones}}
|
||||
<span class="rounded-full bg-theme-adventure/10 text-theme-adventure text-xs font-semibold px-3 py-1">🏅 {{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- The trail: every fact we have, newest first, each linking to the dispatch
|
||||
that told it. -->
|
||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">The trail</h2>
|
||||
<ol class="relative border-l-2 border-[color:var(--ink)]/10 ml-3 space-y-4">
|
||||
{{range .Timeline}}
|
||||
<li class="relative pl-6">
|
||||
<span class="absolute -left-[13px] top-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-[color:var(--card)] border-2 {{if .Notable}}border-theme-adventure{{else}}border-[color:var(--ink)]/10{{end}} text-xs" aria-hidden="true">{{.Emoji}}</span>
|
||||
<a href="{{.Permalink}}" class="group block">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<span class="font-semibold group-hover:text-theme-adventure transition {{if .Notable}}text-theme-adventure{{end}}">{{.Label}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.When}}</span>
|
||||
</div>
|
||||
{{if .Line}}<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">{{.Line}}</p>{{end}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
{{if .MoreHistory}}
|
||||
<p class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 text-xs text-[color:var(--ink)]/45">
|
||||
Showing the most recent {{len .Timeline}}. The counts above cover everything.
|
||||
</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasSelf}}
|
||||
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
||||
only because your signed-in localpart owns this page's token. -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user