R1: split "where am I" from "how far have I walked"

Backtracking (revisit R2) breaks the assumption every zone-run caller
quietly relied on: that CurrentRoom == len(VisitedNodes)-1. Position and
progress have been the same number only because navigation was
forward-only.

Split them. CurrentRoom is now the first-entry index of CurrentNode in
VisitedNodes -- the room number the player was shown on the way in, and
the salt that enemy/trap/harvest/encounter keys hash. A revisited room
therefore resolves to the same room. RoomsTraversed is a new monotonic
step counter, persisted, backfilled from visited_nodes for in-flight rows.

The audit found only two progress-shaped reads. narrationCadence moves to
RoomsTraversed so a backtracking player draws fresh flavor rather than
replaying the entry-room lines. The other was a latent bug: zoneCmdGo
labelled the room it moved into as CurrentRoom+1, which is only correct at
the frontier; advanceZoneRunNode now returns the true path index.

VisitedNodes becomes an ordered set and RoomsCleared becomes idempotent --
both no-ops while navigation is forward-only, both load-bearing after R2.

No player-visible behavior change. Nothing to route off RoomsTraversed for
threat/SU: verified at HEAD that movement charges neither. Threat comes
from combat, supplies burn per day. The revisit plan's cost model claimed
otherwise and has been corrected -- R2's "discount" is really a net-new
cost decision.
This commit is contained in:
prosolis
2026-07-09 19:36:24 -07:00
parent adcc62112b
commit 31e3d69d8d
9 changed files with 418 additions and 36 deletions

View File

@@ -62,7 +62,7 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin
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 {
if _, aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
err = aerr
return
}
@@ -184,7 +184,8 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
if cerr != nil {
return p.SendDM(ctx.Sender, cerr.Error())
}
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
if aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
}
markActedToday(ctx.Sender)
@@ -194,7 +195,6 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
nextNode := g.Nodes[chosen.To]
fireGraphRegionTransition(run.UserID, fromNode, nextNode)
nextRoom := nodeKindToRoomType(nextNode.Kind)
nextIdx := run.CurrentRoom + 1
var b strings.Builder
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))