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, ``, advArtW, advArtH, advArtW, advArtH, esc(c.Label+" "+c.Noun)) fmt.Fprintf(&b, ``, 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, ``, esc(c.Palette.Accent), esc(c.Palette.Accent)) fmt.Fprintf(&b, ``, 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, ``, 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, `%s`, emojiY, advEmojiSize(c), esc(c.Emoji)) if c.Noun != "" { noun := advClamp(c.Noun, 38) fmt.Fprintf(&b, `%s`, nounY, advFitSize(noun, 1060, 82, 40), advDisplayFont, esc(noun)) } if c.Detail != "" { detail := advClamp(c.Detail, 64) fmt.Fprintf(&b, `%s`, detailY, advFitSize(detail, 1040, 38, 26), advDisplayFont, esc(detail)) } if c.Bar != nil { advDrawBar(&b, c) } b.WriteString(``) 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) }