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) } }