Files
Pete/internal/storage/adventure_test.go
prosolis cbe9e67b3e adventure: keep the facts, not just the sentence we made of them
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.
2026-07-16 23:57:23 -07:00

121 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
// 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)
}
}