mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32: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:
@@ -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
|
||||
|
||||
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())
|
||||
}
|
||||
434
internal/plugin/zone_graph_nav.go
Normal file
434
internal/plugin/zone_graph_nav.go
Normal 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
|
||||
}
|
||||
|
||||
257
internal/plugin/zone_graph_nav_test.go
Normal file
257
internal/plugin/zone_graph_nav_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user