diff --git a/internal/plugin/zone_graph_map.go b/internal/plugin/zone_graph_map.go index b9b5762..7630aef 100644 --- a/internal/plugin/zone_graph_map.go +++ b/internal/plugin/zone_graph_map.go @@ -57,9 +57,19 @@ func renderZoneGraphMap(g ZoneGraph, run *DungeonRun) string { }) } + // Drop columns that have no visible nodes (e.g. when the only + // entry was a hidden secret). Otherwise the render leaves a blank + // slot mid-row that reads as a missing status mark. + visibleX := make([]int, 0, maxX+1) + for x := 0; x <= maxX; x++ { + if len(cols[x]) > 0 { + visibleX = append(visibleX, x) + } + } + // Find max stack height per column for vertical alignment. maxRows := 0 - for x := 0; x <= maxX; x++ { + for _, x := range visibleX { if len(cols[x]) > maxRows { maxRows = len(cols[x]) } @@ -73,9 +83,9 @@ func renderZoneGraphMap(g ZoneGraph, run *DungeonRun) string { var sb strings.Builder for row := 0; row < maxRows; row++ { var top, bot strings.Builder - for x := 0; x <= maxX; x++ { - if x > 0 { - connector, statusGap := graphConnector(g, cols[x-1], cols[x], row, visited) + for i, x := range visibleX { + if i > 0 { + connector, statusGap := graphConnector(g, cols[visibleX[i-1]], cols[x], row, visited) top.WriteString(connector) bot.WriteString(statusGap) } diff --git a/internal/plugin/zone_graph_map_test.go b/internal/plugin/zone_graph_map_test.go index 311da21..882b736 100644 --- a/internal/plugin/zone_graph_map_test.go +++ b/internal/plugin/zone_graph_map_test.go @@ -58,6 +58,43 @@ func TestRenderZoneGraphMap_HidesUnvisitedSecrets(t *testing.T) { } } +// TestRenderZoneGraphMap_CollapsesEmptyColumns confirms columns whose +// only inhabitant is a hidden secret don't leave a phantom blank slot +// in the glyph/status rows. Prior to G7 the renderer iterated 0..maxX +// and emitted a space for empty columns, which read as a "missing dot". +func TestRenderZoneGraphMap_CollapsesEmptyColumns(t *testing.T) { + nodes := []ZoneNode{ + {NodeID: "z.entry", Kind: NodeKindEntry, IsEntry: true, PosX: 0}, + {NodeID: "z.mid", Kind: NodeKindExploration, PosX: 1}, + // Secret sits alone in column 2 — hidden until visited. + {NodeID: "z.secret", Kind: NodeKindSecret, PosX: 2, PosY: 1}, + {NodeID: "z.boss", Kind: NodeKindBoss, IsBoss: true, PosX: 3}, + } + edges := []ZoneEdge{ + {From: "z.entry", To: "z.mid"}, + {From: "z.mid", To: "z.secret", Lock: LockPerception, LockData: map[string]any{"dc": 12}}, + {From: "z.mid", To: "z.boss"}, + {From: "z.secret", To: "z.boss"}, + } + g := BuildGraph("test_zone_collapse", nodes, edges) + got := renderZoneGraphMap(g, &DungeonRun{ + CurrentNode: "z.entry", + VisitedNodes: []string{"z.entry"}, + }) + // With col 2 collapsed, the top row should be a contiguous chain + // E──?──☠ with no double-gap. + if !strings.Contains(got, "E──?──☠") { + t.Errorf("expected collapsed top row 'E──?──☠', got:\n%s", got) + } + // And no run of 3+ spaces between glyphs (would indicate a phantom column). + for _, line := range strings.Split(got, "\n") { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, " ") { + t.Errorf("phantom empty column detected in line %q (full output:\n%s)", line, got) + } + } +} + // TestRenderZoneGraphMap_LockedEdgeRenderedAsCross verifies locked-only // approaches show ╳ instead of ──. func TestRenderZoneGraphMap_LockedEdgeRenderedAsCross(t *testing.T) {