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

@@ -150,6 +150,7 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
Tally: f.Count,
Outcome: f.Outcome,
Milestone: f.Milestone,
Stakes: f.Stakes,
Actors: f.Actors,
OccurredAt: occurredAt,
}); err != nil {
@@ -204,6 +205,8 @@ func advEventMeta(eventType string) (label, emoji string) {
return "First clear", "🗺️"
case "zone_clear":
return "Zone cleared", "🗺️"
case "treasure_found":
return "Treasure", "💎"
case "death":
return "In memoriam", "🪦"
case "arrival":
@@ -410,6 +413,24 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
inRegion = " in " + f.Region
}
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
case "treasure_found":
// A story-grade find pulled from a dungeon. stakes is the item's name,
// outcome its rarity, and the priority tier marks a realm-first hoard
// nobody had ever pulled before — the same split zone_first uses.
inZone := f.Zone
if inZone == "" {
inZone = "the dungeon"
}
rarity := ""
if f.Outcome != "" {
rarity = strings.ToLower(f.Outcome) + " "
}
if f.Tier == "priority" {
return fmt.Sprintf("First ever: %s pulls %s out of %s.", f.Subject, f.Stakes, inZone),
fmt.Sprintf("Nobody had laid hands on %s before today. %s found the %shoard deep in %s%s, first in the realm to do it. Some haul.", f.Stakes, f.Subject, rarity, inZone, atLevel), true
}
return fmt.Sprintf("%s turned up %s in %s.", f.Subject, f.Stakes, inZone),
fmt.Sprintf("%s came back from %s with %s to show for it%s. A %sfind like that is worth a mention. Nice one.", f.Subject, inZone, f.Stakes, atLevel, rarity), true
case "death":
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true

View File

@@ -218,6 +218,33 @@ func TestRenderZoneTaxonomy(t *testing.T) {
}
}
// TestRenderTreasure: a story-grade find names the item and its zone; a
// priority find is billed as a realm-first, and the rarity from outcome rides
// into the lede.
func TestRenderTreasure(t *testing.T) {
hoard := AdvFact{EventType: "treasure_found", Tier: "priority", Subject: "Josie",
Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", Level: 7}
hl, lede, ok := renderAdventure(hoard)
if !ok || !strings.Contains(hl, "First ever") || !strings.Contains(hl, "Crown of the Drowned King") {
t.Errorf("hoard headline = %q (ok=%v)", hl, ok)
}
if !strings.Contains(lede, "legendary") {
t.Errorf("hoard lede dropped the rarity: %q", lede)
}
find := AdvFact{EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
Zone: "The Sump", Stakes: "Ring of Nine Sorrows"}
hl2, _, ok := renderAdventure(find)
if !ok || !strings.Contains(hl2, "Josie") || !strings.Contains(hl2, "Ring of Nine Sorrows") ||
!strings.Contains(hl2, "The Sump") || strings.Contains(hl2, "First ever") {
t.Errorf("plain find headline = %q (ok=%v)", hl2, ok)
}
if lbl, emoji := advEventMeta("treasure_found"); lbl != "Treasure" || emoji == "" {
t.Errorf("treasure meta = %q/%q", lbl, emoji)
}
}
func TestAdventureArtAndMeta(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)

File diff suppressed because one or more lines are too long

View File

@@ -153,7 +153,7 @@
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">The record</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.BossKills}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">bosses down</div>
@@ -162,6 +162,10 @@
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.ZoneClears}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">zones cleared</div>
</div>
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.TreasuresFound}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">treasure{{if ne .Trophies.TreasuresFound 1}}s{{end}}</div>
</div>
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Deaths}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">death{{if ne .Trophies.Deaths 1}}s{{end}}</div>
@@ -172,9 +176,9 @@
</div>
</div>
{{if or .Trophies.BossFirsts .Trophies.ZoneFirsts}}
{{if or .Trophies.BossFirsts .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}
<p class="mt-3 text-sm text-theme-adventure font-semibold">
★ {{.Trophies.BossFirsts}} realm-first boss{{if ne .Trophies.BossFirsts 1}}es{{end}}{{if .Trophies.ZoneFirsts}}, {{.Trophies.ZoneFirsts}} first clear{{if ne .Trophies.ZoneFirsts 1}}s{{end}}{{end}} — nobody had done it before.
{{if .Trophies.BossFirsts}}{{.Trophies.BossFirsts}} realm-first boss{{if ne .Trophies.BossFirsts 1}}es{{end}}{{end}}{{if and .Trophies.BossFirsts (or .Trophies.ZoneFirsts .Trophies.TreasureFirsts)}}, {{end}}{{if .Trophies.ZoneFirsts}}{{.Trophies.ZoneFirsts}} first clear{{if ne .Trophies.ZoneFirsts 1}}s{{end}}{{end}}{{if and .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}, {{end}}{{if .Trophies.TreasureFirsts}}{{.Trophies.TreasureFirsts}} first hoard{{if ne .Trophies.TreasureFirsts 1}}s{{end}}{{end}}. Nobody had done it before.
</p>
{{end}}
@@ -208,6 +212,20 @@
{{end}}
</div>
{{if .Trophies.Treasures}}
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
<h3 class="font-display text-lg font-bold mb-3">Treasures found</h3>
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
{{range .Trophies.Treasures}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">💎 {{.Item}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to pull this hoard"></span>{{end}}</span>
{{if .Zone}}<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.Zone}}</span>{{end}}
</li>
{{end}}
</ul>
</div>
{{end}}
{{if .Trophies.Milestones}}
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
<h3 class="font-display text-lg font-bold mb-3">Milestones</h3>

View File

@@ -289,7 +289,10 @@ func buildTimeline(s *Server, events []storage.AdvEvent) []timelineEntry {
Line: timelineLine(e),
When: time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"),
Permalink: s.advPermalink(e.GUID),
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death",
// A realm-first hoard is the treasure worth an eye; a plain find rides
// the quiet border like any other bulletin.
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death" ||
(e.EventType == "treasure_found" && e.Tier == "priority"),
})
}
return out
@@ -324,6 +327,9 @@ func timelineLine(e storage.AdvEvent) string {
return ""
case "milestone":
return e.Milestone
case "treasure_found":
// The item is the point; name it, and say where it came out of.
return inZone(e.Stakes)
}
return inZone("")
}

