diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 877a826..bfeca23 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -1383,6 +1383,10 @@ func (p *AdventurePlugin) announceTreasureToRoom(char *AdventureCharacter, def * if def == nil || def.RoomAnnounce == "" { return } + // The same story-grade gate feeds Pete's trophy case. Emit before the + // games-room check so a find is still recorded as news even when no room is + // configured to announce it in. + emitTreasureFound(char.UserID, def, loc) gr := gamesRoom() if gr == "" { return diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go index 5266a3c..f391410 100644 --- a/internal/plugin/pete.go +++ b/internal/plugin/pete.go @@ -336,3 +336,64 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) { OccurredAt: ts, }, userID, "") } + +// treasureRarityWord maps a treasure def's tier to the rarity adjective Pete +// weaves into a find's dispatch. Story-grade treasures are typically tier 5, so +// "legendary" is the common case; the lower tiers are here for completeness. +func treasureRarityWord(tier int) string { + switch { + case tier >= 5: + return "legendary" + case tier == 4: + return "epic" + case tier == 3: + return "rare" + case tier == 2: + return "uncommon" + default: + return "common" + } +} + +// emitTreasureFound files a story-grade treasure find. Only treasures carrying a +// RoomAnnounce string reach here — the same gate that earns them a public moment +// — so a copper-piece pickup never becomes news. The realm's first finder of a +// given treasure is a PRIORITY hoard; a later finder of the same item is a +// BULLETIN, the first/repeat split zone_first already uses. Character name only; +// no-op unless the seam is enabled. +// +// treasure_found is an event_type Pete's ingest must already know: an unknown +// type 400s, retries to the cap, then parks the bulletin forever. Deploy Pete +// first. +func emitTreasureFound(userID id.UserID, def *AdvTreasureDef, loc *AdvLocation) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + if def == nil || loc == nil { + return + } + // Claim the realm-first BEFORE the name guard, so an unnamed straggler's + // genuine first find still seeds the ledger and the next finder isn't + // mis-billed as the first-ever. Mirrors emitZoneClearNews. + tier := "bulletin" + if claimRealmFirst("treasure", def.Key) { + tier = "priority" + } + name := charName(userID) + if name == "" { + return + } + ts := nowUnix() + disc := fmt.Sprintf("%s:%d", def.Key, ts) + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("treasure_found:%s:%s:%d", eventToken(userID, disc), def.Key, ts), + EventType: "treasure_found", + Tier: tier, + Subject: name, + Zone: loc.Name, + Level: charLevel(userID), + Stakes: def.Name, + Outcome: treasureRarityWord(def.Tier), + OccurredAt: ts, + }, userID, "") +} diff --git a/internal/plugin/pete_test.go b/internal/plugin/pete_test.go index bc45a3f..e876bb5 100644 --- a/internal/plugin/pete_test.go +++ b/internal/plugin/pete_test.go @@ -3,6 +3,7 @@ package plugin import ( "crypto/sha256" "encoding/hex" + "encoding/json" "os" "sync" "testing" @@ -193,6 +194,71 @@ func TestEmitZoneClearTaxonomy(t *testing.T) { } } +// TestEmitTreasureFound: a story-grade find carries the item name and rarity, +// and the realm's first finder of a given treasure is billed a PRIORITY hoard +// while a later finder of the same item is a BULLETIN — the same first/repeat +// split zone_first uses, but keyed on the treasure across the whole realm. +func TestEmitTreasureFound(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + enablePeteSeam(t) + + db.Exec("seed finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@zapp:x", "Zapp") + db.Exec("seed second finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@kif:x", "Kif") + + def := &AdvTreasureDef{Key: "thunderfury", Name: "Thunderfury, Blessed Blade of the Windseeker", + Tier: 5, RoomAnnounce: "x got Thunderfury."} + loc := &AdvLocation{Name: "The Abyssal Maw"} + + emitTreasureFound(id.UserID("@zapp:x"), def, loc) // realm-first + emitTreasureFound(id.UserID("@kif:x"), def, loc) // same item, later finder + + if got := queuedCount(t, "treasure_found:%"); got != 2 { + t.Fatalf("treasure_found queued = %d, want 2", got) + } + + // Pull both payloads and check the taxonomy split plus the carried fields. + rows, err := db.Get().Query(`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'treasure_found:%'`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + tiers := map[string]int{} + for rows.Next() { + var payload string + if err := rows.Scan(&payload); err != nil { + t.Fatal(err) + } + var f map[string]any + if err := json.Unmarshal([]byte(payload), &f); err != nil { + t.Fatal(err) + } + tiers[f["tier"].(string)]++ + if f["stakes"] != def.Name { + t.Errorf("stakes = %v, want the item name", f["stakes"]) + } + if f["zone"] != "The Abyssal Maw" { + t.Errorf("zone = %v, want the location", f["zone"]) + } + if f["outcome"] != "legendary" { + t.Errorf("outcome = %v, want legendary for a tier-5 find", f["outcome"]) + } + } + if tiers["priority"] != 1 || tiers["bulletin"] != 1 { + t.Errorf("tier split = %v, want one priority (realm-first) and one bulletin (repeat)", tiers) + } + + // The ledger is seeded, so a later live find of the same treasure won't + // mis-announce as the first-ever. + if claimRealmFirst("treasure", "thunderfury") { + t.Error("realm-first ledger not seeded for the treasure") + } +} + // TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip. func TestNewsEmissionKillSwitch(t *testing.T) { dir := t.TempDir()