Branching zones G5: navigation surface behind GOGOBEE_BRANCHING_ZONES=1

Wires the graph types from G2/G3/G4 into the !zone advance flow.
Default off; with the gate on, room-clear branches on outgoing-edge
count instead of the linear current_room+1 walk.

zone_graph_nav.go — env gate, edge unlock evaluation
(LockNone/Perception/Key/LevelMin/RegionClear/StatCheck), pendingFork
JSON shape with encode/decode helpers, perception roll seeded
deterministically from (runID, from, to) so reload doesn't grant
retries, plus fork prompt rendering and the
recordRoomCleared/advanceZoneRunNode/completeRunAtNode persistence
trio.

dnd_zone_cmd_graph.go — advanceTransitionGraph drives the graph-mode
branch in zoneCmdAdvance: 0 outs → run completes (boss_defeated set
when current node IsBoss); 1 unlocked out → auto-advance and emit
the same teaser the legacy path uses; 2+ outs (or a single locked
out) → write pending fork to node_choices and render the menu. New
!zone go <n> / !zone choose <n> consumes the prompt, validates the
chosen edge is unlocked, advances, and emits the arrival teaser.
Old "go" alias for "enter" was unused in tests — repurposed.

dnd_zone_cmd.go — extracts formatNextRoomMessage so the legacy and
graph-mode paths share the post-advance teaser. Adds the new
subcommand to the dispatch switch and help text.

Tests: pure-logic coverage for unlock evaluation, perception
determinism, pendingFork roundtrip + decode-edge-cases, choice
resolution, fork prompt rendering with hint vs no-hint locked
options, edge ordering by (Weight, To), and the env gate. Existing
zone tests pass with and without the gate set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 14:49:22 -07:00
parent a413c92844
commit 2d249d7d0a
4 changed files with 928 additions and 3 deletions

View File

