Files
gogobee/internal/plugin/dnd_zone_cmd_graph.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

241 lines
8.3 KiB
Go

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"
"maunium.net/go/mautrix/id"
)
// 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
}
// G6d — region boundary hook. Multi-region branching graphs
// can cross a region edge mid-run; emit the existing transit
// flavor + log so the player gets the same "you've crossed
// into X" beat the !region travel command produces. Doesn't
// retire the run (the graph is the authoritative state) — it
// just narrates the crossing.
fireGraphRegionTransition(run.UserID, g.Nodes[currentNode], g.Nodes[chosen.To])
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
}
// fireGraphRegionTransition compares the from/to graph node region ids
// and, when they differ, mirrors the existing !region travel surface:
// updates Expedition.CurrentRegion + visited list and writes a transit
// log entry. Unlike full !region travel, it does NOT burn supplies,
// advance the day, or retire/spawn DungeonRuns — the graph is the
// authoritative run state, and the player crossed via the graph edge,
// not a separate transit day. Standalone (no expedition) is a no-op.
func fireGraphRegionTransition(userID string, from, to ZoneNode) {
if from.RegionID == to.RegionID {
return
}
if to.RegionID == "" {
return
}
exp, err := getActiveExpedition(id.UserID(userID))
if err != nil || exp == nil {
return
}
if exp.CurrentRegion == to.RegionID {
return
}
if err := setCurrentRegion(exp, to.RegionID); err != nil {
return
}
_, _ = MarkRegionVisited(exp, to.RegionID)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("graph region transit: %s → %s (node %s → %s)",
from.RegionID, to.RegionID, from.NodeID, to.NodeID), "")
}
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, isLeader, err := activeZoneRunFor(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>`.")
}
if !isLeader {
// The fork is one choice for one party, and the run is the leader's row.
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
}
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
}
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 "+continueHint(ctx.Sender))
}
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())
}
return p.commitForkChoice(ctx, run, chosen, "")
}
// commitForkChoice advances the run onto an already-validated fork option and
// emits the arrival teaser. Split out of zoneCmdGo so `!zone unlock` — which
// reaches the same place by paying for it — cannot drift from the plain
// `!zone go` arrival: same region-transition hook, same camp strike, same
// boss/elite prompt. header, if set, is printed above the move.
func (p *AdventurePlugin) commitForkChoice(
ctx MessageContext, run *DungeonRun, chosen pendingChoice, header string,
) error {
nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
if aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
}
markActedToday(ctx.Sender)
g, _ := loadZoneGraph(run.ZoneID)
zone := zoneOrFallback(run.ZoneID)
fromNode := g.Nodes[run.CurrentNode]
nextNode := g.Nodes[chosen.To]
fireGraphRegionTransition(run.UserID, fromNode, nextNode)
nextRoom := nodeKindToRoomType(nextNode.Kind)
var b strings.Builder
b.WriteString(header)
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
}
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.** ", nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom)))
switch nextRoom {
case RoomBoss:
b.WriteString("`!fight` when you're ready for the boss.")
case RoomElite:
b.WriteString("`!fight` when ready.")
default:
b.WriteString(continueHint(ctx.Sender))
}
return p.SendDM(ctx.Sender, b.String())
}