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

@@ -46,8 +46,13 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro
switch strings.ToLower(sub) {
case "", "list", "ls":
return p.zoneCmdList(ctx, c)
case "enter", "go", "start":
case "enter", "start":
return p.zoneCmdEnter(ctx, c, rest)
case "go", "choose":
// Graph-mode fork commit. Outside the POC gate (or with no
// fork pending) the handler short-circuits with a friendly
// message — see zoneCmdGo for the full surface.
return p.zoneCmdGo(ctx, rest)
case "status", "info":
return p.zoneCmdStatus(ctx)
case "map", "m":
@@ -75,6 +80,7 @@ func zoneHelpText() string {
b.WriteString("`!zone status` — show your current run\n")
b.WriteString("`!zone map` — show the room layout\n")
b.WriteString("`!zone advance` — resolve the current room and move on\n")
b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
b.WriteString("`!zone taunt` — poke TwinBee (mood 10)\n")
b.WriteString("`!zone compliment` — flatter TwinBee (mood +5)\n")
@@ -401,6 +407,55 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
return p.streamOrSend(ctx.Sender, preStream, intro, phases, outcome)
}
// Graph-mode (POC gate): clear the current room, then either auto-advance
// down a single edge, surface a fork prompt for 2+ edges, or complete the
// run when there are 0 outgoing edges. Legacy linear path stays on
// markRoomCleared so non-gated deployments are bit-identical to G4.
if branchingZonesEnabled() {
c, cerr := LoadDnDCharacter(ctx.Sender)
if cerr != nil {
return p.SendDM(ctx.Sender, "Couldn't load character: "+cerr.Error())
}
next, forkMsg, complete, gerr := p.advanceTransitionGraph(run, zone, c)
if gerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+gerr.Error())
}
if complete {
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
var b strings.Builder
if outcome != "" {
b.WriteString(outcome)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 {
b.WriteString("**Loot:**\n")
for _, id := range granted {
b.WriteString("• " + id + "\n")
}
}
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
}
if forkMsg != "" {
var b strings.Builder
if outcome != "" {
b.WriteString(outcome)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
b.WriteString(forkMsg)
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
}
// Single-edge auto-advance — fall through to the shared next-room
// teaser using the resolved next room type.
return p.streamOrSend(ctx.Sender, preStream, intro, phases,
p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next))
}
next, err := markRoomCleared(run.RunID)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
@@ -426,7 +481,17 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
}
nextIdx := run.CurrentRoom + 1
return p.streamOrSend(ctx.Sender, preStream, intro, phases,
p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next))
}
// formatNextRoomMessage builds the "✓ Cleared room X. ... Room Y/N —
// TYPE." block used by both the legacy linear advance and graph-mode
// single-edge auto-advance. nextIdx is derived from the live run row
// — markRoomCleared / advanceZoneRunNode have already bumped it by the
// time this is called.
func (p *AdventurePlugin) formatNextRoomMessage(run *DungeonRun, zone ZoneDefinition, prev RoomType, prevIdx int, outcome string, next RoomType) string {
nextIdx := prevIdx + 1
var b strings.Builder
if outcome != "" {
b.WriteString(outcome)
@@ -456,7 +521,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
}
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(next)))
b.WriteString("`!zone advance` to continue.")
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
return b.String()
}
// streamOrSend dispatches a staged room resolution. When the room produced

View File

