From 9d9cfd9f9aa8c60b1f8eb9a119dcfecb295785cf Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:31:15 -0700 Subject: [PATCH] adventure: fix three review findings on the who page - timelineLine named the viewer as their own rival on a lost duel: a rival_result arrives on the loser's page via the opponent column, so naming the opponent field pointed at themselves. Thread the page character's name through and name the other party. - buildMapView drew a frontier room twice when two visited rooms both had a door to it (Nodes carried the id twice). Place each id once. - proseGuard's board query ran before the IsGUIDSeen dedup, so a retried dispatch paid for it and threw it away. Check dedup first. --- internal/web/adventure.go | 16 +++++++++------- internal/web/who.go | 20 ++++++++++++++------ internal/web/who_map.go | 9 +++++++++ 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/internal/web/adventure.go b/internal/web/adventure.go index 258bf6d..5ccd34a 100644 --- a/internal/web/adventure.go +++ b/internal/web/adventure.go @@ -108,6 +108,15 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { http.Error(w, "unknown event_type", http.StatusBadRequest) return } + // Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked + // before the prose-guard because the guard runs a board query (KnownCharacterNames); + // a retried dispatch we already have a story for should cost nothing. + if storage.IsGUIDSeen(f.GUID) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("duplicate")) + return + } + // Prefer gogobee's LLM prose when it is present and safe. Both fields must be // supplied — a half-authored dispatch is not a voice, and mixing an LLM // headline with a template lede reads as two writers. The guard is what makes @@ -122,13 +131,6 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { } } - // Idempotent: a re-delivered fact (gogobee retry) is a no-op success. - if storage.IsGUIDSeen(f.GUID) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("duplicate")) - return - } - // A fact with no occurred_at would otherwise be stored at the Unix epoch: // dated 1970 on the permalink, pinned to the bottom of the section, and // outside every digest window. Treat "missing" as "now". diff --git a/internal/web/who.go b/internal/web/who.go index 758533b..5427609 100644 --- a/internal/web/who.go +++ b/internal/web/who.go @@ -213,7 +213,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) { if page.MoreHistory { events = events[:timelineCap] } - page.Timeline = buildTimeline(s, events) + page.Timeline = buildTimeline(s, entry.Name, events) } // Owner enrichment: only when the signed-in user's localpart owns this exact @@ -279,14 +279,14 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) { // ever: Josie brings down the Rotmother"), and forty of those stacked in a column // read as a wall of shouting. The trail wants the noun, not the announcement. It // also saves a join back to stories for every row. -func buildTimeline(s *Server, events []storage.AdvEvent) []timelineEntry { +func buildTimeline(s *Server, subject string, events []storage.AdvEvent) []timelineEntry { out := make([]timelineEntry, 0, len(events)) for _, e := range events { label, emoji := advEventMeta(e.EventType) out = append(out, timelineEntry{ Emoji: emoji, Label: label, - Line: timelineLine(e), + Line: timelineLine(subject, e), When: time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"), Permalink: s.advPermalink(e.GUID), // A realm-first hoard is the treasure worth an eye; a plain find rides @@ -301,7 +301,7 @@ func buildTimeline(s *Server, events []storage.AdvEvent) []timelineEntry { // timelineLine is the short "what" of a fact: the monster, the zone, the rival. // Empty is fine — the label and emoji already carry the event, and inventing // filler for a fact that carries no nouns would just be noise. -func timelineLine(e storage.AdvEvent) string { +func timelineLine(subject string, e storage.AdvEvent) string { inZone := func(s string) string { if e.Zone == "" { return s @@ -321,8 +321,16 @@ func timelineLine(e storage.AdvEvent) string { } return e.Zone case "rival_result", "pete_duel_win", "pete_duel_loss": - if e.Opponent != "" { - return "vs " + e.Opponent + // Name the *other* party. On the winner's page the rival is the opponent; + // on the loser's page this fact arrives via the opponent column, so the + // rival is the subject — naming e.Opponent there would point the viewer at + // themselves. Pete duels carry no character opponent, so this stays empty. + other := e.Opponent + if e.Opponent == subject { + other = e.Subject + } + if other != "" && other != subject { + return "vs " + other } return "" case "milestone": diff --git a/internal/web/who_map.go b/internal/web/who_map.go index eab38bc..bb23742 100644 --- a/internal/web/who_map.go +++ b/internal/web/who_map.go @@ -116,7 +116,16 @@ func buildMapView(m *whoMap) *mapView { // Group nodes by depth column, each column ordered by first appearance. maxDepth := 0 byCol := map[int][]string{} + placed := make(map[string]bool, len(m.Nodes)) for _, n := range m.Nodes { + // Two visited rooms can both list a door to the same withheld frontier + // room, so the same id can appear twice in Nodes. Place it once, or it + // draws as two overlapping discs and its edges resolve to whichever copy + // landed last. + if placed[n.ID] { + continue + } + placed[n.ID] = true d := depth[n.ID] byCol[d] = append(byCol[d], n.ID) if d > maxDepth {