- 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.
233 lines
7.0 KiB
Go
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]
|
|
}
|
|
}
|
|
}
|