@@ -0,0 +1,169 @@
package plugin
// Phase G5 — graph-mode dispatch glue. Bridges the navigation helpers
// in zone_graph_nav.go into the !zone advance / !zone go command flows.
import (
"fmt"
"strings"
)
// advanceTransitionGraph is the graph-mode replacement for
// markRoomCleared inside zoneCmdAdvance. It records that the current
// room is cleared, then decides what comes next based on the player's
// outgoing edges in the registered (or legacy-compiled) zone graph:
//
// - 0 outgoing edges → run completes. complete=true. Caller emits
// the run-complete narration. boss_defeated is set when the
// current node is the graph's boss.
// - 1 unlocked outgoing edge → auto-advance to that node. next is
// the resolved next-room type for the teaser block.
// - 2+ outgoing edges OR 1 locked edge → fork prompt is persisted
// and forkMessage is the rendered menu. Caller emits it instead
// of the next-room teaser. The player commits via !zone go <n>.
//
// A single locked edge still surfaces as a fork prompt: the player
// would otherwise be stuck with no signal that progression is gated.
func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefinition, c *DnDCharacter) (next RoomType, forkMessage string, complete bool, err error) {
g, ok := loadZoneGraph(zone.ID)
if !ok {
err = fmt.Errorf("no graph for zone %q", zone.ID)
return
}
currentNode := run.CurrentNode
if currentNode == "" {
// Should not happen post-G4 dual-write; fall back to derived id
// so we never crash on an old row that snuck through.
currentNode = deriveLegacyNodeID(run.ZoneID, run.CurrentRoom)
}
if _, rerr := recordRoomCleared(run.RunID); rerr != nil {
err = rerr
return
}
ctx := buildUnlockCtx(c, run.RunID, currentNode)
choices := evaluateForkEdges(g, currentNode, ctx)
if len(choices) == 0 {
// Dead-end or boss node. Boss completes with boss_defeated=1;
// non-boss dead-ends just terminate the run quietly.
isBoss := g.Nodes[currentNode].IsBoss
if cerr := completeRunAtNode(run.RunID, isBoss); cerr != nil {
err = cerr
return
}
complete = true
return
}
unlocked := unlockedChoices(choices)
if len(choices) == 1 && len(unlocked) == 1 {
// Plain auto-advance — no fork to prompt for.
chosen := choices[0]
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
err = aerr
return
}
next = nodeKindToRoomType(g.Nodes[chosen.To].Kind)
return
}
pf := pendingFork{
PendingAt: currentNode,
Options: choices,
}
if werr := writePendingFork(run.RunID, pf); werr != nil {
err = werr
return
}
forkMessage = renderForkPrompt(zone, pf)
return
}
func unlockedChoices(cs []pendingChoice) []pendingChoice {
out := make([]pendingChoice, 0, len(cs))
for _, c := range cs {
if c.Unlocked {
out = append(out, c)
}
}
return out
}
// nodeKindToRoomType bridges the graph node kinds back to the legacy
// RoomType enum used by !zone advance's teaser. Stays in lockstep with
// roomTypeToNodeKind in zone_graph.go.
func nodeKindToRoomType(k ZoneNodeKind) RoomType {
switch k {
case NodeKindEntry:
return RoomEntry
case NodeKindExploration, NodeKindSecret, NodeKindHarvest, NodeKindRestCamp, NodeKindFork, NodeKindMerge:
return RoomExploration
case NodeKindTrap:
return RoomTrap
case NodeKindElite:
return RoomElite
case NodeKindBoss:
return RoomBoss
}
return RoomExploration
}
// zoneCmdGo handles `!zone go <n>` (alias `!zone choose <n>`). Reads
// the pending fork from node_choices, validates the chosen edge is
// unlocked, advances the run state to the chosen node, and emits the
// arrival teaser. The player still has to !zone advance to resolve
// the new room — same cadence as auto-advance.
func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
}
pf, derr := decodePendingFork(run.NodeChoices)
if derr != nil {
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
}
if pf == nil {
return p.SendDM(ctx.Sender, "No fork pending. Use `!zone advance` to continue.")
}
rest = strings.TrimSpace(rest)
if rest == "" {
zone := zoneOrFallback(run.ZoneID)
return p.SendDM(ctx.Sender, "**Pick a path:**\n\n"+renderForkPrompt(zone, *pf)+"\n\n_Use `!zone go <n>`._")
}
choice := atoiSafe(rest)
if choice <= 0 {
return p.SendDM(ctx.Sender, "Choice must be a number from the menu (`!zone go 1`, `!zone go 2`, …).")
}
chosen, cerr := resolveForkChoice(pf, choice)
if cerr != nil {
return p.SendDM(ctx.Sender, cerr.Error())
}
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
}
g, _ := loadZoneGraph(run.ZoneID)
zone := zoneOrFallback(run.ZoneID)
nextNode := g.Nodes[chosen.To]
nextRoom := nodeKindToRoomType(nextNode.Kind)
nextIdx := run.CurrentRoom + 1
var b strings.Builder
b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label))
if nextRoom == RoomBoss {
if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
} else {
if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, nextIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
}
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** `!zone advance` to continue.",
nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom)))
return p.SendDM(ctx.Sender, b.String())
}

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
}

View File

