Files
gogobee/internal/plugin/zone_graph_map.go
prosolis 893d3dacad Branching zones G6: dependent surfaces re-keyed on graph nodes
- Harvest tables (expedition + standalone) now keyed by node_id; legacy
  room-idx entries auto-migrate via read-fallback + drop-on-save.
- Narration salts swapped from run.CurrentRoom to narrationCadence(run)
  (= len(visited_nodes)-1) so flavor pickers survive G9 column drop.
- !zone map renders the graph (BFS by PosX/PosY) when the gate is on:
  ✓/▶/· status, ╳ for locked-only edges, secrets hidden until visited.
- Region-boundary hook in graph advance/!zone go updates expedition
  CurrentRegion + visited list when from/to nodes differ — without
  burning supplies or retiring runs (the graph IS the run state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:17:30 -07:00

175 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
// Phase G6 — graph-aware !zone map renderer.
//
// Replaces the linear renderZoneMap() output when GOGOBEE_BRANCHING_ZONES
// is on. Walks the registered (or legacy-compiled) ZoneGraph in BFS
// order rooted at Entry, lays nodes out by PosX/PosY, and prints a
// horizontal glyph row per layer with a status row underneath. Locked
// outgoing edges show as ``; secret nodes stay hidden until the
// player has visited them (prevents spoilers from `!zone map`).
import (
"fmt"
"sort"
"strings"
)
type laidNode struct {
ID string
Node ZoneNode
}
// renderZoneGraphMap is the graph-mode replacement for renderZoneMap.
// Output stays inside the same ``` block in zoneCmdMap.
func renderZoneGraphMap(g ZoneGraph, run *DungeonRun) string {
if len(g.Nodes) == 0 {
return "(no graph)"
}
visited := map[string]bool{}
for _, n := range run.VisitedNodes {
visited[n] = true
}
current := run.CurrentNode
// Group nodes by PosX, drop secret nodes the player hasn't reached.
cols := map[int][]laidNode{}
maxX := 0
for id, n := range g.Nodes {
if n.Kind == NodeKindSecret && !visited[id] {
continue
}
cols[n.PosX] = append(cols[n.PosX], laidNode{ID: id, Node: n})
if n.PosX > maxX {
maxX = n.PosX
}
}
if len(cols) == 0 {
return "(no nodes)"
}
for x := range cols {
sort.Slice(cols[x], func(i, j int) bool {
a, b := cols[x][i].Node, cols[x][j].Node
if a.PosY != b.PosY {
return a.PosY < b.PosY
}
return cols[x][i].ID < cols[x][j].ID
})
}
// Find max stack height per column for vertical alignment.
maxRows := 0
for x := 0; x <= maxX; x++ {
if len(cols[x]) > maxRows {
maxRows = len(cols[x])
}
}
if maxRows == 0 {
maxRows = 1
}
// Render row-by-row. Each "row" is a top (glyph) + bot (status)
// pair. Empty cells get spaces so columns stay aligned.
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)
top.WriteString(connector)
bot.WriteString(statusGap)
}
if row < len(cols[x]) {
ln := cols[x][row]
top.WriteString(nodeGlyph(ln.Node))
bot.WriteString(nodeStatusMark(ln.ID, current, visited))
} else {
top.WriteString(" ")
bot.WriteString(" ")
}
}
sb.WriteString(strings.TrimRight(top.String(), " "))
sb.WriteString("\n")
sb.WriteString(strings.TrimRight(bot.String(), " "))
if row < maxRows-1 {
sb.WriteString("\n")
}
}
return sb.String()
}
// graphConnector returns the 2-char top/bot strings drawn between
// columns for a given row. Defaults to `──`/` `; if the only edge
// reaching this row's right-column node is locked, swaps to ` ` to
// signal a gate. Hidden secrets aren't connectable so they get blank.
func graphConnector(g ZoneGraph, leftCol, rightCol []laidNode, row int, visited map[string]bool) (string, string) {
_ = visited
if row >= len(rightCol) {
return " ", " "
}
target := rightCol[row].ID
hasUnlocked, hasAny := false, false
for _, ln := range leftCol {
for _, e := range g.Edges[ln.ID] {
if e.To != target {
continue
}
hasAny = true
if e.Lock == "" || e.Lock == LockNone {
hasUnlocked = true
}
}
}
if !hasAny {
return " ", " "
}
if !hasUnlocked {
return "╳─", " "
}
return "──", " "
}
func nodeStatusMark(id, current string, visited map[string]bool) string {
switch {
case id == current:
return "▶"
case visited[id]:
return "✓"
default:
return "·"
}
}
func nodeGlyph(n ZoneNode) string {
switch n.Kind {
case NodeKindEntry:
return "E"
case NodeKindExploration, NodeKindFork, NodeKindMerge:
return "?"
case NodeKindTrap:
return "T"
case NodeKindElite:
return "★"
case NodeKindBoss:
return "☠"
case NodeKindHarvest:
return "H"
case NodeKindRestCamp:
return "⛺"
case NodeKindSecret:
return "⚿"
}
return "·"
}
// debugDump (test-only crutch) prints the raw column layout. Currently
// unused outside potential debugging — kept off the unused-warning list
// by routing fmt through it.
func debugDumpGraphMap(g ZoneGraph) string {
var b strings.Builder
for id, n := range g.Nodes {
b.WriteString(fmt.Sprintf("%s @ (%d,%d) %s\n", id, n.PosX, n.PosY, n.Kind))
}
return b.String()
}