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.
This commit is contained in:
prosolis
2026-07-17 10:31:15 -07:00
parent 8a5fea78ba
commit 9d9cfd9f9a
3 changed files with 32 additions and 13 deletions

View File

@@ -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".

View File

@@ -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":

View File

@@ -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 {