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

@@ -315,7 +315,7 @@ func recordRoomCleared(runID string) (*DungeonRun, error) {
if !r.IsActive() {
return nil, ErrNoActiveRun
}
cleared := append(r.RoomsCleared, r.CurrentRoom)
cleared := appendClearedRoom(r.RoomsCleared, r.CurrentRoom)
clearedJSON, _ := json.Marshal(cleared)
if _, err := db.Get().Exec(`
UPDATE dnd_zone_run
@@ -376,28 +376,36 @@ func completeRunAtNode(runID string, boss bool) error {
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 {
// 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 err
return 0, err
}
if r == nil {
return ErrNoActiveRun
return 0, ErrNoActiveRun
}
visited := append(r.VisitedNodes, nextNode)
visited := appendVisited(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited)
_, err = db.Get().Exec(`
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)
return err
nextNode, string(visitedJSON), runID); err != nil {
return 0, err
}
return pathIndexOf(visited, nextNode), nil
}
// resolveForkChoice takes a 1-based choice index against a pending