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) }