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

@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"gogobee/internal/db"
@@ -25,28 +26,42 @@ import (
// belong to the expedition system. Standalone harvest is the casual
// flow: roll d20 vs DC, get yield, decrement node, save.
// loadStandaloneHarvestNodes reads the run's harvest map and returns the
// nodes for the given room. Lazy-seeds from the zone registry on first
// access. Caller persists via saveStandaloneHarvestNodes.
func loadStandaloneHarvestNodes(runID string, zoneID ZoneID, roomIdx int) ([]HarvestNode, error) {
// loadStandaloneHarvestNodes reads the run's harvest map and returns
// the nodes for the given graph node. Lazy-seeds from the zone registry
// on first access. Caller persists via saveStandaloneHarvestNodes.
//
// 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)
if err != nil {
return nil, err
}
roomKey := roomHarvestKey(roomIdx)
roomKey := nodeHarvestKey(nodeID)
if existing, ok := table[roomKey]; ok {
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
}
// 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)
if err != nil {
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)
if err != nil {
return fmt.Errorf("marshal harvest table: %w", err)
@@ -93,8 +108,8 @@ func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDC
prettyRoomType(run.CurrentRoomType())))
}
roomIdx := run.CurrentRoom
nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, roomIdx)
nodeID := harvestNodeIDFor(run)
nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID)
if err != nil {
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))
}
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)
}