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

@@ -378,5 +378,6 @@ When the new session starts:
3. Read `gogobee_dungeon_zones.md` (existing zone design) + `internal/plugin/dnd_zone.go` for current shape. 3. Read `gogobee_dungeon_zones.md` (existing zone design) + `internal/plugin/dnd_zone.go` for current shape.
4. Run G1 (schema) + G2 (types) + G3 (legacy compiler) + G4 (run state). All four are infra; ship together with no behavior change. 4. Run G1 (schema) + G2 (types) + G3 (legacy compiler) + G4 (run state). All four are infra; ship together with no behavior change.
5. Land G5 navigation behind `GOGOBEE_BRANCHING_ZONES=1` env gate. 5. Land G5 navigation behind `GOGOBEE_BRANCHING_ZONES=1` env gate.
6. POC G7 with Crypt of Valdris. 6. **G6 (done)** — dependent surfaces: harvest re-keyed on `node_id` (with legacy `room_idx` read-fallback + drop-on-save migration), narration cadence salted on `len(visited_nodes)-1` via `narrationCadence(run)`, `!zone map` graph render gated by `GOGOBEE_BRANCHING_ZONES=1` (locked edges → ``, secrets hidden until visited), and a region-boundary hook in graph advance/`!zone go` that mirrors `!region travel` flavor + log without burning supplies (graph is run-state authoritative).
7. Pause for playtest before G8 mass migration. 7. POC G7 with Crypt of Valdris.
8. Pause for playtest before G8 mass migration.

View File

