Files
gogobee/internal/plugin/zone_graph_nav.go
prosolis a5c0a83e2a Branching zones G9b: stop persisting current_room / room_seq_json
The two legacy columns are no longer needed at runtime. CurrentRoom is
derived from len(VisitedNodes)-1 on read (lockstep with what the
dual-write era stored), and RoomSeq is a transient in-memory field
populated only when a fresh run is started.

- INSERT, SELECT, and UPDATE statements drop the two columns. The
  schema still carries them — they retire in G9c.
- scanZoneRun derives CurrentRoom and falls back to a linear-derived
  CurrentNode only if a row predates the G4 dual-write deploy.
- markRoomCleared rewritten to walk the registered graph (first
  outgoing edge / dead-end completion). Tests that use it as an
  end-to-end run driver continue to pass.
- advanceZoneRunNode drops its current_room dual-write.
- adventure_activity_unified reads visited_nodes for the daily
  "X/N rooms" progress fragment.
- Camp boss-room test pivots to setting current_node directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:21:33 -07:00

423 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
//
// G9a retired the GOGOBEE_BRANCHING_ZONES POC gate: graph mode is the
// only runtime path now that all 9 zones have hand-authored graphs.
import (
"crypto/sha1"
"encoding/binary"
"encoding/json"
"fmt"
"strings"
"gogobee/internal/db"
)
// 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_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, 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)
_, err = db.Get().Exec(`
UPDATE dnd_zone_run
SET current_node = ?,
visited_nodes = ?,
node_choices = '{}',
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`,
nextNode, string(visitedJSON), 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
}