adventure: give every dispatch a card that looks like what it is

Every dispatch rendered the same image: one violet gradient, a swapped
emoji, a label. A death, a first-ever clear 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.

The card is now keyed on the dispatch GUID instead of the event type,
so the renderer can read the fact behind it and put the actual nouns on
it — the boss's name, the zone, the item, the level. Each family gets
its own palette, and treasure is tinted by the rarity gogobee already
computes and currently spends on an adjective in a sentence.

A realm-first gets visible ceremony. The priority/bulletin split is
already computed upstream 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. It earns a ribbon on the card and
a badge and a heavier ring in the feed.

Siege cards carry the HP bar, which is the W1 deferral landing. The
siege fact has the boss and the defender count but never the HP, so the
bar comes from the war-room snapshot: the live row while that boss is
still camped, the history once it has closed. When neither has it yet —
the real two-minute window between a win being filed and the push that
explains it — the card renders barless rather than wrong, and caches
for five minutes instead of a day so the unfurl isn't pinned that way.

Nothing needs backfilling. A story with a type-keyed image_url still
serves, and a dispatch with no stored fact degrades to the old emblem.

Two things learned by looking at the rendered output rather than at the
tests, both of which unit tests would never have caught: the feed
thumbnail is a 16/10 crop of a 1200x630 card and was eating the chip
("EGENDARY"), hence advSafeX; and an empty bar under a victory headline
reads at a glance as a wipe, hence the event-aware caption.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 15:47:44 -07:00
parent 23563b6a6a
commit 7051e8ffff
8 changed files with 964 additions and 45 deletions
+6 -40
View File
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7c5ce8"/>
<stop offset="1" stop-color="#5836b8"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#g)"/>
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
</svg>`
// 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,