@@ -284,7 +284,8 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
// Seed harvest nodes for the entry room (they're auto-seeded by // Seed harvest nodes for the entry room (they're auto-seeded by
// loadHarvestNodes on first read, but confirm we get at least one). // loadHarvestNodes on first read, but confirm we get at least one).
roomIdx := currentRoomIndexFor(exp) roomIdx := currentRoomIndexFor(exp)
nodes := loadHarvestNodes(exp, roomIdx) nodeID := currentNodeIDFor(exp)
nodes := loadHarvestNodes(exp, nodeID)
t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes)) t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes))
if len(nodes) == 0 { if len(nodes) == 0 {
t.Error("expected at least one harvest node seeded") t.Error("expected at least one harvest node seeded")

View File

@@ -117,13 +117,40 @@ func harvestActionAbility(c *DnDCharacter, action HarvestAction) int {
// Single-region zones store under "". // Single-region zones store under "".
func regionHarvestKey(e *Expedition) string { return e.CurrentRegion } func regionHarvestKey(e *Expedition) string { return e.CurrentRegion }
// roomHarvestKey returns the JSON-safe key for a 0-based room index. // nodeHarvestKey returns the JSON-safe storage key for a graph node id.
func roomHarvestKey(roomIdx int) string { return strconv.Itoa(roomIdx) } // Phase G6: harvest tables are keyed by node id (e.g. "crypt_valdris.r3"),
// not by raw room index. Linear-zone runs derive a node id with the
// `<zone>.r<N+1>` scheme (deriveLegacyNodeID), so this collapses to a
// stable key for the existing room-walk model. Branching zones use the
// hand-authored node ids and get distinct keys per branch.
func nodeHarvestKey(nodeID string) string { return nodeID }
// loadHarvestNodes returns the (region, room)'s nodes, lazy-seeding from // legacyRoomIdxFromNodeID parses a derived node id back to its 0-based
// room index. Returns ok=false for hand-authored ids that don't match
// the `<zone>.r<N+1>` scheme — there's no legacy storage to migrate
// for those, so the caller skips the fallback.
func legacyRoomIdxFromNodeID(nodeID string, zoneID ZoneID) (int, bool) {
prefix := string(zoneID) + ".r"
if !strings.HasPrefix(nodeID, prefix) {
return 0, false
}
n, err := strconv.Atoi(strings.TrimPrefix(nodeID, prefix))
if err != nil || n < 1 {
return 0, false
}
return n - 1, true
}
// loadHarvestNodes returns the (region, node)'s nodes, lazy-seeding from
// the §3 registry on first access. Caller must persist via // the §3 registry on first access. Caller must persist via
// saveHarvestNodes after mutation. // saveHarvestNodes after mutation.
func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode { //
// Migration: rows written before G6 used the room-index integer as the
// storage key. When a node-id-keyed entry is missing and the id maps
// back to a legacy room index, we read from the old key so existing
// expeditions don't lose harvest state mid-flight. The next save writes
// under the new key.
func loadHarvestNodes(e *Expedition, nodeID string) []HarvestNode {
if e == nil { if e == nil {
return nil return nil
} }
@@ -132,7 +159,7 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode {
} }
table := loadHarvestTable(e) table := loadHarvestTable(e)
regionKey := regionHarvestKey(e) regionKey := regionHarvestKey(e)
roomKey := roomHarvestKey(roomIdx) roomKey := nodeHarvestKey(nodeID)
regionMap, ok := table[regionKey] regionMap, ok := table[regionKey]
if !ok { if !ok {
@@ -142,6 +169,11 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode {
if existing, ok := regionMap[roomKey]; ok { if existing, ok := regionMap[roomKey]; ok {
return existing return existing
} }
if idx, ok := legacyRoomIdxFromNodeID(nodeID, e.ZoneID); ok {
if existing, ok := regionMap[strconv.Itoa(idx)]; ok {
return existing
}
}
seeded := seedRoomNodes(e.ZoneID) seeded := seedRoomNodes(e.ZoneID)
regionMap[roomKey] = seeded regionMap[roomKey] = seeded
table[regionKey] = regionMap table[regionKey] = regionMap
@@ -149,20 +181,25 @@ func loadHarvestNodes(e *Expedition, roomIdx int) []HarvestNode {
return seeded return seeded
} }
// saveHarvestNodes writes the supplied node slice into the (region, room) // saveHarvestNodes writes the supplied node slice into the (region, node)
// slot and persists the expedition state. // slot and persists the expedition state.
func saveHarvestNodes(e *Expedition, roomIdx int, nodes []HarvestNode) error { func saveHarvestNodes(e *Expedition, nodeID string, nodes []HarvestNode) error {
if e.RegionState == nil { if e.RegionState == nil {
e.RegionState = map[string]any{} e.RegionState = map[string]any{}
} }
table := loadHarvestTable(e) table := loadHarvestTable(e)
regionKey := regionHarvestKey(e) regionKey := regionHarvestKey(e)
roomKey := roomHarvestKey(roomIdx) roomKey := nodeHarvestKey(nodeID)
regionMap, ok := table[regionKey] regionMap, ok := table[regionKey]
if !ok { if !ok {
regionMap = map[string][]HarvestNode{} regionMap = map[string][]HarvestNode{}
} }
regionMap[roomKey] = nodes regionMap[roomKey] = nodes
if idx, ok := legacyRoomIdxFromNodeID(nodeID, e.ZoneID); ok {
// Drop the legacy room-idx entry once the node-id key is live so
// the table doesn't carry parallel state forever.
delete(regionMap, strconv.Itoa(idx))
}
table[regionKey] = regionMap table[regionKey] = regionMap
e.RegionState[regionStateHarvestKey] = table e.RegionState[regionStateHarvestKey] = table
return persistRegionState(e) return persistRegionState(e)
@@ -378,8 +415,8 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
return p.SendDM(ctx.Sender, blockReason) return p.SendDM(ctx.Sender, blockReason)
} }
roomIdx := run.CurrentRoom nodeID := harvestNodeIDFor(run)
nodes := loadHarvestNodes(exp, roomIdx) nodes := loadHarvestNodes(exp, nodeID)
idx := pickAvailableNode(nodes, exp, char, action) idx := pickAvailableNode(nodes, exp, char, action)
if idx < 0 { if idx < 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.SendDM(ctx.Sender, fmt.Sprintf(
@@ -413,7 +450,7 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
pre.WriteString("\n\n_The harvest is forfeit._") pre.WriteString("\n\n_The harvest is forfeit._")
} }
// Persist node state untouched (no charge spent). // Persist node state untouched (no charge spent).
_ = saveHarvestNodes(exp, roomIdx, nodes) _ = saveHarvestNodes(exp, nodeID, nodes)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt", _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
fmt.Sprintf("%s %s kind=%d total=%d", string(action), res.ID, int(interrupt), intTotal), "") fmt.Sprintf("%s %s kind=%d total=%d", string(action), res.ID, int(interrupt), intTotal), "")
return p.SendDM(ctx.Sender, pre.String()) return p.SendDM(ctx.Sender, pre.String())
@@ -499,7 +536,7 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
} }
} }
if err := saveHarvestNodes(exp, roomIdx, nodes); err != nil { if err := saveHarvestNodes(exp, nodeID, nodes); err != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest", _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
fmt.Sprintf("persistence error: %v", err), "") fmt.Sprintf("persistence error: %v", err), "")
} }
@@ -640,9 +677,9 @@ func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
if err != nil || run == nil { if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Couldn't load region state.") return p.SendDM(ctx.Sender, "Couldn't load region state.")
} }
roomIdx := run.CurrentRoom nodeID := harvestNodeIDFor(run)
nodes := loadHarvestNodes(exp, roomIdx) nodes := loadHarvestNodes(exp, nodeID)
_ = saveHarvestNodes(exp, roomIdx, nodes) // persist seed if first touch _ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch
zone, _ := getZone(exp.ZoneID) zone, _ := getZone(exp.ZoneID)
regionLabel := exp.CurrentRegion regionLabel := exp.CurrentRegion
@@ -894,14 +931,15 @@ func rollManorCursedTrinket(zoneID ZoneID, action HarvestAction, rng func(int) i
return roll < manorCursedTrinketChance return roll < manorCursedTrinketChance
} }
// restoreHarvestNodesInRoom resets every node in (region, room) to its // restoreHarvestNodesInRoom resets every node in (region, node) to its
// MaxCharges. Used by the Feywild Time Loop event (§7.4) — "previous // MaxCharges. Used by the Feywild Time Loop event (§7.4) — "previous
// harvest results in that room are nullified." // harvest results in that room are nullified." Empty nodeID is a no-op
func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool { // (caller had no active run/node to target).
if roomIdx < 0 { func restoreHarvestNodesInRoom(exp *Expedition, nodeID string) bool {
if nodeID == "" {
return false return false
} }
nodes := loadHarvestNodes(exp, roomIdx) nodes := loadHarvestNodes(exp, nodeID)
if len(nodes) == 0 { if len(nodes) == 0 {
return false return false
} }
@@ -919,7 +957,7 @@ func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool {
// briefing/cycle flow persists RegionState downstream. Avoiding the // briefing/cycle flow persists RegionState downstream. Avoiding the
// persist call here keeps the helper test-friendly without a DB. // persist call here keeps the helper test-friendly without a DB.
regionKey := regionHarvestKey(exp) regionKey := regionHarvestKey(exp)
roomKey := roomHarvestKey(roomIdx) roomKey := nodeHarvestKey(nodeID)
table := loadHarvestTable(exp) table := loadHarvestTable(exp)
regionMap := table[regionKey] regionMap := table[regionKey]
if regionMap == nil { if regionMap == nil {
@@ -943,3 +981,32 @@ func currentRoomIndexFor(e *Expedition) int {
} }
return run.CurrentRoom return run.CurrentRoom
} }
// currentNodeIDFor returns the active region run's current graph node
// id, or "" if no run is linked / loadable. Used by harvest call sites
// that need to address the node-id-keyed harvest table from event
// handlers (no DungeonRun on hand).
func currentNodeIDFor(e *Expedition) string {
if e == nil || e.RunID == "" {
return ""
}
run, err := getZoneRun(e.RunID)
if err != nil || run == nil {
return ""
}
return harvestNodeIDFor(run)
}
// harvestNodeIDFor resolves the storage-key node id for a run. Prefers
// the dual-written CurrentNode; falls back to the legacy linear
// derivation when a row predates G4. Never returns "" for an active
// run with a populated RoomSeq.
func harvestNodeIDFor(run *DungeonRun) string {
if run == nil {
return ""
}
if run.CurrentNode != "" {
return run.CurrentNode
}
return deriveLegacyNodeID(run.ZoneID, run.CurrentRoom)
}

View File

@@ -9,6 +9,94 @@ import (
// ── pure-function tests (no DB) ──────────────────────────────────────────── // ── pure-function tests (no DB) ────────────────────────────────────────────
// TestLegacyRoomIdxFromNodeID exercises the G6 migration parser:
// `<zone>.r<N+1>` round-trips, hand-authored ids return ok=false.
func TestLegacyRoomIdxFromNodeID(t *testing.T) {
cases := []struct {
nodeID string
zone ZoneID
want int
ok bool
}{
{"goblin_warrens.r1", ZoneGoblinWarrens, 0, true},
{"crypt_valdris.r5", ZoneCryptValdris, 4, true},
{"crypt_valdris.fork_a", ZoneCryptValdris, 0, false},
{"goblin_warrens.r1", ZoneCryptValdris, 0, false}, // wrong zone prefix
{"goblin_warrens.r0", ZoneGoblinWarrens, 0, false},
{"", ZoneGoblinWarrens, 0, false},
}
for _, c := range cases {
got, ok := legacyRoomIdxFromNodeID(c.nodeID, c.zone)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("legacyRoomIdxFromNodeID(%q, %s) = (%d, %t), want (%d, %t)",
c.nodeID, c.zone, got, ok, c.want, c.ok)
}
}
}
// TestLoadHarvestNodes_LegacyRoomKeyFallback confirms a row with the
// pre-G6 `strconv.Itoa(roomIdx)` storage key is read via the fallback
// when the new node-id key is queried.
func TestLoadHarvestNodes_LegacyRoomKeyFallback(t *testing.T) {
exp := &Expedition{
ZoneID: ZoneGoblinWarrens,
CurrentRegion: "",
RegionState: map[string]any{
regionStateHarvestKey: map[string]map[string][]HarvestNode{
"": {
"3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}},
},
},
},
}
nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3)
got := loadHarvestNodes(exp, nodeID)
if len(got) != 1 || got[0].ResourceID != "iron_ore" || got[0].CurrentCharges != 0 {
t.Fatalf("legacy fallback didn't return depleted node, got %+v", got)
}
// Authored (non-legacy) node id with no entry should lazy-seed fresh,
// not pick up the legacy "3" entry.
fresh := loadHarvestNodes(exp, "goblin_warrens.fork_a")
for _, n := range fresh {
if n.ResourceID == "iron_ore" && n.CurrentCharges == 0 {
t.Errorf("fork_a leaked depleted state from legacy room 3 entry")
}
}
}
// TestSaveHarvestNodes_DropsLegacyRoomKey verifies the migration-on-save
// drops the legacy `strconv.Itoa(roomIdx)` entry after persistence runs.
func TestSaveHarvestNodes_DropsLegacyRoomKey(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@harvest-migrate:example.org")
defer cleanupHarvestState(uid)
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
if err != nil {
t.Fatal(err)
}
// Seed the legacy "3" key directly so the load fallback fires.
exp.RegionState[regionStateHarvestKey] = map[string]map[string][]HarvestNode{
"": {
"3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}},
},
}
nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3)
got := loadHarvestNodes(exp, nodeID)
if err := saveHarvestNodes(exp, nodeID, got); err != nil {
t.Fatalf("saveHarvestNodes: %v", err)
}
table := loadHarvestTable(exp)
if _, exists := table[""]["3"]; exists {
t.Errorf("legacy room-idx key '3' should be dropped after migration save")
}
if _, exists := table[""][nodeID]; !exists {
t.Errorf("expected node-id key %q after migration save", nodeID)
}
}
func TestResolveHarvestOutcome_Brackets(t *testing.T) { func TestResolveHarvestOutcome_Brackets(t *testing.T) {
cases := []struct { cases := []struct {
roll, dc int roll, dc int
@@ -241,14 +329,16 @@ func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) {
} }
// Seed two rooms and drain one node in each. // Seed two rooms and drain one node in each.
r0 := loadHarvestNodes(exp, 0) n0 := deriveLegacyNodeID(ZoneGoblinWarrens, 0)
n1 := deriveLegacyNodeID(ZoneGoblinWarrens, 1)
r0 := loadHarvestNodes(exp, n0)
r0[0].CurrentCharges = 0 r0[0].CurrentCharges = 0
if err := saveHarvestNodes(exp, 0, r0); err != nil { if err := saveHarvestNodes(exp, n0, r0); err != nil {
t.Fatal(err) t.Fatal(err)
} }
r1 := loadHarvestNodes(exp, 1) r1 := loadHarvestNodes(exp, n1)
r1[0].CurrentCharges = 0 r1[0].CurrentCharges = 0
if err := saveHarvestNodes(exp, 1, r1); err != nil { if err := saveHarvestNodes(exp, n1, r1); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -256,7 +346,7 @@ func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, idx := range []int{0, 1} { for _, idx := range []int{0, 1} {
nodes := loadHarvestNodes(exp, idx) nodes := loadHarvestNodes(exp, deriveLegacyNodeID(ZoneGoblinWarrens, idx))
for i, n := range nodes { for i, n := range nodes {
if n.CurrentCharges != n.MaxCharges { if n.CurrentCharges != n.MaxCharges {
t.Errorf("room %d node %d not replenished: %d/%d", t.Errorf("room %d node %d not replenished: %d/%d",
@@ -276,9 +366,11 @@ func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
nodes := loadHarvestNodes(exp, 2) n2 := deriveLegacyNodeID(ZoneGoblinWarrens, 2)
n5 := deriveLegacyNodeID(ZoneGoblinWarrens, 5)
nodes := loadHarvestNodes(exp, n2)
nodes[0].CurrentCharges = 0 nodes[0].CurrentCharges = 0
if err := saveHarvestNodes(exp, 2, nodes); err != nil { if err := saveHarvestNodes(exp, n2, nodes); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Reload from DB. // Reload from DB.
@@ -286,12 +378,12 @@ func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := loadHarvestNodes(reloaded, 2) got := loadHarvestNodes(reloaded, n2)
if got[0].CurrentCharges != 0 { if got[0].CurrentCharges != 0 {
t.Errorf("after reload, room 2 node 0 charges = %d, want 0", got[0].CurrentCharges) t.Errorf("after reload, room 2 node 0 charges = %d, want 0", got[0].CurrentCharges)
} }
// Other rooms should still be untouched (lazy-seeded fresh). // Other rooms should still be untouched (lazy-seeded fresh).
other := loadHarvestNodes(reloaded, 5) other := loadHarvestNodes(reloaded, n5)
for _, n := range other { for _, n := range other {
if n.CurrentCharges != n.MaxCharges { if n.CurrentCharges != n.MaxCharges {
t.Errorf("room 5 leaked depletion: %+v", n) t.Errorf("room 5 leaked depletion: %+v", n)

View File

@@ -376,7 +376,7 @@ func feywildTemporalPostRollover(e *Expedition) []string {
// to the current room's nodes. Combat loot stays gone; only the // to the current room's nodes. Combat loot stays gone; only the
// renewable harvest nodes loop back. // renewable harvest nodes loop back.
out := []string{line} out := []string{line}
if restored := restoreHarvestNodesInRoom(e, currentRoomIndexFor(e)); restored { if restored := restoreHarvestNodesInRoom(e, currentNodeIDFor(e)); restored {
out = append(out, "_Harvest nodes in this room have re-emerged — the timeline isn't sure they were ever taken._") out = append(out, "_Harvest nodes in this room have re-emerged — the timeline isn't sure they were ever taken._")
} }
return out return out

View File

@@ -203,11 +203,11 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms)) b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
b.WriteString("_" + zone.Hook + "_\n\n") b.WriteString("_" + zone.Hook + "_\n\n")
if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, run.CurrentRoom); line != "" { if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, narrationCadence(run)); line != "" {
b.WriteString(line) b.WriteString(line)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
if aside := moodAsideLine(run.DMMood, run.RunID, run.CurrentRoom); aside != "" { if aside := moodAsideLine(run.DMMood, run.RunID, narrationCadence(run)); aside != "" {
b.WriteString(aside) b.WriteString(aside)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
@@ -286,12 +286,20 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.") return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
} }
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s — Map**\n```\n", zone.Display))
if branchingZonesEnabled() {
if g, ok := loadZoneGraph(run.ZoneID); ok {
b.WriteString(renderZoneGraphMap(g, run))
b.WriteString("\n```\n")
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_")
return p.SendDM(ctx.Sender, b.String())
}
}
cleared := map[int]bool{} cleared := map[int]bool{}
for _, r := range run.RoomsCleared { for _, r := range run.RoomsCleared {
cleared[r] = true cleared[r] = true
} }
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s — Map**\n```\n", zone.Display))
b.WriteString(renderZoneMap(run.RoomSeq, run.CurrentRoom, cleared)) b.WriteString(renderZoneMap(run.RoomSeq, run.CurrentRoom, cleared))
b.WriteString("\n```\n") b.WriteString("\n```\n")
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss · ✓ cleared ▶ here · pending_") b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss · ✓ cleared ▶ here · pending_")
@@ -614,12 +622,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
// before the 58s phase pacing kicks in. // before the 58s phase pacing kicks in.
var ib strings.Builder var ib strings.Builder
if elite { if elite {
if line := eliteRoomEntryLine(zone.ID, run.RunID, run.CurrentRoom); line != "" { if line := eliteRoomEntryLine(zone.ID, run.RunID, narrationCadence(run)); line != "" {
ib.WriteString(line) ib.WriteString(line)
ib.WriteString("\n") ib.WriteString("\n")
} }
} }
if line := twinBeeLine(zone.ID, DMCombatStart, run.RunID, run.CurrentRoom); line != "" { if line := twinBeeLine(zone.ID, DMCombatStart, run.RunID, narrationCadence(run)); line != "" {
ib.WriteString(line) ib.WriteString(line)
ib.WriteString("\n") ib.WriteString("\n")
} }
@@ -642,12 +650,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
// lands *after* the play-by-play, where it has dramatic context. // lands *after* the play-by-play, where it has dramatic context.
var ob strings.Builder var ob strings.Builder
if nat20s > 0 { if nat20s > 0 {
if line := twinBeeLine(zone.ID, DMNat20, run.RunID, run.CurrentRoom); line != "" { if line := twinBeeLine(zone.ID, DMNat20, run.RunID, narrationCadence(run)); line != "" {
ob.WriteString(line) ob.WriteString(line)
ob.WriteString("\n") ob.WriteString("\n")
} }
} else if nat1s > 0 { } else if nat1s > 0 {
if line := twinBeeLine(zone.ID, DMNat1, run.RunID, run.CurrentRoom); line != "" { if line := twinBeeLine(zone.ID, DMNat1, run.RunID, narrationCadence(run)); line != "" {
ob.WriteString(line) ob.WriteString(line)
ob.WriteString("\n") ob.WriteString("\n")
} }
@@ -656,7 +664,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID) _ = abandonZoneRun(userID)
markAdventureDead(userID, "zone", zone.Display) markAdventureDead(userID, "zone", zone.Display)
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, run.CurrentRoom); line != "" { if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
ob.WriteString(line) ob.WriteString(line)
ob.WriteString("\n") ob.WriteString("\n")
} }
@@ -669,7 +677,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
ended = true ended = true
return return
} }
if line := twinBeeLine(zone.ID, DMCombatEnd, run.RunID, run.CurrentRoom); line != "" { if line := twinBeeLine(zone.ID, DMCombatEnd, run.RunID, narrationCadence(run)); line != "" {
ob.WriteString(line) ob.WriteString(line)
ob.WriteString("\n") ob.WriteString("\n")
} }
@@ -853,7 +861,7 @@ func (p *AdventurePlugin) zoneMoodInteraction(
return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error())
} }
var b strings.Builder var b strings.Builder
if line := render(run.RunID, run.CurrentRoom); line != "" { if line := render(run.RunID, narrationCadence(run)); line != "" {
b.WriteString(line) b.WriteString(line)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
@@ -885,7 +893,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.") return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
} }
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
line := twinBeeLine(zone.ID, DMLore, run.RunID, run.CurrentRoom) line := twinBeeLine(zone.ID, DMLore, run.RunID, narrationCadence(run))
if line == "" { if line == "" {
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.") return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
} }

View File

@@ -6,6 +6,8 @@ package plugin
import ( import (
"fmt" "fmt"
"strings" "strings"
"maunium.net/go/mautrix/id"
) )
// advanceTransitionGraph is the graph-mode replacement for // advanceTransitionGraph is the graph-mode replacement for
@@ -64,6 +66,13 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin
err = aerr err = aerr
return return
} }
// G6d — region boundary hook. Multi-region branching graphs
// can cross a region edge mid-run; emit the existing transit
// flavor + log so the player gets the same "you've crossed
// into X" beat the !region travel command produces. Doesn't
// retire the run (the graph is the authoritative state) — it
// just narrates the crossing.
fireGraphRegionTransition(run.UserID, g.Nodes[currentNode], g.Nodes[chosen.To])
next = nodeKindToRoomType(g.Nodes[chosen.To].Kind) next = nodeKindToRoomType(g.Nodes[chosen.To].Kind)
return return
} }
@@ -80,6 +89,36 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin
return return
} }
// fireGraphRegionTransition compares the from/to graph node region ids
// and, when they differ, mirrors the existing !region travel surface:
// updates Expedition.CurrentRegion + visited list and writes a transit
// log entry. Unlike full !region travel, it does NOT burn supplies,
// advance the day, or retire/spawn DungeonRuns — the graph is the
// authoritative run state, and the player crossed via the graph edge,
// not a separate transit day. Standalone (no expedition) is a no-op.
func fireGraphRegionTransition(userID string, from, to ZoneNode) {
if from.RegionID == to.RegionID {
return
}
if to.RegionID == "" {
return
}
exp, err := getActiveExpedition(id.UserID(userID))
if err != nil || exp == nil {
return
}
if exp.CurrentRegion == to.RegionID {
return
}
if err := setCurrentRegion(exp, to.RegionID); err != nil {
return
}
_, _ = MarkRegionVisited(exp, to.RegionID)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("graph region transit: %s → %s (node %s → %s)",
from.RegionID, to.RegionID, from.NodeID, to.NodeID), "")
}
func unlockedChoices(cs []pendingChoice) []pendingChoice { func unlockedChoices(cs []pendingChoice) []pendingChoice {
out := make([]pendingChoice, 0, len(cs)) out := make([]pendingChoice, 0, len(cs))
for _, c := range cs { for _, c := range cs {
@@ -147,7 +186,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
} }
g, _ := loadZoneGraph(run.ZoneID) g, _ := loadZoneGraph(run.ZoneID)
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
fromNode := g.Nodes[run.CurrentNode]
nextNode := g.Nodes[chosen.To] nextNode := g.Nodes[chosen.To]
fireGraphRegionTransition(run.UserID, fromNode, nextNode)
nextRoom := nodeKindToRoomType(nextNode.Kind) nextRoom := nodeKindToRoomType(nextNode.Kind)
nextIdx := run.CurrentRoom + 1 nextIdx := run.CurrentRoom + 1
var b strings.Builder var b strings.Builder

View File

@@ -299,7 +299,7 @@ func (p *AdventurePlugin) applyTrapEffectWithDetect(
// failed detection no longer contradicts the next line. The mismatch // failed detection no longer contradicts the next line. The mismatch
// where "Perception roll pays off" preceded a tripped trap was // where "Perception roll pays off" preceded a tripped trap was
// caused by this line firing unconditionally. // caused by this line firing unconditionally.
if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, run.CurrentRoom); line != "" { if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, narrationCadence(run)); line != "" {
b.WriteString(line) b.WriteString(line)
b.WriteString("\n") b.WriteString("\n")
} }

View File

@@ -145,13 +145,14 @@ func TestRollManorCursedTrinket(t *testing.T) {
} }
func TestRestoreHarvestNodesInRoom(t *testing.T) { func TestRestoreHarvestNodesInRoom(t *testing.T) {
nodeID := deriveLegacyNodeID(ZoneFeywildCrossing, 0)
exp := &Expedition{ exp := &Expedition{
ZoneID: ZoneFeywildCrossing, ZoneID: ZoneFeywildCrossing,
CurrentRegion: "", CurrentRegion: "",
RegionState: map[string]any{ RegionState: map[string]any{
regionStateHarvestKey: map[string]map[string][]HarvestNode{ regionStateHarvestKey: map[string]map[string][]HarvestNode{
"": { "": {
"0": { nodeID: {
{ResourceID: "fey_dust", CurrentCharges: 0, MaxCharges: 2}, {ResourceID: "fey_dust", CurrentCharges: 0, MaxCharges: 2},
{ResourceID: "enchanted_petal", CurrentCharges: 1, MaxCharges: 2}, {ResourceID: "enchanted_petal", CurrentCharges: 1, MaxCharges: 2},
}, },
@@ -159,11 +160,11 @@ func TestRestoreHarvestNodesInRoom(t *testing.T) {
}, },
}, },
} }
if !restoreHarvestNodesInRoom(exp, 0) { if !restoreHarvestNodesInRoom(exp, nodeID) {
t.Fatalf("restore should report changes") t.Fatalf("restore should report changes")
} }
table := loadHarvestTable(exp) table := loadHarvestTable(exp)
got := table[""]["0"] got := table[""][nodeID]
for _, n := range got { for _, n := range got {
if n.CurrentCharges != n.MaxCharges { if n.CurrentCharges != n.MaxCharges {
t.Errorf("node %s: charges=%d max=%d (expected restored)", n.ResourceID, n.CurrentCharges, n.MaxCharges) t.Errorf("node %s: charges=%d max=%d (expected restored)", n.ResourceID, n.CurrentCharges, n.MaxCharges)
@@ -173,8 +174,8 @@ func TestRestoreHarvestNodesInRoom(t *testing.T) {
func TestRestoreHarvestNodesInRoom_NoOpEmpty(t *testing.T) { func TestRestoreHarvestNodesInRoom_NoOpEmpty(t *testing.T) {
exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{}} exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{}}
if got := restoreHarvestNodesInRoom(exp, -1); got { if got := restoreHarvestNodesInRoom(exp, ""); got {
t.Errorf("negative roomIdx should no-op") t.Errorf("empty nodeID should no-op")
} }
} }

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2" "math/rand/v2"
"strconv"
"strings" "strings"
"gogobee/internal/db" "gogobee/internal/db"
@@ -25,28 +26,42 @@ import (
// belong to the expedition system. Standalone harvest is the casual // belong to the expedition system. Standalone harvest is the casual
// flow: roll d20 vs DC, get yield, decrement node, save. // flow: roll d20 vs DC, get yield, decrement node, save.
// loadStandaloneHarvestNodes reads the run's harvest map and returns the // loadStandaloneHarvestNodes reads the run's harvest map and returns
// nodes for the given room. Lazy-seeds from the zone registry on first // the nodes for the given graph node. Lazy-seeds from the zone registry
// access. Caller persists via saveStandaloneHarvestNodes. // on first access. Caller persists via saveStandaloneHarvestNodes.
func loadStandaloneHarvestNodes(runID string, zoneID ZoneID, roomIdx int) ([]HarvestNode, error) { //
// G6 migration: rows written before the re-key used the room-index
// integer as the storage key. When the node-id key is absent and the
// id maps back to a legacy room index, we read from the old key so
// in-flight runs don't lose harvest state. The next save writes under
// the new key (and drops the old one).
func loadStandaloneHarvestNodes(runID string, zoneID ZoneID, nodeID string) ([]HarvestNode, error) {
table, err := loadStandaloneHarvestTable(runID) table, err := loadStandaloneHarvestTable(runID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
roomKey := roomHarvestKey(roomIdx) roomKey := nodeHarvestKey(nodeID)
if existing, ok := table[roomKey]; ok { if existing, ok := table[roomKey]; ok {
return existing, nil return existing, nil
} }
if idx, ok := legacyRoomIdxFromNodeID(nodeID, zoneID); ok {
if existing, ok := table[strconv.Itoa(idx)]; ok {
return existing, nil
}
}
return seedRoomNodes(zoneID), nil return seedRoomNodes(zoneID), nil
} }
// saveStandaloneHarvestNodes writes nodes back into the run's harvest map. // saveStandaloneHarvestNodes writes nodes back into the run's harvest map.
func saveStandaloneHarvestNodes(runID string, roomIdx int, nodes []HarvestNode) error { func saveStandaloneHarvestNodes(runID string, zoneID ZoneID, nodeID string, nodes []HarvestNode) error {
table, err := loadStandaloneHarvestTable(runID) table, err := loadStandaloneHarvestTable(runID)
if err != nil { if err != nil {
return err return err
} }
table[roomHarvestKey(roomIdx)] = nodes table[nodeHarvestKey(nodeID)] = nodes
if idx, ok := legacyRoomIdxFromNodeID(nodeID, zoneID); ok {
delete(table, strconv.Itoa(idx))
}
raw, err := json.Marshal(table) raw, err := json.Marshal(table)
if err != nil { if err != nil {
return fmt.Errorf("marshal harvest table: %w", err) return fmt.Errorf("marshal harvest table: %w", err)
@@ -93,8 +108,8 @@ func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDC
prettyRoomType(run.CurrentRoomType()))) prettyRoomType(run.CurrentRoomType())))
} }
roomIdx := run.CurrentRoom nodeID := harvestNodeIDFor(run)
nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, roomIdx) nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load harvest state.") return p.SendDM(ctx.Sender, "Couldn't load harvest state.")
} }
@@ -155,7 +170,7 @@ func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDC
b.WriteString(flavor.Pick(flavor.NodeDepleted)) b.WriteString(flavor.Pick(flavor.NodeDepleted))
} }
if err := saveStandaloneHarvestNodes(run.RunID, roomIdx, nodes); err != nil { if err := saveStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID, nodes); err != nil {
slog.Error("standalone harvest: save nodes", "user", ctx.Sender, "run", run.RunID, "err", err) slog.Error("standalone harvest: save nodes", "user", ctx.Sender, "run", run.RunID, "err", err)
} }

View File

@@ -48,8 +48,9 @@ func TestStandaloneHarvest_RoutesToZoneRun(t *testing.T) {
if raw == "" || raw == "{}" { if raw == "" || raw == "{}" {
t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw) t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw)
} }
if !strings.Contains(raw, "\"0\":") { wantKey := "\"" + deriveLegacyNodeID(run.ZoneID, 0) + "\":"
t.Errorf("expected room 0 entry in harvest table, got %q", raw) if !strings.Contains(raw, wantKey) {
t.Errorf("expected entry node %s in harvest table, got %q", wantKey, raw)
} }
} }

View File

@@ -330,6 +330,22 @@ func eliteRoomEntryLine(zoneID ZoneID, runID string, roomIdx int) string {
return "🎭 **TwinBee:** " + line return "🎭 **TwinBee:** " + line
} }
// narrationCadence returns the salt index used to seed narration
// pickers — the count of nodes visited so far (0-based, matching the
// pre-G6 CurrentRoom semantics). Both linear-mode and graph-mode
// advance bump len(VisitedNodes) in lockstep with CurrentRoom, so this
// preserves existing pick determinism while letting G9 drop the legacy
// current_room column without re-keying every flavor pool seed.
func narrationCadence(run *DungeonRun) int {
if run == nil {
return 0
}
if n := len(run.VisitedNodes); n > 0 {
return n - 1
}
return run.CurrentRoom
}
// pickLineDeterministic — stable selection across (runID, salt). Same // pickLineDeterministic — stable selection across (runID, salt). Same
// inputs always yield the same line, so a player who re-reads status // inputs always yield the same line, so a player who re-reads status
// sees the same prose; different rooms / different runs vary. // sees the same prose; different rooms / different runs vary.

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()
}

View File

@@ -0,0 +1,81 @@
package plugin
import (
"strings"
"testing"
)
// TestRenderZoneGraphMap_LinearLooksFamiliar exercises the linear-graph
// case so the new renderer doesn't regress the legacy single-row look.
func TestRenderZoneGraphMap_LinearLooksFamiliar(t *testing.T) {
g := BuildLinearGraph(ZoneGoblinWarrens, []ZoneNodeKind{
NodeKindEntry, NodeKindExploration, NodeKindTrap, NodeKindBoss,
})
run := &DungeonRun{
ZoneID: ZoneGoblinWarrens,
CurrentNode: "goblin_warrens.r2",
VisitedNodes: []string{"goblin_warrens.r1", "goblin_warrens.r2"},
}
got := renderZoneGraphMap(g, run)
// Top row should contain glyphs in order.
if !strings.Contains(got, "E──?──T──☠") {
t.Errorf("missing linear glyph row in:\n%s", got)
}
// Status row: first cleared, second current, rest pending.
if !strings.Contains(got, "✓") || !strings.Contains(got, "▶") || !strings.Contains(got, "·") {
t.Errorf("missing status markers in:\n%s", got)
}
}
// TestRenderZoneGraphMap_HidesUnvisitedSecrets confirms the spoiler
// guard: secret nodes don't render until the player has reached them.
func TestRenderZoneGraphMap_HidesUnvisitedSecrets(t *testing.T) {
nodes := []ZoneNode{
{NodeID: "z.entry", Kind: NodeKindEntry, IsEntry: true, PosX: 0},
{NodeID: "z.boss", Kind: NodeKindBoss, IsBoss: true, PosX: 1},
{NodeID: "z.secret", Kind: NodeKindSecret, PosX: 1, PosY: 1},
}
edges := []ZoneEdge{
{From: "z.entry", To: "z.boss"},
{From: "z.entry", To: "z.secret", Lock: LockPerception, LockData: map[string]any{"dc": 12}},
}
g := BuildGraph("test_zone", nodes, edges)
hidden := renderZoneGraphMap(g, &DungeonRun{
CurrentNode: "z.entry",
VisitedNodes: []string{"z.entry"},
})
if strings.Contains(hidden, "⚿") {
t.Errorf("unvisited secret should not render, got:\n%s", hidden)
}
// Once visited, the glyph appears.
revealed := renderZoneGraphMap(g, &DungeonRun{
CurrentNode: "z.secret",
VisitedNodes: []string{"z.entry", "z.secret"},
})
if !strings.Contains(revealed, "⚿") {
t.Errorf("visited secret should render, got:\n%s", revealed)
}
}
// TestRenderZoneGraphMap_LockedEdgeRenderedAsCross verifies locked-only
// approaches show instead of ──.
func TestRenderZoneGraphMap_LockedEdgeRenderedAsCross(t *testing.T) {
nodes := []ZoneNode{
{NodeID: "z.entry", Kind: NodeKindEntry, IsEntry: true, PosX: 0},
{NodeID: "z.gate", Kind: NodeKindExploration, PosX: 1},
{NodeID: "z.boss", Kind: NodeKindBoss, IsBoss: true, PosX: 2},
}
edges := []ZoneEdge{
{From: "z.entry", To: "z.gate", Lock: LockKey, LockData: map[string]any{"key_id": "rune"}},
{From: "z.gate", To: "z.boss"},
}
g := BuildGraph("test_zone_locked", nodes, edges)
got := renderZoneGraphMap(g, &DungeonRun{
CurrentNode: "z.entry",
VisitedNodes: []string{"z.entry"},
})
if !strings.Contains(got, "") {
t.Errorf("locked edge should render as , got:\n%s", got)
}
}