Files
Pete/internal/web/who_map.go
prosolis 9d9cfd9f9a 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.
2026-07-17 10:31:15 -07:00

233 lines
7.0 KiB
Go

package web
// Dungeon map rendering.
//
// gogobee sends the fog-of-war cut of an adventurer's zone graph (whoMap):
// every room they have visited with its true kind, plus the one-hop frontier of
// doors leading out of visited rooms, with the rooms behind them withheld as
// kind "unknown". Pete lays that graph out and draws it as an inline SVG on the
// who page. No node labels or contents ever cross the wire — the map knows a
// room is a trap, never what the trap is.
//
// Layout is a left-to-right layering: column = BFS depth from the entry along
// the edges we were given, row = order the node first appears in the payload.
// It is computed server-side and fully deterministic, so the same run draws the
// same map every render.
// Layout geometry. The node/ring radii live in who.html and the map CSS; only
// the spacing the layout math needs is here.
const (
mapColGap = 84 // horizontal spacing between depth columns
mapRowGap = 60 // vertical spacing between nodes sharing a column
mapMargin = 26 // padding around the node field
)
// mapView is the laid-out map, ready for the template's SVG.
type mapView struct {
Nodes []mapNode
Edges []mapEdge
W int // svg viewBox width
H int // svg viewBox height
}
type mapNode struct {
X, Y int
Kind string // ZoneNodeKind, or "unknown" for an unreached frontier room
Glyph string // the single character drawn inside the node
Class string // CSS class picking the node's colour family
Label string // accessible title, e.g. "Trap" or "Unexplored"
Current bool // the room the adventurer is standing in
}
type mapEdge struct {
X1, Y1, X2, Y2 int
Locked bool
Lock string // the gate's kind, for the door's title text
}
// nodeStyle maps a room kind to how it draws. Kept small and total: an unknown
// kind (a room type Pete hasn't been taught yet) falls through to a neutral dot
// rather than vanishing.
type nodeStyle struct {
glyph string
class string
label string
}
var mapNodeStyles = map[string]nodeStyle{
"entry": {"⌂", "map-node-entry", "Entrance"},
"exploration": {"·", "map-node-plain", "Room"},
"trap": {"!", "map-node-trap", "Trap"},
"elite": {"◆", "map-node-elite", "Elite"},
"boss": {"♛", "map-node-boss", "Boss"}, // text dingbats only — U+26CF/U+2620 fall back to emoji or tofu
"harvest": {"❖", "map-node-harvest", "Harvest"},
"rest_camp": {"✚", "map-node-rest", "Rest camp"},
"secret": {"✦", "map-node-secret", "Secret"},
"fork": {"⋔", "map-node-plain", "Fork"},
"merge": {"⋏", "map-node-plain", "Merge"},
"unknown": {"?", "map-node-unknown", "Unexplored"},
}
func styleFor(kind string) nodeStyle {
if s, ok := mapNodeStyles[kind]; ok {
return s
}
return nodeStyle{"·", "map-node-plain", "Room"}
}
// buildMapView lays out a fog-of-war graph. Returns nil when there is nothing
// worth drawing (no nodes) so the template can skip the panel entirely.
func buildMapView(m *whoMap) *mapView {
if m == nil || len(m.Nodes) == 0 {
return nil
}
// Adjacency for the depth walk, and appearance order for stable rows.
order := make(map[string]int, len(m.Nodes))
for i, n := range m.Nodes {
if _, dup := order[n.ID]; !dup {
order[n.ID] = i
}
}
adj := make(map[string][]string, len(m.Nodes))
for _, e := range m.Edges {
// Only lay out edges whose endpoints are both nodes we were given; a
// half-edge would point at nothing.
if _, ok := order[e.From]; !ok {
continue
}
if _, ok := order[e.To]; !ok {
continue
}
adj[e.From] = append(adj[e.From], e.To)
}
// Depth = shortest hop count from the entry. The first visited node is the
// entry (gogobee sends Visited in path order); fall back to appearance
// order 0 if Visited is somehow empty.
start := ""
if len(m.Visited) > 0 {
start = m.Visited[0]
} else {
start = m.Nodes[0].ID
}
depth := bfsDepth(start, adj, order)
// 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 {
maxDepth = d
}
}
maxRows := 0
for d := 0; d <= maxDepth; d++ {
sortByOrder(byCol[d], order)
if len(byCol[d]) > maxRows {
maxRows = len(byCol[d])
}
}
kindByID := make(map[string]string, len(m.Nodes))
for _, n := range m.Nodes {
kindByID[n.ID] = n.Kind
}
// Place nodes. Each column is vertically centred against the tallest column
// so the map reads as balanced rather than top-aligned.
pos := make(map[string][2]int, len(m.Nodes))
view := &mapView{
W: mapMargin*2 + maxDepth*mapColGap,
H: mapMargin*2 + max(0, maxRows-1)*mapRowGap,
}
if view.W < mapMargin*2 {
view.W = mapMargin * 2
}
for d := 0; d <= maxDepth; d++ {
col := byCol[d]
x := mapMargin + d*mapColGap
offset := (maxRows - len(col)) * mapRowGap / 2
for i, id := range col {
y := mapMargin + offset + i*mapRowGap
pos[id] = [2]int{x, y}
}
}
for d := 0; d <= maxDepth; d++ {
for _, id := range byCol[d] {
p := pos[id]
st := styleFor(kindByID[id])
view.Nodes = append(view.Nodes, mapNode{
X: p[0], Y: p[1],
Kind: kindByID[id],
Glyph: st.glyph,
Class: st.class,
Label: st.label,
Current: id == m.CurrentNode,
})
}
}
for _, e := range m.Edges {
a, okA := pos[e.From]
b, okB := pos[e.To]
if !okA || !okB {
continue
}
view.Edges = append(view.Edges, mapEdge{
X1: a[0], Y1: a[1], X2: b[0], Y2: b[1],
Locked: e.Lock != "" && e.Lock != "none",
Lock: e.Lock,
})
}
return view
}
// bfsDepth returns the hop distance from start to every node reachable along
// adj. Nodes never reached (there should be none in a well-formed cut) default
// to 0, so a stray node still lands in the first column rather than off-canvas.
func bfsDepth(start string, adj map[string][]string, order map[string]int) map[string]int {
depth := map[string]int{start: 0}
queue := []string{start}
for len(queue) > 0 {
u := queue[0]
queue = queue[1:]
for _, v := range adj[u] {
if _, seen := depth[v]; !seen {
depth[v] = depth[u] + 1
queue = append(queue, v)
}
}
}
// Any node with an appearance order but no depth (unreachable via the cut)
// gets 0 so it is still placed.
for id := range order {
if _, ok := depth[id]; !ok {
depth[id] = 0
}
}
return depth
}
// sortByOrder sorts ids in place by their first-appearance index. Insertion
// sort — columns hold a handful of nodes, and it keeps the ordering stable
// without pulling in a comparator closure.
func sortByOrder(ids []string, order map[string]int) {
for i := 1; i < len(ids); i++ {
for j := i; j > 0 && order[ids[j]] < order[ids[j-1]]; j-- {
ids[j], ids[j-1] = ids[j-1], ids[j]
}
}
}