The liveblog answers "what is happening" — it is capped, it scrolls, and six hours after a run ends it is gone, because the adventurer page is about now. Nothing answered the question asked afterwards, usually by somebody who wasn't watching: what WAS that run. So a dispatch announcing a clear or a death was a paragraph about an outcome with no way back to what produced it. The report is that way back. The whole log uncapped, the numbers rolled up, and the single worst hit the party took pulled out of the middle where it otherwise reads as one line among forty. It is stable for a fortnight, which is what makes it a thing worth linking from a dispatch and worth sending to somebody. It is assembled from the same beats through the same renderer as the liveblog. A report that told a different story from the log it was built out of would be the more convincing of the two and the less true. The summary is the exception and the only prose on the channel: gogobee's model reads the finished run back and says what it was about, which is a judgement no template makes. It rides a summary beat rather than its own endpoint, so it inherits the whole channel — idempotent, retried, impossible to attach to a run that doesn't exist — and it passes the same class of guard a dispatch lede does before it reaches a public page. Visibility is the adventurer page's rule exactly, and that matters more here than anywhere: the report outlives the log by a fortnight and is linked from a public dispatch, so it is the surface most likely to still be reachable after somebody opts out. Coming off the board closes it, including through links minted days earlier. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
351 lines
13 KiB
Go
351 lines
13 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// 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,
|
|
// class_race) that describe delivery rather than the event. Stakes is the one
|
|
// former transport field kept here: for a treasure_found it carries the item's
|
|
// name, which is the fact, not the delivery.
|
|
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"`
|
|
Stakes string `json:"stakes"`
|
|
Actors []string `json:"actors"`
|
|
// RunID is set on the dispatches that are the *ending* of an expedition — a
|
|
// clear, a retreat, a death. It is the join from "how it went" to "what
|
|
// happened", and it is empty on every other kind of fact.
|
|
RunID string `json:"run_id,omitempty"`
|
|
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, stakes, actors, run_id, 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, e.Stakes, string(actors),
|
|
e.RunID, e.OccurredAt)
|
|
return err
|
|
}
|
|
|
|
// AdventureEventByGUID returns the stored fact behind one dispatch, or nil when
|
|
// there isn't one. Missing is normal, not an error: the story row is inserted
|
|
// first and the fact record is best-effort (see handleAdventureIngest), and every
|
|
// dispatch that predates the fact table has a story and no fact at all. Callers
|
|
// render the thinner event_type-only view in that case.
|
|
func AdventureEventByGUID(guid string) (*AdvEvent, error) {
|
|
if guid == "" {
|
|
return nil, nil
|
|
}
|
|
var e AdvEvent
|
|
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors, runID sql.NullString
|
|
err := Get().QueryRow(`
|
|
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
|
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at
|
|
FROM adventure_events WHERE guid = ?`, guid).Scan(
|
|
&e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion,
|
|
&e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &runID, &e.OccurredAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if 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, e.Stakes = outcome.String, milestone.String, stakes.String
|
|
e.RunID = runID.String
|
|
if actors.String != "" {
|
|
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
|
|
}
|
|
return &e, nil
|
|
}
|
|
|
|
// AdventureEventFacets returns the (event_type, tier, outcome) of many dispatches
|
|
// in one read, keyed by guid. It exists so a feed page can tint two dozen cards
|
|
// by what they actually are without paying a query per card — the pool is
|
|
// MaxOpenConns(1), so N round trips would serialize behind each other.
|
|
//
|
|
// Guids absent from the map are dispatches with no fact row; the caller leaves
|
|
// those untinted rather than guessing.
|
|
func AdventureEventFacets(guids []string) map[string]AdvEvent {
|
|
out := make(map[string]AdvEvent, len(guids))
|
|
if len(guids) == 0 {
|
|
return out
|
|
}
|
|
q := `SELECT guid, event_type, tier, outcome FROM adventure_events WHERE guid IN (?` +
|
|
strings.Repeat(`,?`, len(guids)-1) + `)`
|
|
args := make([]any, len(guids))
|
|
for i, g := range guids {
|
|
args[i] = g
|
|
}
|
|
rows, err := Get().Query(q, args...)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var e AdvEvent
|
|
var tier, outcome sql.NullString
|
|
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &outcome); err != nil {
|
|
return out
|
|
}
|
|
e.Tier, e.Outcome = tier.String, outcome.String
|
|
out[e.GUID] = e
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Treasure is counted only from the treasure_found fact, never from the vault
|
|
// snapshot: a bought sword is not a trophy, and the snapshot is current-state
|
|
// with no "found it in X on day 3". So a treasure tally that predates the first
|
|
// treasure_found is a clean zero, not a back-derivation.
|
|
type TrophyCase struct {
|
|
Name string `json:"name"`
|
|
Bosses []BossTally `json:"bosses,omitempty"`
|
|
Zones []ZoneTally `json:"zones,omitempty"`
|
|
Treasures []TreasureTally `json:"treasures,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"`
|
|
TreasuresFound int `json:"treasures_found"` // story-grade finds, total
|
|
TreasureFirsts int `json:"treasure_firsts"` // realm-first hoards among them
|
|
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"`
|
|
}
|
|
|
|
// TreasureTally is one story-grade find: the item's name, the zone it came out
|
|
// of, and whether it was a realm-first hoard. Unlike bosses and zones there is no
|
|
// repeat count — a named treasure is found once, so each is its own row.
|
|
type TreasureTally struct {
|
|
Item string `json:"item"`
|
|
Zone string `json:"zone"`
|
|
First bool `json:"first"` // first in the realm to pull this hoard
|
|
}
|
|
|
|
// 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, stakes, 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, stakes, actors sql.NullString
|
|
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
|
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
|
&stakes, &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, e.Stakes = outcome.String, milestone.String, stakes.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 "treasure_found":
|
|
tc.TreasuresFound++
|
|
// A realm-first hoard rides the priority tier, the same way zone_first
|
|
// does; a plain story-grade find is a bulletin.
|
|
first := e.Tier == "priority"
|
|
if first {
|
|
tc.TreasureFirsts++
|
|
}
|
|
if e.Stakes != "" {
|
|
tc.Treasures = append(tc.Treasures, TreasureTally{Item: e.Stakes, Zone: e.Zone, First: first})
|
|
}
|
|
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
|
|
}
|