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,
+506
View File
@@ -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:<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) }
+269
View File
@@ -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, "<svg") || !strings.Contains(b, "🪦") {
t.Errorf("factless card didn't render an emblem: %s", b)
}
}
// TestSiegeCardDrawsTheBar is the W1 deferral landing. A Matrix unfurl of "the
// town holds" that shows the bar is worth ten paragraphs — and the bar has to
// come from the war-room snapshot, because the siege fact carries the boss and
// the defender count but never the HP.
func TestSiegeCardDrawsTheBar(t *testing.T) {
newAdvServer(t, "t") // fresh temp DB
if err := storage.ReplaceSiege(storage.Siege{
Active: false,
History: []storage.SiegePast{{
BossID: 7, BossName: "The Rust Sovereign", Tier: 5, Outcome: "defeated",
HPRemaining: 0, HPMax: 18000, Defenders: 6, EndedAt: 5000,
}},
}, 5000); err != nil {
t.Fatalf("seed siege: %v", err)
}
ev := &storage.AdvEvent{EventType: "siege_win", Tier: "priority",
Boss: "The Rust Sovereign", Tally: 6, OccurredAt: 5000}
card := advArtCardFor("siege_win", ev)
if card.Bar == nil {
t.Fatal("siege card has no bar")
}
if card.Bar.Max != 18000 {
t.Errorf("bar max = %d, want the pool", card.Bar.Max)
}
svg := advRenderArt(card)
for _, want := range []string{"The Rust Sovereign", "6 defenders", "brought to zero"} {
if !strings.Contains(svg, want) {
t.Errorf("siege card missing %q", want)
}
}
// A live Siege draws off the live row, so a siege_start card keeps sliding
// as the town chips away rather than freezing at the spawn value.
if err := storage.ReplaceSiege(storage.Siege{
Active: true, BossID: 8, BossName: "The Rust Sovereign", Tier: 5,
HPCurrent: 6200, HPMax: 18000, EndsAt: 9000,
}, 6000); err != nil {
t.Fatalf("seed live siege: %v", err)
}
live := advArtCardFor("siege_start", &storage.AdvEvent{
EventType: "siege_start", Boss: "The Rust Sovereign", OccurredAt: 6000})
if live.Bar == nil || live.Bar.Current != 6200 {
t.Fatalf("live siege bar = %+v, want the current pool", live.Bar)
}
// An unknown boss gets a siege card with no bar rather than a wrong one.
// This is the real window between a win being filed and the war-room push
// that explains it landing two minutes later.
orphan := advArtCardFor("siege_loss", &storage.AdvEvent{
EventType: "siege_loss", Boss: "Nobody In Particular", OccurredAt: 1})
if orphan.Bar != nil {
t.Errorf("drew a bar for a boss we have no snapshot of: %+v", orphan.Bar)
}
if !strings.Contains(advRenderArt(orphan), "Nobody In Particular") {
t.Error("barless siege card lost its boss name")
}
}
// TestArtRenderIsDeterministicAndEscaped: the card is a public URL served as an
// image, and it must be byte-stable so it can be cached hard.
func TestArtRenderIsDeterministicAndEscaped(t *testing.T) {
card := advArtCardFor("death", &storage.AdvEvent{
EventType: "death", Subject: `Bob<script>alert(1)</script>`, Zone: "the Underforge", Level: 3})
a := advRenderArt(card)
if a != advRenderArt(card) {
t.Error("render is not deterministic")
}
if strings.Contains(a, "<script>") {
t.Errorf("unescaped markup reached the SVG: %s", a)
}
// Long names shrink to fit instead of running off the 1200px card.
long := advArtCardFor("boss_kill", &storage.AdvEvent{
EventType: "boss_kill", Boss: strings.Repeat("Nebuchadnezzar ", 6)})
if got := advFitSize(long.Noun, 1060, 82, 40); got >= 82 {
t.Errorf("long name kept the full font size (%d)", got)
}
if n := len([]rune(advClamp(long.Noun, 38))); n > 38 {
t.Errorf("clamp let %d runes through", n)
}
}
// TestCardAccentTintsTheFeed pins the border tint the feed cards read. It is the
// same palette the art uses, so a card and its thumbnail agree.
func TestCardAccentTintsTheFeed(t *testing.T) {
legendary, first := advCardAccent("treasure_found", "priority", "legendary")
if legendary == "" || !first {
t.Errorf("legendary realm-first accent = %q ceremony=%v", legendary, first)
}
common, _ := advCardAccent("treasure_found", "bulletin", "common")
if common == legendary {
t.Error("a common find is tinted like a legendary one")
}
if a, _ := advCardAccent("", "", ""); a != "" {
t.Error("a non-adventure story got an accent")
}
if legendary != advPaletteFor("treasure_found", "legendary").Accent {
t.Error("feed accent and card art disagree on the colour")
}
}
// TestFeedCardRendersTheTint renders the real adventure page through the real
// template, because the interesting failure here is not in Go.
//
// html/template escapes a style attribute in CSS context and will replace a
// value it doesn't trust with ZgotmplZ, silently — the card would render with no
// border colour and nothing would say why. This is also the reason the accent is
// an inline style at all: a generated Tailwind class would be purged out of the
// stylesheet, which fails the same way and just as quietly.
func TestFeedCardRendersTheTint(t *testing.T) {
s, _ := newAdvServer(t, "t")
first := AdvFact{GUID: "zone_first:a:1000", EventType: "zone_first", Tier: "priority",
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1000}
repeat := AdvFact{GUID: "zone_clear:b:1001", EventType: "zone_clear", Tier: "bulletin",
Actors: []string{"Josie"}, Subject: "Josie", Zone: "The Sump", Region: "Marches", Level: 9, OccurredAt: 1001}
for _, f := range []AdvFact{first, repeat} {
if rw := postFact(t, s, "t", f); rw.Code != 200 {
t.Fatalf("ingest %s: status %d", f.GUID, rw.Code)
}
}
req := httptest.NewRequest("GET", "/adventure", nil)
w := httptest.NewRecorder()
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
body := w.Body.String()
accent := advPaletteFor("zone_first", "").Accent
if !strings.Contains(body, "border-color:"+accent) {
t.Errorf("zone accent %q never reached the card border", accent)
}
if strings.Contains(body, "ZgotmplZ") {
t.Error("html/template rejected the accent as an unsafe CSS value")
}
if !strings.Contains(body, "Realm first") {
t.Error("the realm-first card has no ribbon in the feed")
}
// Exactly one of the two cards is a first. If both got the badge the split
// isn't doing anything.
if n := strings.Count(body, "Realm first"); n != 1 {
t.Errorf("realm-first badges = %d, want 1", n)
}
}
func TestCommaFormatsPools(t *testing.T) {
for in, want := range map[int]string{0: "0", 999: "999", 1000: "1,000", 18000: "18,000", 1234567: "1,234,567"} {
if got := advComma(in); got != want {
t.Errorf("advComma(%d) = %q, want %q", in, got, want)
}
}
}
+19 -5
View File
@@ -249,7 +249,9 @@ func TestAdventureArtAndMeta(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
// Emblem endpoint returns SVG with the event's emoji.
// A bare event type still renders — that is what every story ingested before
// the card became guid-keyed has in its image_url column, and those links
// must not start 404ing.
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
areq.SetPathValue("type", "death.svg")
arw := httptest.NewRecorder()
@@ -264,18 +266,30 @@ func TestAdventureArtAndMeta(t *testing.T) {
t.Errorf("art body missing emblem: %s", b)
}
// Ingest sets the card image to the local emblem path.
// Ingest keys the card image on the GUID, not the event type: the renderer
// reads the fact behind the dispatch so it can name the zone and the level
// instead of drawing the identical emblem for every death.
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
got, _ := storage.GetStoryByGUID("death:abc:1000")
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
if got == nil || got.ImageURL != "/adventure/art/death:abc:1000.svg" {
t.Errorf("story image = %q", got.ImageURL)
}
// Permalink page is noindex with an og:image.
// And that card carries the nouns.
greq := httptest.NewRequest("GET", "/adventure/art/death:abc:1000.svg", nil)
greq.SetPathValue("type", "death:abc:1000.svg")
grw := httptest.NewRecorder()
s.handleAdventureArt(grw, greq)
gb := grw.Body.String()
if !strings.Contains(gb, "Brannigan") || !strings.Contains(gb, "the Underforge") || !strings.Contains(gb, "level 4") {
t.Errorf("guid card missing the fact's nouns: %s", gb)
}
// Permalink page is noindex with an og:image pointing at that same card.
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
preq.SetPathValue("guid", "death:abc:1000")
prw := httptest.NewRecorder()
@@ -284,7 +298,7 @@ func TestAdventureArtAndMeta(t *testing.T) {
if !strings.Contains(body, `name="robots" content="noindex"`) {
t.Error("permalink not noindex")
}
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death:abc:1000.svg"`) {
t.Errorf("permalink missing og:image; body=%s", body)
}
}
+42
View File
@@ -18,6 +18,7 @@ const pageSize = 24
// StoryView is the trimmed-down record used in templates.
type StoryView struct {
ID int64
GUID string
Headline string
Lede string
ImageURL string
@@ -29,11 +30,14 @@ type StoryView struct {
Channel string // channel slug; also the theme key
ReadMins int // estimated reading time in minutes; 0 = unknown (no chip)
Views int // all-time reader-mode opens; 0 = none yet (no badge)
Accent string // adventure only: the event family's colour, "" for everything else
Ceremony bool // adventure only: this is a realm-first and gets the ribbon
}
func toView(s storage.Story) StoryView {
return StoryView{
ID: s.ID,
GUID: s.GUID,
Headline: s.Headline,
Lede: s.Lede,
ImageURL: s.ImageURL,
@@ -73,6 +77,44 @@ func decorate(groups ...[]StoryView) {
g[i].Views = views[g[i].ID]
}
}
decorateAdventure(groups...)
}
// decorateAdventure tints adventure cards by what they are: the event family's
// accent on the border, and the realm-first ribbon on a first-ever.
//
// The information was always there — gogobee computes the rarity of a find and
// whether a clear is the realm's first, and both were being spent on a sentence
// and then thrown away. A feed where a legendary hoard and a routine repeat look
// identical is throwing away the game's own sense of occasion.
//
// One batched read over the guids of the adventure cards only, skipped entirely
// on a page with none — which is most pages. Best-effort like the rest of
// decorate: a miss leaves the default border, not a broken card.
func decorateAdventure(groups ...[]StoryView) {
var guids []string
seen := make(map[string]bool)
for _, g := range groups {
for _, v := range g {
if v.Channel == "adventure" && v.GUID != "" && !seen[v.GUID] {
seen[v.GUID] = true
guids = append(guids, v.GUID)
}
}
}
if len(guids) == 0 {
return
}
facets := storage.AdventureEventFacets(guids)
for _, g := range groups {
for i := range g {
ev, ok := facets[g[i].GUID]
if !ok {
continue
}
g[i].Accent, g[i].Ceremony = advCardAccent(ev.EventType, ev.Tier, ev.Outcome)
}
}
}
// readMinutes turns a character count into a rounded minutes-to-read estimate,
+11
View File
@@ -10,6 +10,11 @@
data-ch-title="{{$ch.Title}}" data-ch-emoji="{{$ch.Emoji}}" data-ch-theme="{{$ch.Theme}}"
data-posted="{{if .Story.Posted}}1{{end}}"
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
{{/* An adventure card borrows its event family's colour for the border, and
a realm-first gets a heavier ring on top of it. Inline style rather than
a class: the palette is a Go table of hex values, and a generated
Tailwind class would be purged out of the stylesheet and fail silently. */}}
{{if .Story.Accent}}style="border-color:{{.Story.Accent}}{{if .Story.Ceremony}};box-shadow:0 0 0 4px {{.Story.Accent}}33, 0 10px 30px -12px {{.Story.Accent}}{{end}}"{{end}}
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
<span role="button" tabindex="0" data-bookmark-btn data-story-id="{{.Story.ID}}"
aria-label="Bookmark this story" aria-pressed="false"
@@ -36,6 +41,12 @@
{{.Story.Source}}
</span>
{{end}}
{{if .Story.Ceremony}}
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 font-bold uppercase tracking-wider text-white"
style="background-color:{{.Story.Accent}}" title="First time this has ever happened in the realm">
<span aria-hidden="true" class="mr-1"></span>Realm first
</span>
{{end}}
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
{{if .Story.ReadMins}}<span class="text-[color:var(--ink)]/45">· {{.Story.ReadMins}} min read</span>{{end}}
{{if .Story.Views}}<span class="ml-auto inline-flex items-center gap-1 text-[color:var(--ink)]/45 tabular-nums" title="{{.Story.Views}} reads">