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>
This commit is contained in:
prosolis
2026-05-09 15:17:30 -07:00
parent 2d249d7d0a
commit 893d3dacad
14 changed files with 562 additions and 64 deletions

View File

@@ -0,0 +1,174 @@
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()
}