@@ -0,0 +1,434 @@
package plugin
// Phase G5 — branching-zone navigation surface.
//
// Wires the graph types from G2/G3/G4 into the !zone advance flow:
// when the player clears a room with 2+ outgoing edges, write a pending
// fork prompt to dnd_zone_run.node_choices and DM the menu. !zone go <n>
// consumes the choice, validates the chosen edge is unlocked, and
// advances the run state to the chosen node.
//
// Behavior is gated by GOGOBEE_BRANCHING_ZONES=1 during the POC week
// (plan §G5 / §9.5). With the gate off, !zone advance still uses the
// linear markRoomCleared path and forks never fire — even on zones
// that have a hand-authored graph registered.
import (
"crypto/sha1"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"strings"
"gogobee/internal/db"
)
// branchingZonesEnabled — POC gate. Default off. Operator flips it for
// the POC soak week, then it's removed in G9.
func branchingZonesEnabled() bool {
return os.Getenv("GOGOBEE_BRANCHING_ZONES") == "1"
}
// pendingFork is the typed shape of dnd_zone_run.node_choices when the
// player is paused at a fork. Persisted as JSON inside the
// map[string]any NodeChoices column; helpers below round-trip via JSON
// so the column stays tolerant of hand-authored / future shapes.
type pendingFork struct {
PendingAt string `json:"pending_at"`
Options []pendingChoice `json:"options"`
}
type pendingChoice struct {
Index int `json:"index"`
To string `json:"to"`
Label string `json:"label"`
Unlocked bool `json:"unlocked"`
Hint string `json:"hint"`
Lock string `json:"lock"`
Reason string `json:"reason,omitempty"`
}
// encodePendingFork turns a pendingFork into the map[string]any shape
// that DungeonRun.NodeChoices uses, so persisting just goes through
// the existing json marshaling in markRoomCleared / advanceZoneRunNode.
func encodePendingFork(pf pendingFork) (map[string]any, error) {
raw, err := json.Marshal(pf)
if err != nil {
return nil, err
}
out := map[string]any{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return out, nil
}
// decodePendingFork reads a pendingFork out of NodeChoices. Returns
// (nil, nil) when the column is empty or doesn't carry a fork prompt.
func decodePendingFork(m map[string]any) (*pendingFork, error) {
if len(m) == 0 {
return nil, nil
}
if _, ok := m["pending_at"]; !ok {
return nil, nil
}
raw, err := json.Marshal(m)
if err != nil {
return nil, err
}
var pf pendingFork
if err := json.Unmarshal(raw, &pf); err != nil {
return nil, err
}
return &pf, nil
}
// edgeUnlockCtx bundles everything the unlock evaluators need so we can
// test them without going through the live DB. Filled in by
// evaluateForkEdges from the live run + character.
type edgeUnlockCtx struct {
RunID string
FromNode string
CharLevel int
AbilityMods [6]int // STR, DEX, CON, INT, WIS, CHA — matches DnDCharacter.Modifiers()
InventoryNames map[string]bool
Expedition *Expedition
}
// evaluateEdgeLock returns whether the player can take this edge right
// now, with a player-facing reason on failure. Per plan §G5: Perception
// rolls fire once at fork-arrival (deterministic seed) and the result
// is committed for the lifetime of the prompt. Re-renders show the
// same outcome so the player can't reload to retry.
func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason string) {
switch e.Lock {
case "", LockNone:
return true, ""
case LockPerception:
dc := lockDataInt(e.LockData, "dc", 12)
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[4]
if total >= dc {
return true, ""
}
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
case LockKey:
key := strings.ToLower(strings.TrimSpace(lockDataString(e.LockData, "key_id")))
if key == "" {
return false, "missing key (no key_id authored)"
}
if ctx.InventoryNames[key] {
return true, ""
}
return false, "you don't have the key"
case LockLevelMin:
min := lockDataInt(e.LockData, "min_level", 1)
if ctx.CharLevel >= min {
return true, ""
}
return false, fmt.Sprintf("requires level %d (you are %d)", min, ctx.CharLevel)
case LockRegionClear:
region := lockDataString(e.LockData, "region_id")
if region == "" {
return false, "no region_id authored"
}
if ctx.Expedition != nil && IsRegionCleared(ctx.Expedition, region) {
return true, ""
}
return false, "another region must be cleared first"
case LockStatCheck:
dc := lockDataInt(e.LockData, "dc", 12)
stat := strings.ToUpper(lockDataString(e.LockData, "stat"))
idx := abilityIndex(stat)
if idx < 0 {
return false, "invalid stat_check authoring"
}
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[idx]
if total >= dc {
return true, ""
}
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc)
}
return false, "unknown lock type"
}
// abilityIndex maps a stat short-name to the Modifiers() slot.
func abilityIndex(s string) int {
switch strings.ToUpper(s) {
case "STR":
return 0
case "DEX":
return 1
case "CON":
return 2
case "INT":
return 3
case "WIS":
return 4
case "CHA":
return 5
}
return -1
}
func lockDataInt(m map[string]any, key string, def int) int {
v, ok := m[key]
if !ok {
return def
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return def
}
func lockDataString(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
if s, ok := v.(string); ok {
return s
}
return ""
}
// perceptionRollForEdge synthesizes a stable 1d20 result for a given
// (run, from-node, to-node). SHA1 keeps the distribution clean and
// avoids math/rand state contention. Re-arrival at the same fork in
// the same run reproduces the same roll, so the player can't reload
// to retry a failed Perception (plan §G5).
func perceptionRollForEdge(runID, fromNode, toNode string) int {
h := sha1.Sum([]byte(runID + "|" + fromNode + "|" + toNode))
return int(binary.BigEndian.Uint16(h[:2])%20) + 1
}
// buildUnlockCtx assembles an edgeUnlockCtx from the live character +
// expedition state. Inventory item names are lower-cased for matching
// against lock_data.key_id.
func buildUnlockCtx(c *DnDCharacter, runID, fromNode string) edgeUnlockCtx {
ctx := edgeUnlockCtx{
RunID: runID,
FromNode: fromNode,
CharLevel: c.Level,
AbilityMods: c.Modifiers(),
InventoryNames: map[string]bool{},
}
if items, err := loadAdvInventory(c.UserID); err == nil {
for _, it := range items {
ctx.InventoryNames[strings.ToLower(it.Name)] = true
}
}
if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil {
ctx.Expedition = exp
}
return ctx
}
// evaluateForkEdges walks all outgoing edges of fromNode in the graph
// and produces a pending-choice list ready to be persisted. Locked
// edges that have a Hint stay in the menu (the player needs the
// teaser); locked edges without any hint at all are still listed but
// reasoned-out.
func evaluateForkEdges(g ZoneGraph, fromNode string, ctx edgeUnlockCtx) []pendingChoice {
outs := g.outgoingEdges(fromNode)
if len(outs) == 0 {
return nil
}
choices := make([]pendingChoice, 0, len(outs))
for i, e := range outs {
unlocked, reason := evaluateEdgeLock(e, ctx)
toNode := g.Nodes[e.To]
label := toNode.Label
if label == "" {
label = prettyNodeKind(toNode.Kind)
}
choices = append(choices, pendingChoice{
Index: i + 1,
To: e.To,
Label: label,
Unlocked: unlocked,
Hint: e.Hint,
Lock: string(e.Lock),
Reason: reason,
})
}
return choices
}
func prettyNodeKind(k ZoneNodeKind) string {
switch k {
case NodeKindEntry:
return "Entry"
case NodeKindExploration:
return "Exploration"
case NodeKindTrap:
return "Trap"
case NodeKindElite:
return "Elite"
case NodeKindBoss:
return "Boss"
case NodeKindHarvest:
return "Harvest"
case NodeKindRestCamp:
return "Rest Camp"
case NodeKindSecret:
return "Secret"
case NodeKindFork:
return "Fork"
case NodeKindMerge:
return "Merge"
}
return "Room"
}
// renderForkPrompt is the player-facing menu rendered from a pendingFork.
// Locked edges with a Hint show the hint as a teaser; locked edges
// without a hint just show "(locked)".
func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display))
for _, c := range pf.Options {
switch {
case c.Unlocked:
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
case c.Hint != "":
b.WriteString(fmt.Sprintf("**%d.** %s _(locked — %s)_\n", c.Index, c.Label, c.Hint))
default:
b.WriteString(fmt.Sprintf("**%d.** %s _(locked)_\n", c.Index, c.Label))
}
}
return strings.TrimRight(b.String(), "\n")
}
// recordRoomCleared appends the current room to rooms_cleared and
// bumps last_action_at, without advancing current_room/current_node.
// Used by the graph-mode fork path: clearing the room is a separate
// step from choosing where to go next. Returns the updated DungeonRun
// snapshot reloaded post-write so callers see fresh fields.
func recordRoomCleared(runID string) (*DungeonRun, error) {
r, err := getZoneRun(runID)
if err != nil {
return nil, err
}
if r == nil {
return nil, ErrNoActiveRun
}
if !r.IsActive() {
return nil, ErrNoActiveRun
}
cleared := append(r.RoomsCleared, r.CurrentRoom)
clearedJSON, _ := json.Marshal(cleared)
if _, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET rooms_cleared = ?,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, string(clearedJSON), runID); err != nil {
return nil, err
}
r.RoomsCleared = cleared
return r, nil
}
// writePendingFork persists a pendingFork into node_choices for the
// given run. Replaces any prior fork — there is only ever one pending
// at a time.
func writePendingFork(runID string, pf pendingFork) error {
m, err := encodePendingFork(pf)
if err != nil {
return err
}
raw, err := json.Marshal(m)
if err != nil {
return err
}
_, err = db.Get().Exec(`
UPDATE dnd_zone_run
SET node_choices = ?,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, string(raw), runID)
return err
}
// clearPendingFork wipes node_choices. Called when the player commits a
// choice via !zone go and we transition to the chosen node.
func clearPendingFork(runID string) error {
_, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET node_choices = '{}',
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, runID)
return err
}
// completeRunAtNode marks the run finished at the current node. Used
// when graph-mode advance hits a 0-outgoing-edge boss or dead-end.
// boss=true sets boss_defeated; dead-ends leave it false.
func completeRunAtNode(runID string, boss bool) error {
bossI := 0
if boss {
bossI = 1
}
_, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET boss_defeated = ?,
completed_at = CURRENT_TIMESTAMP,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, bossI, runID)
return err
}
// advanceZoneRunNode moves a run to nextNode: appends to visited_nodes,
// sets current_node, bumps current_room (legacy dual-write), and
// clears any pending fork prompt. Caller is expected to have already
// called recordRoomCleared for the prior node.
func advanceZoneRunNode(runID, nextNode string) error {
r, err := getZoneRun(runID)
if err != nil {
return err
}
if r == nil {
return ErrNoActiveRun
}
visited := append(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited)
nextRoom := r.CurrentRoom + 1
_, err = db.Get().Exec(`
UPDATE dnd_zone_run
SET current_node = ?,
visited_nodes = ?,
current_room = ?,
node_choices = '{}',
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`,
nextNode, string(visitedJSON), nextRoom, runID)
return err
}
// resolveForkChoice takes a 1-based choice index against a pending
// fork and returns the chosen pendingChoice if it's both present and
// unlocked. Errors are formatted for direct DM display.
func resolveForkChoice(pf *pendingFork, choice int) (pendingChoice, error) {
if pf == nil || len(pf.Options) == 0 {
return pendingChoice{}, fmt.Errorf("no fork pending")
}
if choice < 1 || choice > len(pf.Options) {
return pendingChoice{}, fmt.Errorf("choice %d out of range (1%d)", choice, len(pf.Options))
}
c := pf.Options[choice-1]
if !c.Unlocked {
if c.Reason != "" {
return c, fmt.Errorf("path locked: %s", c.Reason)
}
return c, fmt.Errorf("path locked")
}
return c, nil
}