adventure: count the treasures an adventurer has actually found

A treasure_found fact turns loot into a trophy: a story-grade find lands on the
who page as a stat tile, a named row in a Treasures found showcase, and a trail
entry linking back to the dispatch. A realm-first hoard rides the priority tier
the way zone_first does, and gets the same star and callout.

The item name travels in the fact's stakes field, which the events log now keeps
(a new nullable column, backfilled to NULL for older rows). Counting is only ever
from the fact, never the vault snapshot, so a bought sword is never a trophy and a
history that predates the fact is a clean zero.

This is a new event_type, so Pete's ingest must be deployed before gogobee emits
one, or the first finds park forever on the retry ladder.
This commit is contained in:
prosolis
2026-07-17 09:02:19 -07:00
parent 1589c36e96
commit 6219224ea9
10 changed files with 192 additions and 41 deletions

View File

@@ -19,7 +19,9 @@ import (
// 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.
// 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"`
@@ -33,6 +35,7 @@ type AdvEvent struct {
Tally int `json:"tally"`
Outcome string `json:"outcome"`
Milestone string `json:"milestone"`
Stakes string `json:"stakes"`
Actors []string `json:"actors"`
OccurredAt int64 `json:"occurred_at"`
}
@@ -50,10 +53,10 @@ func InsertAdventureEvent(e *AdvEvent) error {
_, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
level, tally, outcome, milestone, stakes, 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)
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
return err
}
@@ -74,30 +77,39 @@ type ZoneTally struct {
// 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.
// 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"`
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"`
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
@@ -119,7 +131,7 @@ func EventsBySubject(name string, limit int) ([]AdvEvent, error) {
}
q := `
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
level, tally, outcome, milestone, actors, occurred_at
level, tally, outcome, milestone, stakes, actors, occurred_at
FROM adventure_events
WHERE subject = ? OR opponent = ?
ORDER BY occurred_at DESC`
@@ -137,15 +149,15 @@ func EventsBySubject(name string, limit int) ([]AdvEvent, error) {
var out []AdvEvent
for rows.Next() {
var e AdvEvent
var tier, subject, opponent, boss, zone, region, outcome, milestone, actors sql.NullString
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, &region, &e.Level, &e.Tally, &outcome, &milestone,
&actors, &e.OccurredAt); err != nil {
&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 = outcome.String, milestone.String
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
if actors.String != "" {
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
}
@@ -211,6 +223,17 @@ func BuildTrophyCase(name string, events []AdvEvent) TrophyCase {
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":