diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 0418d9e..682dc77 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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 ` — 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 diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go new file mode 100644 index 0000000..2016855 --- /dev/null +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -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 . +// +// 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 ` (alias `!zone choose `). 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 `.") + } + 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 `._") + } + 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()) +} diff --git a/internal/plugin/zone_graph_nav.go b/internal/plugin/zone_graph_nav.go new file mode 100644 index 0000000..d64ccb8 --- /dev/null +++ b/internal/plugin/zone_graph_nav.go @@ -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 +// 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", 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 +} + diff --git a/internal/plugin/zone_graph_nav_test.go b/internal/plugin/zone_graph_nav_test.go new file mode 100644 index 0000000..a25a357 --- /dev/null +++ b/internal/plugin/zone_graph_nav_test.go @@ -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") + } +}