R2: !revisit <N> -- walk back one edge, for +1 threat

Retires forward-only navigation. `!revisit <N>` (alias `!zone revisit`)
walks one graph edge back to a room in VisitedNodes, chosen by the number
the player reads off !map's Path strip. Edges are directed, but a corridor
you walked down is a corridor you can walk back up, so adjacency checks
both directions. !map now prints the legal targets.

Cost is +1 threat, not the discount the plan specced. There was nothing to
discount: movement charges neither threat nor supplies, and a cleared room
fires no combat, so backtracking was free. +1 mirrors harvest noise -- less
than a fight (+5) or an elite (+8). Standalone runs have no threat clock and
pay nothing. A siege guard refuses the move at threat >= 99: siege is
one-way sticky, and a player must not be able to strand their own expedition
on a move they made to go pick up a herb.

The plan claimed terminal CombatSession rows already prevent re-triggering a
cleared room. They gate only Elite and Boss, at the doorway. An Exploration
room re-entered resolveCombatRoom unconditionally and a Trap room re-armed --
so revisit would have been an infinite farm: step back, advance, repeat.
resolveRoom now early-returns on an already-cleared room. No-op forward-only,
since advance clears a room only after resolving it.

Autopilot is ticker-driven with no on/off switch, so it would have walked the
player straight back out of the room they revisited. grantAutorunGrace stamps
last_autorun_at to buy one cooldown window. That is a stopgap; R5 still owes
the real policy.

Fork re-pick stays deferred to R3: revisit refuses while a fork is pending,
because advance and `!zone go` resolve node_choices without checking which
node the player is standing on.
This commit is contained in:
prosolis
2026-07-09 19:47:16 -07:00
parent 31e3d69d8d
commit ae5762fc91
5 changed files with 488 additions and 0 deletions

View File

@@ -59,6 +59,8 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro
return p.zoneCmdMap(ctx)
case "advance", "next", "a":
return p.zoneCmdAdvance(ctx)
case "revisit", "back":
return p.handleRevisitCmd(ctx, rest)
case "abandon", "leave", "quit":
return p.zoneCmdAbandon(ctx)
case "taunt":
@@ -81,6 +83,7 @@ func zoneHelpText() string {
b.WriteString("`!zone map` — show the room layout\n")
b.WriteString("`!zone advance` — resolve the current room and move on\n")
b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\n")
b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
b.WriteString("`!zone compliment` — flatter TwinBee (they'll like that)\n")
@@ -311,6 +314,13 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path)
}
if targets := revisitTargets(g, run); len(targets) > 0 {
nums := make([]string, len(targets))
for i, n := range targets {
nums[i] = fmt.Sprintf("`!revisit %d`", n)
}
b.WriteString("\n**Back to:** " + strings.Join(nums, " · "))
}
return p.SendDM(ctx.Sender, b.String())
}
// No registered graph (defensive — every zone has one post-G8).
@@ -933,6 +943,21 @@ func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []strin
// non-combat rooms (entry, trap), phases is nil and outcome carries the
// resolution narration.
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
// Revisit R2 — a cleared room stays cleared. Advancing out of a room the
// player backtracked into must not re-roll its combat or re-arm its trap.
//
// The revisit plan assumed terminal CombatSession rows already gated this.
// They only gate Elite/Boss (checked at the doorway in advanceOnceWithOpts);
// an Exploration room re-enters resolveCombatRoom unconditionally, and a
// Trap room re-arms. Without this, `!revisit` would be a loot exploit:
// walk back one room, advance, farm the same enemy forever.
//
// A no-op under forward-only navigation — advance clears the current room
// only after resolving it, so the room being resolved is never already in
// RoomsCleared.
if run.RoomIsCleared(run.CurrentRoom) {
return
}
switch run.CurrentRoomType() {
case RoomEntry:
return