From 7051e8ffffac4e92e84ee5be1ae8623b2025b988 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:47:44 -0700 Subject: [PATCH] adventure: give every dispatch a card that looks like what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/storage/adventure.go | 68 ++++ internal/storage/siege.go | 43 +++ internal/web/adventure.go | 46 +-- internal/web/adventure_art.go | 506 +++++++++++++++++++++++++++++ internal/web/adventure_art_test.go | 269 +++++++++++++++ internal/web/adventure_test.go | 24 +- internal/web/handlers.go | 42 +++ internal/web/templates/_card.html | 11 + 8 files changed, 964 insertions(+), 45 deletions(-) create mode 100644 internal/web/adventure_art.go create mode 100644 internal/web/adventure_art_test.go diff --git a/internal/storage/adventure.go b/internal/storage/adventure.go index 8a2f612..b001917 100644 --- a/internal/storage/adventure.go +++ b/internal/storage/adventure.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "sort" + "strings" ) // The durable record of what has actually happened in the realm. @@ -60,6 +61,73 @@ func InsertAdventureEvent(e *AdvEvent) error { return err } +// AdventureEventByGUID returns the stored fact behind one dispatch, or nil when +// there isn't one. Missing is normal, not an error: the story row is inserted +// first and the fact record is best-effort (see handleAdventureIngest), and every +// dispatch that predates the fact table has a story and no fact at all. Callers +// render the thinner event_type-only view in that case. +func AdventureEventByGUID(guid string) (*AdvEvent, error) { + if guid == "" { + return nil, nil + } + var e AdvEvent + var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString + err := Get().QueryRow(` + SELECT guid, event_type, tier, subject, opponent, boss, zone, region, + level, tally, outcome, milestone, stakes, actors, occurred_at + FROM adventure_events WHERE guid = ?`, guid).Scan( + &e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion, + &e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &e.OccurredAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String + e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String + e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String + if actors.String != "" { + _ = json.Unmarshal([]byte(actors.String), &e.Actors) + } + return &e, nil +} + +// AdventureEventFacets returns the (event_type, tier, outcome) of many dispatches +// in one read, keyed by guid. It exists so a feed page can tint two dozen cards +// by what they actually are without paying a query per card — the pool is +// MaxOpenConns(1), so N round trips would serialize behind each other. +// +// Guids absent from the map are dispatches with no fact row; the caller leaves +// those untinted rather than guessing. +func AdventureEventFacets(guids []string) map[string]AdvEvent { + out := make(map[string]AdvEvent, len(guids)) + if len(guids) == 0 { + return out + } + q := `SELECT guid, event_type, tier, outcome FROM adventure_events WHERE guid IN (?` + + strings.Repeat(`,?`, len(guids)-1) + `)` + args := make([]any, len(guids)) + for i, g := range guids { + args[i] = g + } + rows, err := Get().Query(q, args...) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var e AdvEvent + var tier, outcome sql.NullString + if err := rows.Scan(&e.GUID, &e.EventType, &tier, &outcome); err != nil { + return out + } + e.Tier, e.Outcome = tier.String, outcome.String + out[e.GUID] = e + } + return out +} + // BossTally is one monster and how many times this adventurer has put it down. type BossTally struct { Boss string `json:"boss"` diff --git a/internal/storage/siege.go b/internal/storage/siege.go index 3578e23..a17bee5 100644 --- a/internal/storage/siege.go +++ b/internal/storage/siege.go @@ -131,6 +131,49 @@ func ReplaceSiege(s Siege, snapshotAt int64) error { return tx.Commit() } +// SiegeBarForBoss finds the HP bar to draw on a siege dispatch's card: current +// and max HP for the named boss around the time the dispatch was filed. +// +// A siege fact carries the boss and the defender count but not the HP, so the +// bar has to come from the war-room snapshot. Two places to look, in order: +// +// - the live row, when that boss is still camped (a siege_start card should +// show the bar as it stands right now, and it will keep sliding as the town +// chips away); +// - the history, for a Siege that has closed — matched on name and then on +// the row that ended nearest the dispatch, since the same boss comes back +// month after month and only the clock separates the two. +// +// ok is false when neither has it, which is a real and temporary state: the +// win/loss dispatch is filed the moment the Siege resolves, and the history that +// explains it doesn't reach Pete until the next 2-minute push. The card renders +// without a bar in the meantime rather than drawing a wrong one. +func SiegeBarForBoss(boss string, at int64) (current, max int, ok bool) { + if boss == "" { + return 0, 0, false + } + var active bool + var name string + var hpCur, hpMax int + err := Get().QueryRow(` + SELECT active, boss_name, hp_current, hp_max FROM adventure_siege WHERE id = 1`). + Scan(&active, &name, &hpCur, &hpMax) + if err == nil && active && name == boss && hpMax > 0 { + return hpCur, hpMax, true + } + + // ORDER BY the distance from the dispatch, so a boss that has besieged the + // town three times resolves to the siege this dispatch is actually about. + err = Get().QueryRow(` + SELECT hp_remaining, hp_max FROM adventure_siege_history + WHERE boss_name = ? AND hp_max > 0 + ORDER BY ABS(ended_at - ?) ASC LIMIT 1`, boss, at).Scan(&hpCur, &hpMax) + if err != nil { + return 0, 0, false + } + return hpCur, hpMax, true +} + // LoadSiege returns the war room as last pushed. ok is false when gogobee has // never pushed one at all — distinct from a pushed snapshot that says no Siege // is camped, which is a real answer the page can render. diff --git a/internal/web/adventure.go b/internal/web/adventure.go index fde4fb5..9966882 100644 --- a/internal/web/adventure.go +++ b/internal/web/adventure.go @@ -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 = ` - - - - - - - - %s - %s -` - // 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, diff --git a/internal/web/adventure_art.go b/internal/web/adventure_art.go new file mode 100644 index 0000000..2c3aa15 --- /dev/null +++ b/internal/web/adventure_art.go @@ -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::") 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) } diff --git a/internal/web/adventure_art_test.go b/internal/web/adventure_art_test.go new file mode 100644 index 0000000..f70a119 --- /dev/null +++ b/internal/web/adventure_art_test.go @@ -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, "alert(1)`, Zone: "the Underforge", Level: 3}) + a := advRenderArt(card) + if a != advRenderArt(card) { + t.Error("render is not deterministic") + } + if strings.Contains(a, "