@@ -0,0 +1,257 @@
package plugin
import (
"strings"
"testing"
)
func TestEvaluateEdgeLock_None(t *testing.T) {
e := ZoneEdge{From: "a", To: "b", Lock: LockNone}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{})
if !ok {
t.Fatal("LockNone should always unlock")
}
}
func TestEvaluateEdgeLock_Perception(t *testing.T) {
e := ZoneEdge{
From: "a",
To: "b",
Lock: LockPerception,
LockData: map[string]any{"dc": 1}, // trivially passes
}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{
RunID: "run", FromNode: "a",
AbilityMods: [6]int{0, 0, 0, 0, 5, 0},
})
if !ok {
t.Fatal("Perception roll+5 vs DC 1 should unlock")
}
// DC 99 + bad WIS → never unlock
e.LockData = map[string]any{"dc": 99}
ok, reason := evaluateEdgeLock(e, edgeUnlockCtx{
RunID: "run", FromNode: "a",
AbilityMods: [6]int{0, 0, 0, 0, -2, 0},
})
if ok {
t.Fatal("Perception vs DC 99 should fail")
}
if !strings.Contains(reason, "Perception") {
t.Errorf("reason = %q, want Perception phrasing", reason)
}
}
func TestPerceptionRollForEdge_Deterministic(t *testing.T) {
r1 := perceptionRollForEdge("run-x", "a", "b")
r2 := perceptionRollForEdge("run-x", "a", "b")
if r1 != r2 {
t.Fatalf("perception roll non-deterministic: %d vs %d", r1, r2)
}
if r1 < 1 || r1 > 20 {
t.Errorf("roll %d out of 1..20", r1)
}
r3 := perceptionRollForEdge("run-y", "a", "b")
r4 := perceptionRollForEdge("run-x", "a", "c")
// Just sanity-check the seed actually mixes inputs.
if r1 == r3 && r1 == r4 {
t.Error("perception roll insensitive to seed inputs")
}
}
func TestEvaluateEdgeLock_Key(t *testing.T) {
e := ZoneEdge{
From: "a",
To: "b",
Lock: LockKey,
LockData: map[string]any{"key_id": "Brass Key"},
}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{
InventoryNames: map[string]bool{"brass key": true},
})
if !ok {
t.Fatal("matching key should unlock")
}
ok, reason := evaluateEdgeLock(e, edgeUnlockCtx{
InventoryNames: map[string]bool{},
})
if ok {
t.Fatal("missing key should not unlock")
}
if !strings.Contains(reason, "key") {
t.Errorf("reason = %q", reason)
}
}
func TestEvaluateEdgeLock_LevelMin(t *testing.T) {
e := ZoneEdge{From: "a", To: "b", Lock: LockLevelMin, LockData: map[string]any{"min_level": 5}}
if ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{CharLevel: 5}); !ok {
t.Fatal("level 5 should pass min 5")
}
if ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{CharLevel: 4}); ok {
t.Fatal("level 4 should fail min 5")
}
}
func TestEvaluateEdgeLock_StatCheck(t *testing.T) {
e := ZoneEdge{
From: "a", To: "b", Lock: LockStatCheck,
LockData: map[string]any{"stat": "STR", "dc": 1},
}
ok, _ := evaluateEdgeLock(e, edgeUnlockCtx{
RunID: "x", FromNode: "a",
AbilityMods: [6]int{5, 0, 0, 0, 0, 0},
})
if !ok {
t.Fatal("STR+5 vs DC 1 should unlock")
}
}
func TestPendingFork_Roundtrip(t *testing.T) {
pf := pendingFork{
PendingAt: "fork",
Options: []pendingChoice{
{Index: 1, To: "left", Label: "Left", Unlocked: true},
{Index: 2, To: "right", Label: "Right", Unlocked: false, Hint: "you sense a draft", Reason: "Perception 4 vs DC 12"},
},
}
m, err := encodePendingFork(pf)
if err != nil {
t.Fatalf("encode: %v", err)
}
got, err := decodePendingFork(m)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got == nil || got.PendingAt != "fork" || len(got.Options) != 2 {
t.Fatalf("decoded pendingFork mismatch: %+v", got)
}
if got.Options[0].To != "left" || got.Options[1].Hint != "you sense a draft" {
t.Errorf("decoded options bad: %+v", got.Options)
}
}
func TestDecodePendingFork_Empty(t *testing.T) {
if pf, _ := decodePendingFork(nil); pf != nil {
t.Error("nil map should decode to nil fork")
}
if pf, _ := decodePendingFork(map[string]any{}); pf != nil {
t.Error("empty map should decode to nil fork")
}
// Non-fork content (some future schema in the same column) is also nil.
if pf, _ := decodePendingFork(map[string]any{"unrelated": 1}); pf != nil {
t.Error("non-fork content should decode to nil fork")
}
}
func TestResolveForkChoice(t *testing.T) {
pf := &pendingFork{
PendingAt: "fork",
Options: []pendingChoice{
{Index: 1, To: "left", Unlocked: true},
{Index: 2, To: "right", Unlocked: false, Reason: "DC 12"},
},
}
if c, err := resolveForkChoice(pf, 1); err != nil || c.To != "left" {
t.Errorf("choice 1 = %+v err=%v", c, err)
}
if _, err := resolveForkChoice(pf, 2); err == nil {
t.Error("locked choice should error")
}
if _, err := resolveForkChoice(pf, 3); err == nil {
t.Error("out-of-range should error")
}
if _, err := resolveForkChoice(nil, 1); err == nil {
t.Error("nil fork should error")
}
}
func TestRenderForkPrompt_LockedHint(t *testing.T) {
zone := ZoneDefinition{ID: "z", Display: "Test"}
pf := pendingFork{
Options: []pendingChoice{
{Index: 1, Label: "Left", Unlocked: true},
{Index: 2, Label: "Right", Unlocked: false, Hint: "you sense a draft"},
{Index: 3, Label: "Hidden", Unlocked: false},
},
}
got := renderForkPrompt(zone, pf)
if !strings.Contains(got, "**1.** Left") {
t.Errorf("unlocked option not rendered: %q", got)
}
if !strings.Contains(got, "you sense a draft") {
t.Errorf("hint not rendered: %q", got)
}
if !strings.Contains(got, "**3.** Hidden _(locked)_") {
t.Errorf("plain locked option not rendered: %q", got)
}
}
func TestEvaluateForkEdges_OrdersByGraph(t *testing.T) {
g := BuildGraph("z",
[]ZoneNode{
{NodeID: "e", IsEntry: true, Kind: NodeKindEntry, Label: "Entry"},
{NodeID: "fork", Kind: NodeKindFork, Label: "Fork"},
{NodeID: "left", Kind: NodeKindExploration, Label: "Left Hall"},
{NodeID: "right", Kind: NodeKindExploration, Label: "Right Hall"},
{NodeID: "b", IsBoss: true, Kind: NodeKindBoss, Label: "Boss"},
},
[]ZoneEdge{
{From: "e", To: "fork"},
{From: "fork", To: "right", Weight: 2,
Lock: LockLevelMin, LockData: map[string]any{"min_level": 99}},
{From: "fork", To: "left", Weight: 1},
{From: "left", To: "b"},
{From: "right", To: "b"},
},
)
ctx := edgeUnlockCtx{RunID: "run-1", FromNode: "fork", CharLevel: 5}
choices := evaluateForkEdges(g, "fork", ctx)
if len(choices) != 2 {
t.Fatalf("want 2 choices, got %d", len(choices))
}
// Lowest weight first → left.
if choices[0].To != "left" || !choices[0].Unlocked {
t.Errorf("choice 0 = %+v", choices[0])
}
if choices[1].To != "right" || choices[1].Unlocked {
t.Errorf("choice 1 = %+v", choices[1])
}
if choices[0].Label != "Left Hall" {
t.Errorf("label not propagated: %q", choices[0].Label)
}
}
func TestNodeKindToRoomType(t *testing.T) {
cases := map[ZoneNodeKind]RoomType{
NodeKindEntry: RoomEntry,
NodeKindExploration: RoomExploration,
NodeKindSecret: RoomExploration,
NodeKindHarvest: RoomExploration,
NodeKindFork: RoomExploration,
NodeKindMerge: RoomExploration,
NodeKindTrap: RoomTrap,
NodeKindElite: RoomElite,
NodeKindBoss: RoomBoss,
}
for k, want := range cases {
if got := nodeKindToRoomType(k); got != want {
t.Errorf("%s → %s, want %s", k, got, want)
}
}
}
func TestBranchingZonesGate(t *testing.T) {
t.Setenv("GOGOBEE_BRANCHING_ZONES", "")
if branchingZonesEnabled() {
t.Error("gate should be off when env empty")
}
t.Setenv("GOGOBEE_BRANCHING_ZONES", "1")
if !branchingZonesEnabled() {
t.Error("gate should be on when env=1")
}
t.Setenv("GOGOBEE_BRANCHING_ZONES", "0")
if branchingZonesEnabled() {
t.Error("gate should be off when env=0")
}
}