diff --git a/internal/storage/adventure.go b/internal/storage/adventure.go
index 8a2f612..b001917 100644
--- a/internal/storage/adventure.go
+++ b/internal/storage/adventure.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"sort"
+ "strings"
)
// The durable record of what has actually happened in the realm.
@@ -60,6 +61,73 @@ func InsertAdventureEvent(e *AdvEvent) error {
return err
}
+// AdventureEventByGUID returns the stored fact behind one dispatch, or nil when
+// there isn't one. Missing is normal, not an error: the story row is inserted
+// first and the fact record is best-effort (see handleAdventureIngest), and every
+// dispatch that predates the fact table has a story and no fact at all. Callers
+// render the thinner event_type-only view in that case.
+func AdventureEventByGUID(guid string) (*AdvEvent, error) {
+ if guid == "" {
+ return nil, nil
+ }
+ var e AdvEvent
+ var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString
+ err := Get().QueryRow(`
+ SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
+ level, tally, outcome, milestone, stakes, actors, occurred_at
+ FROM adventure_events WHERE guid = ?`, guid).Scan(
+ &e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion,
+ &e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &e.OccurredAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ 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.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
+ if actors.String != "" {
+ _ = json.Unmarshal([]byte(actors.String), &e.Actors)
+ }
+ return &e, nil
+}
+
+// AdventureEventFacets returns the (event_type, tier, outcome) of many dispatches
+// in one read, keyed by guid. It exists so a feed page can tint two dozen cards
+// by what they actually are without paying a query per card — the pool is
+// MaxOpenConns(1), so N round trips would serialize behind each other.
+//
+// Guids absent from the map are dispatches with no fact row; the caller leaves
+// those untinted rather than guessing.
+func AdventureEventFacets(guids []string) map[string]AdvEvent {
+ out := make(map[string]AdvEvent, len(guids))
+ if len(guids) == 0 {
+ return out
+ }
+ q := `SELECT guid, event_type, tier, outcome FROM adventure_events WHERE guid IN (?` +
+ strings.Repeat(`,?`, len(guids)-1) + `)`
+ args := make([]any, len(guids))
+ for i, g := range guids {
+ args[i] = g
+ }
+ rows, err := Get().Query(q, args...)
+ if err != nil {
+ return out
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var e AdvEvent
+ var tier, outcome sql.NullString
+ if err := rows.Scan(&e.GUID, &e.EventType, &tier, &outcome); err != nil {
+ return out
+ }
+ e.Tier, e.Outcome = tier.String, outcome.String
+ out[e.GUID] = e
+ }
+ return out
+}
+
// BossTally is one monster and how many times this adventurer has put it down.
type BossTally struct {
Boss string `json:"boss"`
diff --git a/internal/storage/siege.go b/internal/storage/siege.go
index 3578e23..a17bee5 100644
--- a/internal/storage/siege.go
+++ b/internal/storage/siege.go
@@ -131,6 +131,49 @@ func ReplaceSiege(s Siege, snapshotAt int64) error {
return tx.Commit()
}
+// SiegeBarForBoss finds the HP bar to draw on a siege dispatch's card: current
+// and max HP for the named boss around the time the dispatch was filed.
+//
+// A siege fact carries the boss and the defender count but not the HP, so the
+// bar has to come from the war-room snapshot. Two places to look, in order:
+//
+// - the live row, when that boss is still camped (a siege_start card should
+// show the bar as it stands right now, and it will keep sliding as the town
+// chips away);
+// - the history, for a Siege that has closed — matched on name and then on
+// the row that ended nearest the dispatch, since the same boss comes back
+// month after month and only the clock separates the two.
+//
+// ok is false when neither has it, which is a real and temporary state: the
+// win/loss dispatch is filed the moment the Siege resolves, and the history that
+// explains it doesn't reach Pete until the next 2-minute push. The card renders
+// without a bar in the meantime rather than drawing a wrong one.
+func SiegeBarForBoss(boss string, at int64) (current, max int, ok bool) {
+ if boss == "" {
+ return 0, 0, false
+ }
+ var active bool
+ var name string
+ var hpCur, hpMax int
+ err := Get().QueryRow(`
+ SELECT active, boss_name, hp_current, hp_max FROM adventure_siege WHERE id = 1`).
+ Scan(&active, &name, &hpCur, &hpMax)
+ if err == nil && active && name == boss && hpMax > 0 {
+ return hpCur, hpMax, true
+ }
+
+ // ORDER BY the distance from the dispatch, so a boss that has besieged the
+ // town three times resolves to the siege this dispatch is actually about.
+ err = Get().QueryRow(`
+ SELECT hp_remaining, hp_max FROM adventure_siege_history
+ WHERE boss_name = ? AND hp_max > 0
+ ORDER BY ABS(ended_at - ?) ASC LIMIT 1`, boss, at).Scan(&hpCur, &hpMax)
+ if err != nil {
+ return 0, 0, false
+ }
+ return hpCur, hpMax, true
+}
+
// LoadSiege returns the war room as last pushed. ok is false when gogobee has
// never pushed one at all — distinct from a pushed snapshot that says no Siege
// is camped, which is a real answer the page can render.
diff --git a/internal/web/adventure.go b/internal/web/adventure.go
index fde4fb5..9966882 100644
--- a/internal/web/adventure.go
+++ b/internal/web/adventure.go
@@ -4,7 +4,6 @@ import (
"crypto/subtle"
"encoding/json"
"fmt"
- "html/template"
"io"
"log/slog"
"net/http"
@@ -156,7 +155,11 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
}
articleURL := s.advPermalink(f.GUID)
- imageURL := advArtURL(f.EventType)
+ // Keyed on the guid, not the event type: the card renderer reads the fact
+ // behind the dispatch so it can put the boss's name on it. The fact insert
+ // below is best-effort, and the card degrades to the type-only emblem when
+ // it isn't there — so this URL is safe to bake in before that runs.
+ imageURL := advArtURL(f.GUID)
if err := storage.InsertStory(&storage.Story{
GUID: f.GUID,
Headline: headline,
@@ -353,43 +356,6 @@ func advFallbackRender(f AdvFact) (headline, lede string) {
return "Word in from the realm.", "Something happened out there." + stillGetting
}
-// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
-// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
-// the external-image thumbnailer.
-func advArtURL(eventType string) string {
- return "/adventure/art/" + eventType + ".svg"
-}
-
-// handleAdventureArt renders the themed emblem for an event type — an adventure
-// gradient with the event's emoji and label. Deterministic and dependency-free
-// (no external asset), so every dispatch card has visual identity instead of the
-// blank placeholder that made the section look broken next to RSS cards.
-func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
- if !s.adv.Enabled {
- http.NotFound(w, r)
- return
- }
- eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
- label, emoji := advEventMeta(eventType)
- w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
- w.Header().Set("Cache-Control", "public, max-age=86400")
- _, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
-}
-
-// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
-// card ratio) so the same image works as a link-preview image.
-const advArtSVG = ``
-
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
// dispatch reads like the rest of the site, with an adventure-themed hero.
type advStoryPage struct {
@@ -435,7 +401,7 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
base.Active = "adventure"
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
- base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
+ base.OGImage = abs + advArtURL(guid) // the dispatch's own card, for link unfurls
}
s.render(w, "story", advStoryPage{
pageData: base,
diff --git a/internal/web/adventure_art.go b/internal/web/adventure_art.go
new file mode 100644
index 0000000..2c3aa15
--- /dev/null
+++ b/internal/web/adventure_art.go
@@ -0,0 +1,506 @@
+package web
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+ "unicode/utf8"
+
+ "pete/internal/storage"
+)
+
+// The dispatch card.
+//
+// Every adventure dispatch used to render the same image: one violet gradient,
+// a swapped emoji, and a label. A death, a realm-first, and a legendary hoard
+// were visually identical — and this image is not decoration, it is the og:image
+// on every link Pete puts in Matrix and the thumbnail on every feed card. The
+// most interesting thing that has ever happened in the realm looked exactly like
+// the most routine.
+//
+// Two changes fix that. The card is keyed on the dispatch GUID rather than the
+// event type, so it can read the fact behind the dispatch and put the actual
+// NOUNS on it — the boss's name, the zone, the level, the item. And each event
+// family gets its own palette, with treasure tinted by the rarity gogobee
+// already computes and throws away into a sentence.
+//
+// Everything here stays deterministic, server-rendered, dependency-free SVG:
+// same input, same bytes, no external asset, no font file, cacheable forever.
+
+// advArtCard is the fully-resolved card: what to draw, already escaped-safe as
+// plain text (the renderer escapes on write). Built by advArtCardFor.
+type advArtCard struct {
+ Label string // the event-family chip, e.g. "THE SIEGE"
+ Emoji string
+ Noun string // the headline noun: boss, zone, item, or adventurer
+ Detail string // the supporting line: region, level, who
+ Ceremony string // ribbon text for a realm-first; "" for everything else
+ Palette advPalette
+ Bar *advArtBar // siege HP, when we have it
+}
+
+// advArtBar is the Siege health bar drawn onto a siege card. This is the W1
+// deferral landing: a Matrix unfurl of "the town holds" that SHOWS the bar is
+// worth ten paragraphs, and it was parked here because doing it in W1 would have
+// meant threading boss HP through art plumbing this phase was going to redesign.
+type advArtBar struct {
+ Current, Max int
+}
+
+// advPalette is one event family's colours. From/To are the background gradient
+// stops; Accent tints the chip, the ribbon and the bar fill, and is also what a
+// feed card borrows for its border.
+type advPalette struct {
+ From, To, Accent string
+}
+
+// The house palette. Dark, saturated backgrounds so white text always clears
+// contrast, with an accent bright enough to read as a border on both the light
+// and dark site themes.
+var (
+ palSiege = advPalette{"#7a1f12", "#2b0a06", "#ff6b3d"} // ember: the town is on fire
+ palDeath = advPalette{"#3b4250", "#171a20", "#9aa6b8"} // slate: no colour, on purpose
+ palBoss = advPalette{"#4a1030", "#1a0714", "#ff4d6d"}
+ palZone = advPalette{"#14532d", "#052e16", "#4ade80"}
+ palMischief = advPalette{"#4c1d95", "#120524", "#a78bfa"}
+ palArrival = advPalette{"#0e7490", "#083344", "#22d3ee"}
+ palMilestone = advPalette{"#a16207", "#422006", "#fbbf24"}
+ palSetback = advPalette{"#78350f", "#2a1206", "#f59e0b"} // retreat, departure
+ palRival = advPalette{"#1e3a8a", "#0b1a3d", "#60a5fa"}
+ palPete = advPalette{"#7c5ce8", "#5836b8", "#c4b5fd"} // Pete's own violet
+ palNeutral = advPalette{"#7c5ce8", "#5836b8", "#c4b5fd"} // the old one-and-only
+
+ // Treasure is tinted by rarity — the loot-game convention, and gogobee
+ // already computes the word (treasureRarityWord) and spends it on prose.
+ palLegendary = advPalette{"#b4530a", "#4a1d02", "#ffb020"}
+ palEpic = advPalette{"#5b21b6", "#2e1065", "#c084fc"}
+ palRare = advPalette{"#1e3a8a", "#0b1a3d", "#60a5fa"}
+ palUncommon = advPalette{"#14532d", "#052e16", "#4ade80"}
+ palCommon = advPalette{"#3f3f46", "#18181b", "#a1a1aa"}
+)
+
+// advPaletteFor picks the family colours. outcome carries the treasure rarity
+// and is ignored everywhere else.
+func advPaletteFor(eventType, outcome string) advPalette {
+ switch eventType {
+ case "siege_start", "siege_win", "siege_loss":
+ return palSiege
+ case "death":
+ return palDeath
+ case "boss_first", "boss_kill":
+ return palBoss
+ case "zone_first", "zone_clear":
+ return palZone
+ case "treasure_found":
+ switch strings.ToLower(outcome) {
+ case "legendary":
+ return palLegendary
+ case "epic":
+ return palEpic
+ case "rare":
+ return palRare
+ case "uncommon":
+ return palUncommon
+ case "common":
+ return palCommon
+ }
+ return palLegendary // story-grade finds are typically tier 5
+ case "mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled":
+ return palMischief
+ case "arrival":
+ return palArrival
+ case "milestone":
+ return palMilestone
+ case "retreat", "departure":
+ return palSetback
+ case "standings", "rival_result", "pete_duel_win", "pete_duel_loss":
+ return palRival
+ case "companion_hire":
+ return palPete
+ }
+ return palNeutral
+}
+
+// advIsRealmFirst reports whether a dispatch is the first time anything like it
+// has ever happened in the realm. gogobee already computes this — it is the
+// priority/bulletin split claimRealmFirst applies — and until now it only
+// decided whether Matrix got pinged. A thing nobody has ever done should also
+// LOOK different from the ninth time somebody did it.
+func advIsRealmFirst(eventType, tier string) bool {
+ switch eventType {
+ case "boss_first", "zone_first":
+ return true
+ case "treasure_found":
+ // A realm-first hoard rides the priority tier, the same split
+ // BuildTrophyCase counts on.
+ return tier == "priority"
+ }
+ return false
+}
+
+// advCardAccent is the feed-card tint for a dispatch: the family accent colour
+// and whether it earns the realm-first ring. Returns "" for a non-adventure or
+// unknown story so the caller leaves the card's default border alone.
+func advCardAccent(eventType, tier, outcome string) (accent string, ceremony bool) {
+ if eventType == "" {
+ return "", false
+ }
+ return advPaletteFor(eventType, outcome).Accent, advIsRealmFirst(eventType, tier)
+}
+
+// advArtCardFor resolves a dispatch into everything the card draws.
+//
+// ev is nil for a dispatch with no stored fact — anything that predates the fact
+// table, plus the brief window where the story row exists and the best-effort
+// fact insert failed. That degrades to exactly the old card (family palette,
+// emoji, label) rather than to a broken one.
+func advArtCardFor(eventType string, ev *storage.AdvEvent) advArtCard {
+ label, emoji := advEventMeta(eventType)
+ card := advArtCard{Label: strings.ToUpper(label), Emoji: emoji, Palette: advPaletteFor(eventType, "")}
+ if ev == nil {
+ return card
+ }
+
+ card.Palette = advPaletteFor(eventType, ev.Outcome)
+ if advIsRealmFirst(eventType, ev.Tier) {
+ card.Ceremony = "REALM FIRST"
+ }
+
+ // The noun is whatever the dispatch is ABOUT — which is not the same field
+ // from family to family. A siege is about the boss; a treasure is about the
+ // item; a death is about the person.
+ switch eventType {
+ case "siege_start", "siege_win", "siege_loss":
+ card.Noun = ev.Boss
+ card.Detail = advSiegeDetail(eventType, ev.Tally)
+ if cur, max, ok := storage.SiegeBarForBoss(ev.Boss, ev.OccurredAt); ok {
+ card.Bar = &advArtBar{Current: cur, Max: max}
+ }
+ case "boss_first", "boss_kill":
+ card.Noun = ev.Boss
+ card.Detail = advJoinDetail(ev.Subject, ev.Zone, ev.Level)
+ case "zone_first", "zone_clear":
+ card.Noun = ev.Zone
+ card.Detail = advJoinDetail(ev.Subject, ev.Region, ev.Level)
+ case "treasure_found":
+ card.Noun = ev.Stakes // the item's name
+ card.Detail = advJoinDetail(ev.Subject, ev.Zone, ev.Level)
+ if ev.Outcome != "" {
+ card.Label = strings.ToUpper(ev.Outcome)
+ }
+ case "mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled":
+ card.Noun = ev.Subject
+ card.Detail = advJoinDetail(ev.Boss, ev.Zone, ev.Level)
+ case "companion_hire":
+ card.Noun = ev.Subject
+ card.Detail = advJoinDetail("", ev.Zone, ev.Level)
+ case "milestone":
+ card.Noun = ev.Subject
+ card.Detail = ev.Milestone
+ default:
+ card.Noun = ev.Subject
+ card.Detail = advJoinDetail("", ev.Zone, ev.Level)
+ }
+ if card.Noun == "" { // a fact missing its own subject still gets a card
+ card.Noun = ev.Subject
+ }
+ return card
+}
+
+// advSiegeDetail is the siege card's supporting line. Tally is the defender
+// count on a win; a start has none yet.
+func advSiegeDetail(eventType string, defenders int) string {
+ switch {
+ case eventType == "siege_start":
+ return "the town is called out"
+ case defenders == 1:
+ return "1 defender"
+ case defenders > 1:
+ return fmt.Sprintf("%d defenders", defenders)
+ case eventType == "siege_win":
+ return "the town holds"
+ }
+ return "the gates gave way"
+}
+
+// advJoinDetail assembles the supporting line from whichever of who/where/level
+// the fact actually has, dot-separated, skipping the empties. A card with one
+// real field reads better than one padded out with "unknown".
+func advJoinDetail(who, where string, level int) string {
+ var parts []string
+ if who != "" {
+ parts = append(parts, who)
+ }
+ if where != "" {
+ parts = append(parts, where)
+ }
+ if level > 0 {
+ parts = append(parts, fmt.Sprintf("level %d", level))
+ }
+ return strings.Join(parts, " · ")
+}
+
+// advArtURL is the card/OG image for a dispatch, keyed on the dispatch GUID so
+// the renderer can read the fact behind it and name names. Root-relative, so it
+// bypasses the external-image thumbnailer.
+//
+// The guid is path-escaped for the same reason advPermalink escapes it: it is
+// ingest-supplied, and a stray "/" would produce a URL that routes somewhere
+// else entirely.
+//
+// Older stories have an event-type URL baked into their image_url column
+// (/adventure/art/death.svg). Those keep working — handleAdventureArt falls back
+// to the type-only card when the path isn't a guid it knows — so nothing has to
+// be backfilled and no card ever 404s.
+func advArtURL(guid string) string {
+ return "/adventure/art/" + url.PathEscape(guid) + ".svg"
+}
+
+// handleAdventureArt renders one dispatch's card.
+func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
+ if !s.adv.Enabled {
+ http.NotFound(w, r)
+ return
+ }
+ key := strings.TrimSuffix(r.PathValue("type"), ".svg")
+
+ // The path is either a guid ("death::") or, for a story from
+ // before this was guid-keyed, a bare event type. Both start with the event
+ // type, so the family colours are right either way; only the nouns need the
+ // fact row.
+ ev, err := storage.AdventureEventByGUID(key)
+ if err != nil {
+ ev = nil // a read failure is a thinner card, not a broken image
+ }
+ eventType := key
+ if t, _, hasSep := strings.Cut(key, ":"); hasSep {
+ eventType = t
+ }
+ card := advArtCardFor(eventType, ev)
+
+ w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
+ // A finished card never changes, so cache it hard. The exception is a siege
+ // card still waiting on its bar: the win/loss dispatch is filed before the
+ // war-room push that explains it, and an unfurl fetched in that window would
+ // otherwise be pinned barless for a day.
+ if card.Bar == nil && strings.HasPrefix(eventType, "siege_") {
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ }
+ _, _ = w.Write([]byte(advRenderArt(card)))
+}
+
+// Card geometry. 1200×630 is the OG ratio, so the same image works as a
+// link-preview and as a feed thumbnail.
+const (
+ advArtW = 1200
+ advArtH = 630
+
+ // advSafeX is the horizontal margin anything readable has to stay inside.
+ //
+ // The card is 1200×630 for the OG ratio, but the feed thumbnail is a 16/10
+ // object-cover box — it keeps the full height and crops the WIDTH to
+ // 630×1.6 = 1008px, taking 96px off each side. A chip pinned at x=64 renders
+ // perfectly on the permalink and as "EGENDARY" in the feed. Centred text is
+ // unaffected; only the corner furniture has to respect this.
+ advSafeX = 116
+)
+
+// advDisplayFont is the site's display stack. No @font-face: an SVG served as an
+// image can't fetch one, so this resolves against whatever the renderer has and
+// falls through to the system UI face.
+const advDisplayFont = "Fredoka, Nunito, system-ui, sans-serif"
+
+// advRenderArt draws the card. Deterministic: same card in, same bytes out.
+func advRenderArt(c advArtCard) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, ``)
+ return b.String()
+}
+
+// advEmojiSize shrinks the emblem when the card also has to carry a name — the
+// old 260px glyph was the whole design, and next to a boss name it just crowds
+// it out.
+func advEmojiSize(c advArtCard) int {
+ if c.Noun == "" {
+ return 260
+ }
+ if c.Bar != nil {
+ return 110
+ }
+ return 148
+}
+
+// advDrawChip draws the event-family label as a pill in the top-left.
+func advDrawChip(b *strings.Builder, c advArtCard) {
+ label := advClamp(c.Label, 28)
+ // Letter-spaced small caps: width is the glyph run plus the tracking.
+ const size, track = 30, 5.0
+ w := int(float64(utf8.RuneCountInString(label))*(float64(size)*0.62+track)) + 56
+ fmt.Fprintf(b, ``,
+ advSafeX, w, esc(c.Palette.Accent), esc(c.Palette.Accent))
+ fmt.Fprintf(b, `%s`,
+ advSafeX+w/2, size, advDisplayFont, track, esc(label))
+}
+
+// advDrawRibbon draws the realm-first banner in the top-right. Filled with the
+// accent at full strength — this is the one card element allowed to shout.
+func advDrawRibbon(b *strings.Builder, c advArtCard) {
+ label := advClamp(c.Ceremony, 24)
+ const size, track = 28, 5.0
+ w := int(float64(utf8.RuneCountInString(label))*(float64(size)*0.62+track)) + 52
+ x := advArtW - advSafeX - w
+ fmt.Fprintf(b, ``, x, w, esc(c.Palette.Accent))
+ fmt.Fprintf(b, `%s`,
+ x+w/2, size, advDisplayFont, track, esc(label))
+}
+
+// advDrawBar draws the Siege health bar: the track, the fill, and the numbers.
+//
+// HP remaining, not damage dealt — the same direction the war room page draws,
+// so the unfurl and the page you land on from it agree. The caption is
+// event-aware because the bar alone doesn't say who won: an empty track on a
+// victory card is the best possible outcome and would otherwise read at a glance
+// as a wipe.
+func advDrawBar(b *strings.Builder, c advArtCard) {
+ const x, y, w, h = 190, 470, 820, 34
+ frac := 0.0
+ if c.Bar.Max > 0 {
+ frac = float64(c.Bar.Current) / float64(c.Bar.Max)
+ }
+ if frac < 0 {
+ frac = 0
+ }
+ if frac > 1 {
+ frac = 1
+ }
+ fill := int(frac * w)
+ fmt.Fprintf(b, ``, x, y, w, h, h/2)
+ if fill > 0 {
+ // rx on a very short fill would round it away to nothing; clamp the
+ // corner radius to half the drawn width so a nearly-dead boss still
+ // shows a sliver.
+ rx := h / 2
+ if fill/2 < rx {
+ rx = fill / 2
+ }
+ fmt.Fprintf(b, ``, x, y, fill, h, rx, esc(c.Palette.Accent))
+ }
+ fmt.Fprintf(b, `%s`,
+ y+h+40, advDisplayFont, esc(advBarCaption(c)))
+}
+
+// advBarCaption says what the bar means. A siege_win's bar is empty because the
+// town emptied it.
+func advBarCaption(c advArtCard) string {
+ switch {
+ case c.Bar.Current <= 0:
+ return fmt.Sprintf("all %s of it, brought to zero", advComma(c.Bar.Max))
+ case c.Bar.Current >= c.Bar.Max:
+ return fmt.Sprintf("%s HP, untouched so far", advComma(c.Bar.Max))
+ }
+ return fmt.Sprintf("%s HP left of %s", advComma(c.Bar.Current), advComma(c.Bar.Max))
+}
+
+// advComma renders an int with thousands separators. Siege pools run to five and
+// six figures, and "18000" is a number you have to stop and parse.
+func advComma(n int) string {
+ s := fmt.Sprintf("%d", n)
+ neg := strings.HasPrefix(s, "-")
+ s = strings.TrimPrefix(s, "-")
+ var out []byte
+ for i, d := range []byte(s) {
+ if i > 0 && (len(s)-i)%3 == 0 {
+ out = append(out, ',')
+ }
+ out = append(out, d)
+ }
+ if neg {
+ return "-" + string(out)
+ }
+ return string(out)
+}
+
+// advFitSize shrinks a font size until the string is likely to fit maxWidth,
+// never below min. SVG has no measurement API on the server, so this is the
+// standard approximation: ~0.58em per glyph for a humanist sans. Being a little
+// conservative is the correct failure — a name that renders a shade small is
+// fine, a name that runs off the card is not.
+func advFitSize(s string, maxWidth, base, min int) int {
+ n := utf8.RuneCountInString(s)
+ if n == 0 {
+ return base
+ }
+ size := base
+ for size > min && float64(n)*float64(size)*0.58 > float64(maxWidth) {
+ size -= 2
+ }
+ return size
+}
+
+// advClamp truncates to n runes with an ellipsis. Rune-aware so a non-ASCII
+// name isn't cut mid-character into a replacement glyph.
+func advClamp(s string, n int) string {
+ if utf8.RuneCountInString(s) <= n {
+ return s
+ }
+ r := []rune(s)
+ return strings.TrimRight(string(r[:n-1]), " ·") + "…"
+}
+
+// esc escapes text for an SVG text node or attribute value. Everything on a card
+// is either game-authored (boss, zone, item) or a character name that already
+// passed the ingest fact-guard, so this is defence in depth rather than the only
+// line — but the card is a public URL and it stays escaped regardless.
+func esc(s string) string { return template.HTMLEscapeString(s) }
diff --git a/internal/web/adventure_art_test.go b/internal/web/adventure_art_test.go
new file mode 100644
index 0000000..f70a119
--- /dev/null
+++ b/internal/web/adventure_art_test.go
@@ -0,0 +1,269 @@
+package web
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "pete/internal/storage"
+)
+
+// TestArtPaletteSplitsFamilies is the regression for the whole point of W3: two
+// different kinds of dispatch must not render the same image. Before this, every
+// card in the section was the same violet gradient with a swapped emoji.
+func TestArtPaletteSplitsFamilies(t *testing.T) {
+ death := advPaletteFor("death", "")
+ siege := advPaletteFor("siege_win", "")
+ zone := advPaletteFor("zone_clear", "")
+ if death == siege || siege == zone || death == zone {
+ t.Errorf("families share a palette: death=%v siege=%v zone=%v", death, siege, zone)
+ }
+
+ // Treasure is tinted by the rarity gogobee already computes and currently
+ // spends on an adjective in a sentence.
+ leg := advPaletteFor("treasure_found", "legendary")
+ rare := advPaletteFor("treasure_found", "rare")
+ if leg == rare {
+ t.Errorf("legendary and rare hoards render identically: %v", leg)
+ }
+ // Case shouldn't matter — the rarity word arrives however gogobee wrote it.
+ if advPaletteFor("treasure_found", "Legendary") != leg {
+ t.Error("rarity match is case-sensitive")
+ }
+ // An unrecognised rarity still gets the story-grade treatment rather than
+ // falling through to the neutral card.
+ if advPaletteFor("treasure_found", "mythic") != leg {
+ t.Error("unknown rarity dropped to the neutral palette")
+ }
+}
+
+// TestRealmFirstEarnsCeremony pins that the priority/bulletin split gogobee
+// already computes now changes how a card LOOKS, not just whether Matrix gets
+// pinged. A first-ever clear and the ninth repeat of it must be distinguishable.
+func TestRealmFirstEarnsCeremony(t *testing.T) {
+ cases := []struct {
+ eventType, tier string
+ want bool
+ }{
+ {"zone_first", "priority", true},
+ {"zone_clear", "bulletin", false},
+ {"boss_first", "priority", true},
+ {"boss_kill", "bulletin", false},
+ {"treasure_found", "priority", true}, // realm-first hoard
+ {"treasure_found", "bulletin", false}, // someone else already pulled it
+ {"death", "priority", false}, // priority, but not a "first"
+ }
+ for _, c := range cases {
+ if got := advIsRealmFirst(c.eventType, c.tier); got != c.want {
+ t.Errorf("%s/%s ceremony = %v, want %v", c.eventType, c.tier, got, c.want)
+ }
+ }
+
+ // And it reaches the card as a ribbon.
+ ev := &storage.AdvEvent{EventType: "zone_first", Tier: "priority", Subject: "Josie", Zone: "The Sump", Level: 9}
+ card := advArtCardFor("zone_first", ev)
+ if card.Ceremony == "" {
+ t.Error("realm-first card has no ribbon")
+ }
+ if !strings.Contains(advRenderArt(card), card.Ceremony) {
+ t.Error("ribbon text never reached the SVG")
+ }
+ plain := advArtCardFor("zone_clear", &storage.AdvEvent{EventType: "zone_clear", Tier: "bulletin", Subject: "Josie", Zone: "The Sump"})
+ if plain.Ceremony != "" {
+ t.Error("a repeat clear got the realm-first ribbon")
+ }
+}
+
+// TestArtCardCarriesNouns: the card names the thing the dispatch is about, and
+// which field that is differs per family. A siege is about the boss; a treasure
+// is about the item.
+func TestArtCardCarriesNouns(t *testing.T) {
+ treasure := advArtCardFor("treasure_found", &storage.AdvEvent{
+ EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
+ Zone: "The Sump", Stakes: "Ring of Nine Sorrows", Outcome: "epic", Level: 12,
+ })
+ if treasure.Noun != "Ring of Nine Sorrows" {
+ t.Errorf("treasure noun = %q, want the item", treasure.Noun)
+ }
+ if treasure.Label != "EPIC" {
+ t.Errorf("treasure label = %q, want the rarity", treasure.Label)
+ }
+ svg := advRenderArt(treasure)
+ for _, want := range []string{"Ring of Nine Sorrows", "Josie", "The Sump", "level 12", "EPIC"} {
+ if !strings.Contains(svg, want) {
+ t.Errorf("treasure card missing %q", want)
+ }
+ }
+
+ boss := advArtCardFor("boss_kill", &storage.AdvEvent{
+ EventType: "boss_kill", Subject: "Josie", Boss: "Aldric the Pale", Zone: "dragons_lair", Level: 14,
+ })
+ if boss.Noun != "Aldric the Pale" {
+ t.Errorf("boss noun = %q, want the boss", boss.Noun)
+ }
+
+ // A dispatch with no stored fact still renders — that is every story from
+ // before the fact table, plus the window where the best-effort fact insert
+ // failed. It degrades to the old emblem, not to a broken image.
+ bare := advArtCardFor("death", nil)
+ if bare.Noun != "" || bare.Label == "" {
+ t.Errorf("factless card = %+v, want label-only", bare)
+ }
+ if b := advRenderArt(bare); !strings.Contains(b, "