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, "