Files
gogobee/internal/plugin/zone_graph_nav.go
T
prosolis 6bcac41aa2 zones: make locked doors openable and stop autopilot stalling on them
Locks were fully implemented as pass/fail gates and nothing else. A
Perception or stat check rolls once per (run, edge), seeded so it can't
be reload-scummed — that half shipped in G5, the counterweight never did.
A bad roll simply deleted a branch of the graph for the rest of the run,
worst for a solo low-WIS character who quietly loses routes they never
learn existed. Three changes, one theme: a die roll should not be able to
permanently wall a player.

Party's best stat answers the check. evaluateEdgeLock read only the
acting character's mods, which made a party's rogue and its hired scout
decorative at every lock. Fold the whole roster — Pete included, since
excluding him would make hiring a scout worth less than the coins it
costs — and credit whoever got it open in the fork menu.

Thieves' tools as the escape hatch. A utility item (not a ConsumableDef,
or the fight engine would spend them for you) sold on Luigi's supplies
shelf, consumed by `!zone unlock <n>`. Deliberately not a skeleton key:
tools answer the two dice-driven locks only. A key lock is a quest token,
a level-min lock is progression, a region-clear lock is structure — none
of those are "you rolled badly", so none of them are pickable.

Autopilot picks a route instead of parking. The fork timeout was 8h,
which reads as "the player gets first say" and behaves as "the expedition
stops for a third of a day, at every fork" — a multi-day expedition
crosses a lot of forks. 30m keeps a genuine first say for anyone at the
keyboard. It now ranks by unvisited-then-edge-weight rather than taking
whatever the graph author happened to list first, spends tools when every
route is locked, and backtracks a room when it can't do even that, rather
than idling into the 24h reaper and losing the player days of progress to
a roll they never saw.

Sim A/B, same seeds, 90 runs/arm: 43.3% -> 42.2% clear, a single run
flipping and well inside the documented noise floor. A party+companion+pet
arm runs 31/32 clean through the new roster-folding path.
2026-07-20 23:52:20 -07:00

499 lines
15 KiB
Go
Raw 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"
"maunium.net/go/mautrix/id"
)
// 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 is the *party's best* modifier per ability — STR, DEX, CON,
// INT, WIS, CHA, matching DnDCharacter.Modifiers(). A door doesn't care
// which set of eyes spotted the seam, and reading only the leader's sheet
// meant a party's rogue and its hired scout were decorative at every lock.
// AbilityWho names whoever supplied each best, empty when it's the leader,
// so the fork prompt can say who got it open.
AbilityMods [6]int
AbilityWho [6]string
InventoryNames map[string]bool
Expedition *Expedition
}
// creditFor names the party member whose ability carried a check, phrased for
// the fork prompt. Empty when the acting character managed it alone — there is
// nobody to credit and the menu stays quiet.
func (c edgeUnlockCtx) creditFor(ability int) string {
if who := c.AbilityWho[ability]; who != "" {
return who + " got it open"
}
return ""
}
// bestAbility folds one body's modifiers into the running party-best, recording
// the contributor's name for any ability it improves on.
func (c *edgeUnlockCtx) bestAbility(mods [6]int, who string) {
for i, m := range mods {
if m > c.AbilityMods[i] {
c.AbilityMods[i] = m
c.AbilityWho[i] = who
}
}
}
// 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, ctx.creditFor(4)
}
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, ctx.creditFor(idx)
}
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
foldPartyAbilities(&ctx, exp, c.UserID)
}
return ctx
}
// foldPartyAbilities raises ctx.AbilityMods to the best any body on the
// expedition can offer. The companion counts: he is a seat that walks the same
// corridor, and excluding him would make hiring a scout worth less than the
// coins it costs.
//
// Errors are swallowed rather than propagated — a roster read that fails leaves
// the leader's own mods standing, which is exactly the pre-party behaviour and
// never harder than it was.
func foldPartyAbilities(ctx *edgeUnlockCtx, exp *Expedition, acting id.UserID) {
seats, err := expeditionParty(exp.ID, string(exp.UserID))
if err != nil {
return
}
for _, s := range seats {
if s.Kind == SeatCompanion {
class, level := companionLoadout(exp.ID)
ctx.bestAbility(companionSheet(class, level).Modifiers(), companionDisplayName)
continue
}
if s.UserID == acting {
continue // whoever we built the ctx from is already the baseline
}
mate, err := LoadDnDCharacter(s.UserID)
if err != nil || mate == nil {
continue
}
name, _ := loadDisplayName(s.UserID)
if name == "" {
name = string(s.UserID)
}
ctx.bestAbility(mate.Modifiers(), name)
}
}
// 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 && c.Reason != "":
// A party-mate's ability beat the check — say so, so the player can
// see what the roster bought them.
b.WriteString(fmt.Sprintf("**%d.** %s _(%s)_\n", c.Index, c.Label, c.Reason))
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 := appendClearedRoom(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: records the node entry in
// visited_nodes, sets current_node, bumps the traversal counter, and clears
// any pending fork prompt. Caller is expected to have already called
// recordRoomCleared for the prior node.
//
// Returns the moved-to room's path index so callers can label it without
// assuming CurrentRoom+1 — an assumption that only holds while the player
// is walking the frontier.
func advanceZoneRunNode(runID, nextNode string) (int, error) {
r, err := getZoneRun(runID)
if err != nil {
return 0, err
}
if r == nil {
return 0, ErrNoActiveRun
}
visited := appendVisited(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited)
if _, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET current_node = ?,
visited_nodes = ?,
node_choices = '{}',
rooms_traversed = rooms_traversed + 1,
last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`,
nextNode, string(visitedJSON), runID); err != nil {
return 0, err
}
return pathIndexOf(visited, nextNode), nil
}
// 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
}