View File

@@ -88,8 +88,8 @@ func seedWho(t *testing.T, owner string) *Server {
Effect: "faster to act, +4 HP", Attunement: true, Attuned: false},
},
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
}}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
@@ -110,6 +110,8 @@ func seedHistory(t *testing.T, s *Server) {
Subject: "Josie", Zone: "holymachina", Level: 14, OccurredAt: 300},
{GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"},
Subject: "Josie", Milestone: "level 14", OccurredAt: 400},
{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority", Actors: []string{"Josie"},
Subject: "Josie", Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", OccurredAt: 500},
}
for _, f := range facts {
body, _ := json.Marshal(f)
@@ -298,10 +300,14 @@ func TestWhoHistoryPanels(t *testing.T) {
"The record",
"The Rotmother", // the boss tally
"bosses down",
"realm-first", // the boss_first got flagged
"level 14", // the milestone chip
"realm-first", // the boss_first got flagged
"level 14", // the milestone chip
"treasure", // the treasure stat tile
"Treasures found", // the showcase panel
"Crown of the Drowned King", // the named hoard
"first hoard", // the realm-first callout picked it up
"The trail",
"/adventure/boss_first:a:1", // the trail links back to the dispatch
"/adventure/treasure_found:e:5", // the trail links back to the treasure dispatch
} {
if !strings.Contains(body, want) {
t.Errorf("history render missing %q", want)