@@ -96,6 +111,28 @@ {{end}} + + {{if .MapView}} + + + + The map + {{if .Detail.Room}}Room {{.Detail.Room}}{{end}} + + + {{template "dungeonmap" .MapView}} + + + entrance + boss + unexplored + locked door + + + {{end}} {{else}} No detailed sheet on file for this adventurer yet — check back after the next snapshot. diff --git a/internal/web/who.go b/internal/web/who.go index 3ba1b0c..d81f3d1 100644 --- a/internal/web/who.go +++ b/internal/web/who.go @@ -33,6 +33,30 @@ type whoDetail struct { Supplies int `json:"supplies"` ThreatLevel int `json:"threat_level"` Room string `json:"room"` + Map *whoMap `json:"map"` +} + +// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with +// their true kind, plus the one-hop frontier of doors whose rooms are withheld +// (kind "unknown"). Pete lays it out and draws it; it never receives node +// labels or contents, only ids and kinds. See who_map.go. +type whoMap struct { + ZoneID string `json:"zone_id"` + CurrentNode string `json:"current_node"` + Visited []string `json:"visited"` + Nodes []whoMapNode `json:"nodes"` + Edges []whoMapEdge `json:"edges"` +} + +type whoMapNode struct { + ID string `json:"id"` + Kind string `json:"kind"` +} + +type whoMapEdge struct { + From string `json:"from"` + To string `json:"to"` + Lock string `json:"lock"` } type whoGear struct { @@ -58,6 +82,7 @@ type whoPage struct { HasDetail bool Detail whoDetail Abilities []abilityRow + MapView *mapView // laid-out dungeon map, nil when not on a run or no graph HasSelf bool Self storage.PlayerDetail // The private panels, wrapped so a row knows where it is sitting. Bond state @@ -150,6 +175,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) { page.HasDetail = true page.Detail = d page.Abilities = abil + page.MapView = buildMapView(d.Map) } // History: one read feeds both the trophy case and the trail. Keyed on the diff --git a/internal/web/who_map.go b/internal/web/who_map.go new file mode 100644 index 0000000..eab38bc --- /dev/null +++ b/internal/web/who_map.go @@ -0,0 +1,223 @@ +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{} + for _, n := range m.Nodes { + 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] + } + } +} diff --git a/internal/web/who_map_test.go b/internal/web/who_map_test.go new file mode 100644 index 0000000..9e3fd49 --- /dev/null +++ b/internal/web/who_map_test.go @@ -0,0 +1,148 @@ +package web + +import ( + "encoding/json" + "strings" + "testing" + + "pete/internal/storage" + "time" +) + +// fogMap is the shape gogobee sends: two visited rooms (entry, elite), and two +// frontier rooms reachable one hop out — n4 is really the boss but arrives with +// its kind withheld as "unknown". n3 sits behind a perception-locked door, n4 +// behind a key-locked one. +func fogMap() *whoMap { + return &whoMap{ + ZoneID: "ossuary", + CurrentNode: "n2", + Visited: []string{"n1", "n2"}, + Nodes: []whoMapNode{ + {ID: "n1", Kind: "entry"}, + {ID: "n2", Kind: "elite"}, + {ID: "n3", Kind: "unknown"}, + {ID: "n4", Kind: "unknown"}, + }, + Edges: []whoMapEdge{ + {From: "n1", To: "n2", Lock: ""}, + {From: "n1", To: "n3", Lock: "perception_check"}, + {From: "n2", To: "n4", Lock: "key_required"}, + }, + } +} + +func TestBuildMapView_Layout(t *testing.T) { + v := buildMapView(fogMap()) + if v == nil { + t.Fatal("nil view for a non-empty map") + } + if len(v.Nodes) != 4 { + t.Fatalf("want 4 nodes, got %d", len(v.Nodes)) + } + + // Depth places entry in column 0, its two successors in column 1, the boss + // frontier in column 2 — a left-to-right dungeon. + var n1, n2, n4 mapNode + for _, n := range v.Nodes { + switch { + case n.Kind == "entry": + n1 = n + case n.Kind == "elite": + n2 = n + case n.Kind == "unknown" && n.X > n1.X+mapColGap: + n4 = n // the deepest unknown is n4 + } + } + if !(n1.X < n2.X && n2.X < n4.X) { + t.Errorf("columns not left-to-right: entry=%d elite=%d frontier=%d", n1.X, n2.X, n4.X) + } + // The boss frontier must NOT reveal its kind or its glyph. + if n4.Kind != "unknown" || n4.Glyph != "?" { + t.Errorf("frontier boss leaked: kind=%q glyph=%q", n4.Kind, n4.Glyph) + } + if n2.Current != true { + t.Errorf("current node n2 (elite) should be marked current") + } + if n1.Current { + t.Errorf("entry is not the current node") + } + + // Edges: three total, the two locked ones dashed, the visited-visited one open. + if len(v.Edges) != 3 { + t.Fatalf("want 3 edges, got %d", len(v.Edges)) + } + locked := 0 + for _, e := range v.Edges { + if e.Locked { + locked++ + } + } + if locked != 2 { + t.Errorf("want 2 locked doors (perception, key), got %d", locked) + } +} + +func TestBuildMapView_EmptyIsNil(t *testing.T) { + if buildMapView(nil) != nil { + t.Error("nil map should give nil view") + } + if buildMapView(&whoMap{ZoneID: "x"}) != nil { + t.Error("map with no nodes should give nil view") + } +} + +// publicDetailWithMap is publicDetail plus a fog-of-war map, the way the roster +// push carries both. +func publicDetailWithMap(t *testing.T) json.RawMessage { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "hp_current": 30, + "hp_max": 42, + "armor_class": 17, + "abilities": [6]int{16, 14, 15, 10, 12, 8}, + "modifiers": [6]int{3, 2, 2, 0, 1, -1}, + "supplies": 8, + "room": "2 / 4", + "map": fogMap(), + }) + if err != nil { + t.Fatal(err) + } + return raw +} + +// TestWhoMapRenders drives the real template with a map present and asserts the +// SVG lands, the current room is marked, and the fog holds: the frontier boss +// is drawn as an unexplored room, never as a boss. +func TestWhoMapRenders(t *testing.T) { + s, _ := newAdvServer(t, "tok") + s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} + now := time.Now().Unix() + + e := entry("tok-josie", "Josie", "expedition", "holymachina") + e.Detail = publicDetailWithMap(t) + if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 { + t.Fatalf("seed roster = %d", w.Code) + } + + w := getWho(t, s, "tok-josie", "") + if w.Code != 200 { + t.Fatalf("who = %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + + for _, want := range []string{"The map", "map-svg", "map-node-current", "map-edge-locked"} { + if !strings.Contains(body, want) { + t.Errorf("map render missing %q", want) + } + } + // The elite room glyph shows (visited); the boss glyph must not (the boss is + // only reachable as an unrevealed frontier door). + if !strings.Contains(body, "◆") { + t.Error("visited elite room should show its glyph") + } + if strings.Contains(body, "♛") { + t.Error("fog leak: the boss glyph rendered for an unexplored frontier room") + } +}