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.
165 lines
6.0 KiB
Go
165 lines
6.0 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// TestTrophyCaseTreasures: story-grade finds count out of the treasure_found
|
||
// fact, the priority tier marks a realm-first hoard, and the item name rides the
|
||
// stakes field into a per-find row. A find with no name still counts but earns no
|
||
// showcase row.
|
||
func TestTrophyCaseTreasures(t *testing.T) {
|
||
setupTestDB(t)
|
||
|
||
hoard := ev("treasure_found:1:1", "treasure_found", "Josie", 100)
|
||
hoard.Tier, hoard.Stakes, hoard.Zone = "priority", "Crown of the Drowned King", "The Ossuary"
|
||
find := ev("treasure_found:2:2", "treasure_found", "Josie", 200)
|
||
find.Tier, find.Stakes, find.Zone = "bulletin", "Ring of Nine Sorrows", "The Sump"
|
||
nameless := ev("treasure_found:3:3", "treasure_found", "Josie", 300)
|
||
nameless.Tier = "bulletin" // a find gogobee sent without a stakes noun
|
||
|
||
for _, e := range []*AdvEvent{hoard, find, nameless} {
|
||
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.TreasuresFound != 3 {
|
||
t.Errorf("treasures found: got %d, want 3 (a nameless find still happened)", tc.TreasuresFound)
|
||
}
|
||
if tc.TreasureFirsts != 1 {
|
||
t.Errorf("treasure firsts: got %d, want 1 (only the priority hoard)", tc.TreasureFirsts)
|
||
}
|
||
// Only the two named finds earn a showcase row, newest first.
|
||
if len(tc.Treasures) != 2 {
|
||
t.Fatalf("treasure rows: got %d, want 2 (nameless earns no row)", len(tc.Treasures))
|
||
}
|
||
if tc.Treasures[0].Item != "Ring of Nine Sorrows" || tc.Treasures[0].First {
|
||
t.Errorf("newest row wrong: %+v", tc.Treasures[0])
|
||
}
|
||
if tc.Treasures[1].Item != "Crown of the Drowned King" || !tc.Treasures[1].First || tc.Treasures[1].Zone != "The Ossuary" {
|
||
t.Errorf("hoard row wrong: %+v", tc.Treasures[1])
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|