mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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.
188 lines
7.2 KiB
Go
188 lines
7.2 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gogobee/internal/db"
|
|
)
|
|
|
|
// Revisit R2 (gogobee_revisit_plan.md §R2) — `!revisit <N>` walks one graph
|
|
// edge back toward a room the player has already been to. Forward-only
|
|
// navigation was the rule since Phase G; this retires it deliberately.
|
|
//
|
|
// Built on R1's split: CurrentRoom is the *position* (the first-entry index
|
|
// of CurrentNode) and moves backwards with the player, while RoomsTraversed
|
|
// is the *effort* and only climbs. The room keeps its original number, so a
|
|
// revisited room resolves to the same enemies, traps and harvest nodes it
|
|
// had on the way in.
|
|
|
|
// revisitThreatCost is what one backtrack edge adds to the threat clock.
|
|
//
|
|
// The revisit plan originally specced this as a *discount* on a +2/-1.0
|
|
// per-room cost. That cost does not exist: verified at HEAD, movement charges
|
|
// no threat and no supplies (supplies burn per day; threat comes from combat,
|
|
// drift, harvest noise and ambient). Backtracking through cleared rooms fires
|
|
// no combat, so without this it would be entirely free.
|
|
//
|
|
// +1 mirrors the harvest-noise bump: doubling back stirs up attention but
|
|
// costs less than a fight (+5) or an elite (+8). The real pressure on a
|
|
// detour remains the day clock, which a long one burns anyway.
|
|
const revisitThreatCost = 1
|
|
|
|
// revisitSiegeGuard refuses a voluntary backtrack that would trip Siege Mode.
|
|
// applyThreatDelta flips siege at 100 and siege is one-way sticky, so a
|
|
// player at 99 must not be able to strand their own expedition on a move
|
|
// they made to go pick up a herb.
|
|
const revisitSiegeGuard = 100 - revisitThreatCost
|
|
|
|
// adjacentNodes returns every node joined to `node` by a graph edge, in
|
|
// either direction. Zone graph edges are directed (they encode the forward
|
|
// route), but a corridor you walked down is a corridor you can walk back up.
|
|
func adjacentNodes(g ZoneGraph, node string) map[string]bool {
|
|
adj := make(map[string]bool)
|
|
for _, e := range g.outgoingEdges(node) {
|
|
adj[e.To] = true
|
|
}
|
|
for from, outs := range g.Edges {
|
|
for _, e := range outs {
|
|
if e.To == node {
|
|
adj[from] = true
|
|
}
|
|
}
|
|
}
|
|
return adj
|
|
}
|
|
|
|
// revisitZoneRun moves the run back to targetNode. VisitedNodes is untouched
|
|
// (it is an ordered set — see appendVisited), so the target keeps the room
|
|
// number the player read off `!map`. rooms_traversed still climbs: the walk
|
|
// happened, and narration cadence rides it.
|
|
//
|
|
// Returns the target's path index.
|
|
func revisitZoneRun(runID, targetNode string, visited []string) (int, error) {
|
|
if _, err := db.Get().Exec(`
|
|
UPDATE dnd_zone_run
|
|
SET current_node = ?,
|
|
rooms_traversed = rooms_traversed + 1,
|
|
last_action_at = CURRENT_TIMESTAMP
|
|
WHERE run_id = ?`, targetNode, runID); err != nil {
|
|
return 0, err
|
|
}
|
|
return pathIndexOf(visited, targetNode), nil
|
|
}
|
|
|
|
// handleRevisitCmd implements `!revisit <N>` (also reachable as
|
|
// `!zone revisit <N>`). N is the 1-indexed room number from `!map`'s Path
|
|
// strip.
|
|
func (p *AdventurePlugin) handleRevisitCmd(ctx MessageContext, rest string) error {
|
|
run, err := getActiveZoneRun(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 cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
|
|
return p.SendDM(ctx.Sender, "⚔️ Finish the fight first — `!attack` or `!flee`.")
|
|
}
|
|
// A pending fork lives at the node the player is standing on, and both
|
|
// `!zone advance` and `!zone go` resolve it without checking where that
|
|
// is. Walking away would leave a prompt pointing at a room the player
|
|
// has left. R3 makes revisit re-open forks properly; until then, commit.
|
|
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
|
|
return p.SendDM(ctx.Sender, "Choose your path first — `!zone go <n>`.")
|
|
}
|
|
|
|
rest = strings.TrimSpace(rest)
|
|
if rest == "" {
|
|
return p.SendDM(ctx.Sender,
|
|
"Revisit which room? Numbers come from the Path strip in `!map` — e.g. `!revisit 2`.")
|
|
}
|
|
n := atoiSafe(rest)
|
|
if n <= 0 || n > len(run.VisitedNodes) {
|
|
return p.SendDM(ctx.Sender, "You haven't been there yet. `!map` shows where you've walked.")
|
|
}
|
|
target := run.VisitedNodes[n-1]
|
|
if target == run.CurrentNode {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf("You're already standing in Room %d.", n))
|
|
}
|
|
|
|
g, ok := loadZoneGraph(run.ZoneID)
|
|
if !ok {
|
|
return p.SendDM(ctx.Sender, "Couldn't read the zone layout.")
|
|
}
|
|
if !adjacentNodes(g, run.CurrentNode)[target] {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Room %d isn't directly connected — backtrack one step at a time.", n))
|
|
}
|
|
|
|
exp, _ := getActiveExpedition(ctx.Sender)
|
|
if exp != nil && !exp.IsActive() {
|
|
return p.SendDM(ctx.Sender, "Your expedition isn't underway.")
|
|
}
|
|
if exp != nil && exp.ThreatLevel >= revisitSiegeGuard {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"⏰ **Too hot to double back** — threat is %d/100. Doubling back would bring the siege down on you. Press on or `!extract`.",
|
|
exp.ThreatLevel))
|
|
}
|
|
|
|
idx, rerr := revisitZoneRun(run.RunID, target, run.VisitedNodes)
|
|
if rerr != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't backtrack: "+rerr.Error())
|
|
}
|
|
markActedToday(ctx.Sender)
|
|
|
|
var b strings.Builder
|
|
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
|
|
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
|
|
}
|
|
roomType := nodeKindToRoomType(g.Nodes[target].Kind)
|
|
b.WriteString(fmt.Sprintf("↩ You retrace your steps to **Room %d** — %s.\n\n",
|
|
idx+1, prettyRoomType(roomType)))
|
|
|
|
if exp != nil {
|
|
// Standalone runs have no threat clock; only bill an expedition.
|
|
_ = applyThreatDelta(exp.ID, revisitThreatCost, "retraced steps")
|
|
grantAutorunGrace(exp.ID)
|
|
b.WriteString(fmt.Sprintf("_Threat +%d — moving back through cleared ground is not quiet._\n\n",
|
|
revisitThreatCost))
|
|
}
|
|
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** %s",
|
|
idx+1, run.TotalRooms, prettyRoomType(roomType), continueHint(ctx.Sender)))
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|
|
|
|
// grantAutorunGrace stamps the autorun CAS column so the background walker
|
|
// does not immediately march the player back out of the room they just
|
|
// walked into. Buys one full autoRunCooldown window.
|
|
//
|
|
// This is a stopgap, not the R5 autopilot guard: autopilot has no on/off
|
|
// switch (it is ticker-driven for every active expedition), so without it a
|
|
// revisit would be undone within the cooldown and the whole feature would
|
|
// read as broken. R5 decides the real policy — refuse to auto-walk from a
|
|
// non-frontier node, or walk back to the frontier first.
|
|
func grantAutorunGrace(expID string) {
|
|
_, _ = db.Get().Exec(`
|
|
UPDATE dnd_expedition
|
|
SET last_autorun_at = CURRENT_TIMESTAMP
|
|
WHERE expedition_id = ?`, expID)
|
|
}
|
|
|
|
// revisitTargets lists the rooms adjacent to the player's position that they
|
|
// have already visited — the legal `!revisit` arguments. Rendered by `!map`
|
|
// so players don't have to guess which numbers are reachable.
|
|
func revisitTargets(g ZoneGraph, run *DungeonRun) []int {
|
|
if run == nil {
|
|
return nil
|
|
}
|
|
adj := adjacentNodes(g, run.CurrentNode)
|
|
var out []int
|
|
for i, node := range run.VisitedNodes {
|
|
if node != run.CurrentNode && adj[node] {
|
|
out = append(out, i+1)
|
|
}
|
|
}
|
|
return out
|
|
}
|