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.
254 lines
8.9 KiB
Go
254 lines
8.9 KiB
Go
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
|
|
}
|