Files
gogobee/internal/plugin/zone_graph_nav.go
prosolis 509df7fadf adventure: stop the log claiming a win it didn't see
Four things the review found, all of them the code telling a player
something that isn't true.

A run that walks into a dead end filed its ending as "cleared" whatever
the caller said, so both the liveblog and the summary reported a clear
for a party that merely ran out of map — and the end beat is
first-writer-wins, so nothing later could take it back. It says
"cleared" only for a boss now.

The re-offer branches of babysit and resume returned a zero cost, so a
player who was in fact charged read "0 coins" in the verdict. Both
re-quote the price they actually took.

And the realm-firsts reseed retired its one-shot job even when the read
under it had failed, which on a transient fault at Init would have left
the ledger mis-dated permanently. The read now says whether it worked,
and the job stays open when it didn't.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 22:07:38 -07:00

518 lines
16 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"
"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
}
// Only a boss kill is a clear. This function also closes a non-boss
// dead-end — the party simply ran out of map — and beatRunEnd is
// first-writer-wins, so calling that "cleared" would put a lie in the
// liveblog and the run summary that nothing downstream could correct.
// "ended" is deliberately outside the {cleared,died,retreated,abandoned}
// set: the prompt renderer already degrades an unknown outcome to a
// neutral "the run ended", which is exactly what happened.
if run, _ := getZoneRun(runID); run != nil {
outcome := "ended"
if boss {
outcome = "cleared"
}
beatRunEnd(run, outcome)
}
_, 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
}
idx := pathIndexOf(visited, nextNode)
// Every forward move in the game funnels through here — auto-advance, a
// player's `!zone go`, and the autopilot's stale-fork pick alike — which is
// what makes this the one honest place to say "the party is now in room N".
beatRoom(r, nextNode, idx, "entered")
return idx, 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
}