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

@@ -19,7 +19,9 @@ import (
// AdvEvent is one game fact, kept as fact rather than as the sentence it was // AdvEvent is one game fact, kept as fact rather than as the sentence it was
// rendered into. Mirrors web.AdvFact minus the transport-only fields (no_push, // rendered into. Mirrors web.AdvFact minus the transport-only fields (no_push,
// stakes, class_race) that describe delivery rather than the event. // class_race) that describe delivery rather than the event. Stakes is the one
// former transport field kept here: for a treasure_found it carries the item's
// name, which is the fact, not the delivery.
type AdvEvent struct { type AdvEvent struct {
GUID string `json:"guid"` GUID string `json:"guid"`
EventType string `json:"event_type"` EventType string `json:"event_type"`
@@ -33,6 +35,7 @@ type AdvEvent struct {
Tally int `json:"tally"` Tally int `json:"tally"`
Outcome string `json:"outcome"` Outcome string `json:"outcome"`
Milestone string `json:"milestone"` Milestone string `json:"milestone"`
Stakes string `json:"stakes"`
Actors []string `json:"actors"` Actors []string `json:"actors"`
OccurredAt int64 `json:"occurred_at"` OccurredAt int64 `json:"occurred_at"`
} }
@@ -50,10 +53,10 @@ func InsertAdventureEvent(e *AdvEvent) error {
_, err = Get().Exec(` _, err = Get().Exec(`
INSERT OR IGNORE INTO adventure_events INSERT OR IGNORE INTO adventure_events
(guid, event_type, tier, subject, opponent, boss, zone, region, (guid, event_type, tier, subject, opponent, boss, zone, region,
level, tally, outcome, milestone, actors, occurred_at) level, tally, outcome, milestone, stakes, actors, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone, e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, string(actors), e.OccurredAt) e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
return err return err
} }
@@ -74,30 +77,39 @@ type ZoneTally struct {
// TrophyCase is an adventurer's whole history, counted. // TrophyCase is an adventurer's whole history, counted.
// //
// Note what is *not* here: treasure. The event vocabulary gogobee sends has no // Treasure is counted only from the treasure_found fact, never from the vault
// loot fact — items appear only in the owner-private inventory snapshot, which // snapshot: a bought sword is not a trophy, and the snapshot is current-state
// is a current-state list with no history and no "found it in X on day 3". So // with no "found it in X on day 3". So a treasure tally that predates the first
// "treasures found" is not derivable from anything on the wire today; it needs a // treasure_found is a clean zero, not a back-derivation.
// new fact type upstream rather than a query here. Deliberately absent instead of
// faked out of the vault contents, which would silently count a bought sword as
// a trophy.
type TrophyCase struct { type TrophyCase struct {
Name string `json:"name"` Name string `json:"name"`
Bosses []BossTally `json:"bosses,omitempty"` Bosses []BossTally `json:"bosses,omitempty"`
Zones []ZoneTally `json:"zones,omitempty"` Zones []ZoneTally `json:"zones,omitempty"`
BossKills int `json:"boss_kills"` // total, including firsts Treasures []TreasureTally `json:"treasures,omitempty"`
BossFirsts int `json:"boss_firsts"` // realm-firsts among them BossKills int `json:"boss_kills"` // total, including firsts
ZoneClears int `json:"zone_clears"` BossFirsts int `json:"boss_firsts"` // realm-firsts among them
ZoneFirsts int `json:"zone_firsts"` ZoneClears int `json:"zone_clears"`
Deaths int `json:"deaths"` ZoneFirsts int `json:"zone_firsts"`
Retreats int `json:"retreats"` TreasuresFound int `json:"treasures_found"` // story-grade finds, total
RivalWins int `json:"rival_wins"` TreasureFirsts int `json:"treasure_firsts"` // realm-first hoards among them
Milestones []string `json:"milestones,omitempty"` Deaths int `json:"deaths"`
Survived int `json:"survived"` // mischief contracts walked away from Retreats int `json:"retreats"`
Downed int `json:"downed"` // mischief contracts that landed RivalWins int `json:"rival_wins"`
Events int `json:"events"` // total facts on file Milestones []string `json:"milestones,omitempty"`
FirstSeen int64 `json:"first_seen"` Survived int `json:"survived"` // mischief contracts walked away from
LastSeen int64 `json:"last_seen"` Downed int `json:"downed"` // mischief contracts that landed
Events int `json:"events"` // total facts on file
FirstSeen int64 `json:"first_seen"`
LastSeen int64 `json:"last_seen"`
}
// TreasureTally is one story-grade find: the item's name, the zone it came out
// of, and whether it was a realm-first hoard. Unlike bosses and zones there is no
// repeat count — a named treasure is found once, so each is its own row.
type TreasureTally struct {
Item string `json:"item"`
Zone string `json:"zone"`
First bool `json:"first"` // first in the realm to pull this hoard
} }
// EventsBySubject returns every fact about a character, newest first. This is the // EventsBySubject returns every fact about a character, newest first. This is the
@@ -119,7 +131,7 @@ func EventsBySubject(name string, limit int) ([]AdvEvent, error) {
} }
q := ` q := `
SELECT guid, event_type, tier, subject, opponent, boss, zone, region, SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
level, tally, outcome, milestone, actors, occurred_at level, tally, outcome, milestone, stakes, actors, occurred_at
FROM adventure_events FROM adventure_events
WHERE subject = ? OR opponent = ? WHERE subject = ? OR opponent = ?
ORDER BY occurred_at DESC` ORDER BY occurred_at DESC`
@@ -137,15 +149,15 @@ func EventsBySubject(name string, limit int) ([]AdvEvent, error) {
var out []AdvEvent var out []AdvEvent
for rows.Next() { for rows.Next() {
var e AdvEvent var e AdvEvent
var tier, subject, opponent, boss, zone, region, outcome, milestone, actors sql.NullString var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent, if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
&boss, &zone, &region, &e.Level, &e.Tally, &outcome, &milestone, &boss, &zone, &region, &e.Level, &e.Tally, &outcome, &milestone,
&actors, &e.OccurredAt); err != nil { &stakes, &actors, &e.OccurredAt); err != nil {
return nil, err return nil, err
} }
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
e.Outcome, e.Milestone = outcome.String, milestone.String e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
if actors.String != "" { if actors.String != "" {
_ = json.Unmarshal([]byte(actors.String), &e.Actors) _ = json.Unmarshal([]byte(actors.String), &e.Actors)
} }
@@ -211,6 +223,17 @@ func BuildTrophyCase(name string, events []AdvEvent) TrophyCase {
if e.EventType == "zone_first" { if e.EventType == "zone_first" {
tc.ZoneFirsts++ tc.ZoneFirsts++
} }
case "treasure_found":
tc.TreasuresFound++
// A realm-first hoard rides the priority tier, the same way zone_first
// does; a plain story-grade find is a bulletin.
first := e.Tier == "priority"
if first {
tc.TreasureFirsts++
}
if e.Stakes != "" {
tc.Treasures = append(tc.Treasures, TreasureTally{Item: e.Stakes, Zone: e.Zone, First: first})
}
case "death": case "death":
tc.Deaths++ tc.Deaths++
case "retreat": case "retreat":

View File

@@ -88,6 +88,50 @@ func TestTrophyCaseCounts(t *testing.T) {
} }
} }
// 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 // TestTrophyCaseIgnoresOpponentCredit is the one that matters for honesty. A
// duel Josie *lost* still names her, as the opponent in Quack's dispatch. It // 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. // belongs on her timeline but must never be credited to her trophy case.

View File

@@ -101,6 +101,10 @@ func runMigrations(d *sql.DB) error {
// click-through page. Rides the roster snapshot; NULL on rows pushed by a // click-through page. Rides the roster snapshot; NULL on rows pushed by a
// gogobee build that predates the detail page. // gogobee build that predates the detail page.
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT") addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
// The noun a fact is about (a mischief bounty, a found treasure's name). Facts
// recorded before the treasure_found event existed carry NULL, which is right:
// they had no such noun to keep.
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
// FTS5 virtual tables don't support IF NOT EXISTS reliably. // FTS5 virtual tables don't support IF NOT EXISTS reliably.
// Check sqlite_master before creating. // Check sqlite_master before creating.

View File

@@ -85,6 +85,8 @@ CREATE TABLE IF NOT EXISTS adventure_events (
tally INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ... tally INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ...
outcome TEXT, outcome TEXT,
milestone TEXT, milestone TEXT,
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
occurred_at INTEGER NOT NULL occurred_at INTEGER NOT NULL
); );

View File

@@ -150,6 +150,7 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
Tally: f.Count, Tally: f.Count,
Outcome: f.Outcome, Outcome: f.Outcome,
Milestone: f.Milestone, Milestone: f.Milestone,
Stakes: f.Stakes,
Actors: f.Actors, Actors: f.Actors,
OccurredAt: occurredAt, OccurredAt: occurredAt,
}); err != nil { }); err != nil {
@@ -204,6 +205,8 @@ func advEventMeta(eventType string) (label, emoji string) {
return "First clear", "🗺️" return "First clear", "🗺️"
case "zone_clear": case "zone_clear":
return "Zone cleared", "🗺️" return "Zone cleared", "🗺️"
case "treasure_found":
return "Treasure", "💎"
case "death": case "death":
return "In memoriam", "🪦" return "In memoriam", "🪦"
case "arrival": case "arrival":
@@ -410,6 +413,24 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
inRegion = " in " + f.Region inRegion = " in " + f.Region
} }
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true 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": case "death":
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone), 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 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) { func TestAdventureArtAndMeta(t *testing.T) {
const token = "t" const token = "t"
s, _ := newAdvServer(t, token) 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"> <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> <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="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="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> <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="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 class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">zones cleared</div>
</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="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="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> <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>
</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"> <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> </p>
{{end}} {{end}}
@@ -208,6 +212,20 @@
{{end}} {{end}}
</div> </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}} {{if .Trophies.Milestones}}
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10"> <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> <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), Line: timelineLine(e),
When: time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"), When: time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"),
Permalink: s.advPermalink(e.GUID), 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 return out
@@ -324,6 +327,9 @@ func timelineLine(e storage.AdvEvent) string {
return "" return ""
case "milestone": case "milestone":
return e.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("") 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}, Effect: "faster to act, +4 HP", Attunement: true, Attuned: false},
}, },
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}}, Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
House: storage.HouseView{Tier: 2, LoanBalance: 1500}, House: storage.HouseView{Tier: 2, LoanBalance: 1500},
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}}, Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
}}}); w.Code != 200 { }}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code) 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}, Subject: "Josie", Zone: "holymachina", Level: 14, OccurredAt: 300},
{GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"}, {GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"},
Subject: "Josie", Milestone: "level 14", OccurredAt: 400}, 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 { for _, f := range facts {
body, _ := json.Marshal(f) body, _ := json.Marshal(f)
@@ -298,10 +300,14 @@ func TestWhoHistoryPanels(t *testing.T) {
"The record", "The record",
"The Rotmother", // the boss tally "The Rotmother", // the boss tally
"bosses down", "bosses down",
"realm-first", // the boss_first got flagged "realm-first", // the boss_first got flagged
"level 14", // the milestone chip "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", "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) { if !strings.Contains(body, want) {
t.Errorf("history render missing %q", want) t.Errorf("history render missing %q", want)