Files
Pete/internal/web/adventure_art.go
prosolis 7051e8ffff 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
2026-07-24 15:47:44 -07:00

507 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<hash>:<ts>") 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, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
advArtW, advArtH, advArtW, advArtH, esc(c.Label+" "+c.Noun))
fmt.Fprintf(&b, `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%s"/><stop offset="1" stop-color="%s"/></linearGradient>`,
esc(c.Palette.From), esc(c.Palette.To))
// A soft radial lift behind the emoji so the middle of the card isn't flat.
fmt.Fprintf(&b, `<radialGradient id="v" cx="0.5" cy="0.34" r="0.62"><stop offset="0" stop-color="%s" stop-opacity="0.35"/><stop offset="1" stop-color="%s" stop-opacity="0"/></radialGradient></defs>`,
esc(c.Palette.Accent), esc(c.Palette.Accent))
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="url(#g)"/><rect width="%d" height="%d" fill="url(#v)"/>`,
advArtW, advArtH, advArtW, advArtH)
// An accent hairline along the bottom, so even a card cropped to a strip in
// a feed still carries its family colour.
fmt.Fprintf(&b, `<rect x="0" y="%d" width="%d" height="8" fill="%s"/>`, advArtH-8, advArtW, esc(c.Palette.Accent))
advDrawChip(&b, c)
if c.Ceremony != "" {
advDrawRibbon(&b, c)
}
// Vertical rhythm: emoji, noun, detail, and the bar when there is one. The
// block sits higher when a bar has to fit under it. The gaps are wider than
// they look on paper because an emoji is drawn from its own centre and a
// name from its baseline — set them any closer and a tall glyph sits on top
// of the capital letters underneath it.
emojiY, nounY, detailY := 250, 420, 488
if c.Bar != nil {
emojiY, nounY, detailY = 200, 350, 412
}
if c.Noun == "" {
// Nothing to name (a pre-fact-table dispatch): centre the emoji and let
// the label carry the card, which is what the old emblem did.
emojiY, nounY, detailY = 300, 0, 500
}
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" text-anchor="middle" dominant-baseline="central">%s</text>`,
emojiY, advEmojiSize(c), esc(c.Emoji))
if c.Noun != "" {
noun := advClamp(c.Noun, 38)
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" font-family="%s" font-weight="700" fill="#ffffff" text-anchor="middle">%s</text>`,
nounY, advFitSize(noun, 1060, 82, 40), advDisplayFont, esc(noun))
}
if c.Detail != "" {
detail := advClamp(c.Detail, 64)
fmt.Fprintf(&b, `<text x="600" y="%d" font-size="%d" font-family="%s" font-weight="600" fill="#ffffff" fill-opacity="0.72" text-anchor="middle">%s</text>`,
detailY, advFitSize(detail, 1040, 38, 26), advDisplayFont, esc(detail))
}
if c.Bar != nil {
advDrawBar(&b, c)
}
b.WriteString(`</svg>`)
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, `<rect x="%d" y="56" width="%d" height="60" rx="30" fill="%s" fill-opacity="0.22" stroke="%s" stroke-opacity="0.55" stroke-width="2"/>`,
advSafeX, w, esc(c.Palette.Accent), esc(c.Palette.Accent))
fmt.Fprintf(b, `<text x="%d" y="86" font-size="%d" font-family="%s" font-weight="700" fill="#ffffff" letter-spacing="%.0f" text-anchor="middle" dominant-baseline="central">%s</text>`,
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, `<rect x="%d" y="56" width="%d" height="60" rx="12" fill="%s"/>`, x, w, esc(c.Palette.Accent))
fmt.Fprintf(b, `<text x="%d" y="86" font-size="%d" font-family="%s" font-weight="700" fill="#0f0a04" letter-spacing="%.0f" text-anchor="middle" dominant-baseline="central">%s</text>`,
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, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d" fill="#000000" fill-opacity="0.38"/>`, 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, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d" fill="%s"/>`, x, y, fill, h, rx, esc(c.Palette.Accent))
}
fmt.Fprintf(b, `<text x="600" y="%d" font-size="30" font-family="%s" font-weight="700" fill="#ffffff" fill-opacity="0.82" text-anchor="middle">%s</text>`,
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) }