mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
169
internal/plugin/dnd_zone_cmd_graph.go
Normal file
169
internal/plugin/dnd_zone_cmd_graph.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user