diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index da45697..774966b 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -342,6 +342,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "zone") { return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone")) } + if p.IsCommand(ctx.Body, "revisit") { + return p.handleRevisitCmd(ctx, p.GetArgs(ctx.Body, "revisit")) + } if p.IsCommand(ctx.Body, "fight") { return p.handleFightCmd(ctx) } diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 5d8eda3..869d6e3 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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 ` — at a fork, take path #n\n") + b.WriteString("`!revisit ` — 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 diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index d19c061..0e5926f 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -113,6 +113,18 @@ func (r *DungeonRun) CurrentRoomType() RoomType { return r.RoomSeq[r.CurrentRoom] } +// RoomIsCleared reports whether the player has already resolved the room at +// the given path index. Sticky: once cleared, a room stays cleared for the +// life of the run, however many times the player walks back through it. +func (r *DungeonRun) RoomIsCleared(idx int) bool { + for _, c := range r.RoomsCleared { + if c == idx { + return true + } + } + return false +} + // generateRoomSequence builds the deterministic-but-seeded room layout // for a run of the given zone. The boss room is always last; one Entry // is always first; one Trap and one Elite room sit between explorations. diff --git a/internal/plugin/zone_revisit.go b/internal/plugin/zone_revisit.go new file mode 100644 index 0000000..51f5f58 --- /dev/null +++ b/internal/plugin/zone_revisit.go @@ -0,0 +1,187 @@ +package plugin + +import ( + "fmt" + "strings" + + "gogobee/internal/db" +) + +// Revisit R2 (gogobee_revisit_plan.md §R2) — `!revisit ` 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 ` (also reachable as +// `!zone revisit `). 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 `.") + } + 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 `.") + } + + 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 +} diff --git a/internal/plugin/zone_revisit_r2_test.go b/internal/plugin/zone_revisit_r2_test.go new file mode 100644 index 0000000..3496ef8 --- /dev/null +++ b/internal/plugin/zone_revisit_r2_test.go @@ -0,0 +1,261 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Revisit R2 (gogobee_revisit_plan.md §R2) — `!revisit ` backtracking. + +// ── adjacency ─────────────────────────────────────────────────────────────── + +func testGraph() ZoneGraph { + // a → b → c, plus a side branch b → d. Directed, as all zone graphs are. + return ZoneGraph{ + Entry: "a", + Nodes: map[string]ZoneNode{ + "a": {NodeID: "a", Kind: NodeKindEntry}, + "b": {NodeID: "b", Kind: NodeKindExploration}, + "c": {NodeID: "c", Kind: NodeKindExploration}, + "d": {NodeID: "d", Kind: NodeKindHarvest}, + }, + Edges: map[string][]ZoneEdge{ + "a": {{From: "a", To: "b"}}, + "b": {{From: "b", To: "c"}, {From: "b", To: "d"}}, + }, + } +} + +func TestAdjacentNodes_TraversesEdgesInBothDirections(t *testing.T) { + g := testGraph() + // From b: forward to c and d, backward to a. A corridor you walked down + // is a corridor you can walk back up. + adj := adjacentNodes(g, "b") + for _, want := range []string{"a", "c", "d"} { + if !adj[want] { + t.Errorf("adjacentNodes(b) missing %q: %v", want, adj) + } + } + if len(adj) != 3 { + t.Errorf("adjacentNodes(b) = %v, want exactly {a,c,d}", adj) + } + // c is a leaf: only the reverse edge from b. + if adj := adjacentNodes(g, "c"); len(adj) != 1 || !adj["b"] { + t.Errorf("adjacentNodes(c) = %v, want {b}", adj) + } +} + +func TestAdjacentNodes_NonAdjacentIsNotReachable(t *testing.T) { + // a and c are two edges apart — revisit is one step at a time. + if adjacentNodes(testGraph(), "a")["c"] { + t.Error("a is adjacent to c; want not adjacent") + } +} + +func TestRevisitTargets_OnlyVisitedNeighbours(t *testing.T) { + g := testGraph() + // Walked a → b, standing in b. d is adjacent but never visited; c same. + run := &DungeonRun{VisitedNodes: []string{"a", "b"}, CurrentNode: "b"} + got := revisitTargets(g, run) + if len(got) != 1 || got[0] != 1 { + t.Errorf("revisitTargets = %v, want [1] (room 1 = node a)", got) + } + // Standing in a with nothing behind: no targets. + atEntry := &DungeonRun{VisitedNodes: []string{"a"}, CurrentNode: "a"} + if got := revisitTargets(g, atEntry); len(got) != 0 { + t.Errorf("revisitTargets at entry = %v, want none", got) + } +} + +// ── the cost, and the guard on it ─────────────────────────────────────────── + +func TestRevisitThreatCost_IsOneAndGuardsSiege(t *testing.T) { + // The plan specced a discount on a cost that doesn't exist; +1 is the + // net-new charge. If someone retunes it, the siege guard must follow. + if revisitThreatCost != 1 { + t.Errorf("revisitThreatCost = %d, want 1", revisitThreatCost) + } + if revisitSiegeGuard+revisitThreatCost != 100 { + t.Errorf("siege guard %d + cost %d != 100 — a revisit could trip siege", + revisitSiegeGuard, revisitThreatCost) + } +} + +// ── the state move ────────────────────────────────────────────────────────── + +func TestRevisitZoneRun_MovesBackWithoutRenumbering(t *testing.T) { + setupRevisitTestDB(t) + uid := id.UserID("@r2-move:example") + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + for i := 0; i < 2; i++ { + if _, err := markRoomCleared(run.RunID); err != nil { + t.Fatalf("markRoomCleared: %v", err) + } + } + fwd, _ := getZoneRun(run.RunID) + if fwd.CurrentRoom != 2 || fwd.RoomsTraversed != 3 { + t.Fatalf("setup: room=%d traversed=%d, want 2/3", fwd.CurrentRoom, fwd.RoomsTraversed) + } + + target := fwd.VisitedNodes[1] // room 2 + idx, err := revisitZoneRun(run.RunID, target, fwd.VisitedNodes) + if err != nil { + t.Fatalf("revisitZoneRun: %v", err) + } + if idx != 1 { + t.Errorf("revisit returned index %d, want 1", idx) + } + + back, _ := getZoneRun(run.RunID) + if back.CurrentNode != target { + t.Errorf("CurrentNode = %q, want %q", back.CurrentNode, target) + } + if back.CurrentRoom != 1 { + t.Errorf("CurrentRoom = %d, want 1 — the room keeps its original number", back.CurrentRoom) + } + if len(back.VisitedNodes) != 3 { + t.Errorf("VisitedNodes grew on revisit: %v", back.VisitedNodes) + } + // Effort climbs even though position fell back. This is the R1 split. + if back.RoomsTraversed != 4 { + t.Errorf("RoomsTraversed = %d, want 4 (the walk happened)", back.RoomsTraversed) + } +} + +// ── the exploit the cleared-room gate closes ──────────────────────────────── + +func TestRoomIsCleared_GatesReResolution(t *testing.T) { + run := &DungeonRun{RoomsCleared: []int{0, 1, 2}} + for _, idx := range []int{0, 1, 2} { + if !run.RoomIsCleared(idx) { + t.Errorf("room %d should read as cleared", idx) + } + } + if run.RoomIsCleared(3) { + t.Error("uncleared room 3 read as cleared") + } +} + +// Walking back into a cleared room and advancing out of it must not re-roll +// combat or re-arm the trap. Pre-R2 resolveRoom dispatched unconditionally +// for Exploration/Trap — only Elite/Boss were gated (by their combat +// session), so this was a farm: step back one room, advance, repeat. +func TestRevisitedRoom_DoesNotReResolve(t *testing.T) { + setupRevisitTestDB(t) + uid := id.UserID("@r2-noexploit:example") + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + // Clear two rooms, then stand back in room 1 as a revisit would leave us. + for i := 0; i < 2; i++ { + if _, err := markRoomCleared(run.RunID); err != nil { + t.Fatalf("markRoomCleared: %v", err) + } + } + fwd, _ := getZoneRun(run.RunID) + if _, err := revisitZoneRun(run.RunID, fwd.VisitedNodes[1], fwd.VisitedNodes); err != nil { + t.Fatalf("revisitZoneRun: %v", err) + } + back, _ := getZoneRun(run.RunID) + + if !back.RoomIsCleared(back.CurrentRoom) { + t.Fatalf("revisited room %d is not marked cleared — the gate won't fire", back.CurrentRoom) + } + p := &AdventurePlugin{} + zone := zoneOrFallback(back.ZoneID) + intro, phases, outcome, ended, err := p.resolveRoom(uid, back, zone, true) + if err != nil { + t.Fatalf("resolveRoom: %v", err) + } + if intro != "" || phases != nil || outcome != "" || ended { + t.Errorf("cleared room re-resolved: intro=%q phases=%v outcome=%q ended=%v", + intro, phases, outcome, ended) + } +} + +// Forward-only runs must still resolve their rooms — the gate is a no-op +// until someone backtracks. A regression here silently empties every zone. +func TestFreshRoom_StillResolves(t *testing.T) { + setupRevisitTestDB(t) + uid := id.UserID("@r2-fresh:example") + + if err := createAdvCharacter(uid, "r2fresh"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if err := SaveDnDCharacter(&DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, + STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10, + HPMax: 60, HPCurrent: 60, ArmorClass: 18, + }); err != nil { + t.Fatalf("SaveDnDCharacter: %v", err) + } + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + // Advance off the entry room (entry resolves to nothing by design) so we + // land on a fresh, uncleared exploration room. + if _, err := markRoomCleared(run.RunID); err != nil { + t.Fatalf("markRoomCleared: %v", err) + } + cur, _ := getZoneRun(run.RunID) + if cur.RoomIsCleared(cur.CurrentRoom) { + t.Fatalf("room %d cleared straight after arriving", cur.CurrentRoom) + } + if cur.CurrentRoomType() != RoomExploration { + t.Skipf("room 2 is %s, not exploration — nothing to assert", cur.CurrentRoomType()) + } + p := &AdventurePlugin{} + zone := zoneOrFallback(cur.ZoneID) + // compact mode collapses a won fight into a single outcome line and leaves + // phases nil, so outcome is the signal that combat actually ran. + intro, phases, outcome, _, err := p.resolveRoom(uid, cur, zone, true) + if err != nil { + t.Fatalf("resolveRoom: %v", err) + } + if intro == "" && phases == nil && outcome == "" { + t.Error("fresh exploration room resolved to nothing — the gate is over-firing") + } +} + +// ── autopilot grace ───────────────────────────────────────────────────────── + +// Autopilot is ticker-driven with no on/off switch, so without a grace stamp +// the background walker marches the player straight back out of the room they +// revisited and the feature reads as broken. +func TestGrantAutorunGrace_StampsCooldown(t *testing.T) { + setupRevisitTestDB(t) + uid := id.UserID("@r2-grace:example") + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, makeSupplies(ZoneTier(1), SupplyPurchase{StandardPacks: 3})) + if err != nil { + t.Skipf("startExpedition unavailable in this harness: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_autorun_at = NULL WHERE expedition_id = ?`, exp.ID); err != nil { + t.Fatalf("clear stamp: %v", err) + } + grantAutorunGrace(exp.ID) + + var stamped int + if err := db.Get().QueryRow( + `SELECT COUNT(*) FROM dnd_expedition WHERE expedition_id = ? AND last_autorun_at IS NOT NULL`, + exp.ID).Scan(&stamped); err != nil { + t.Fatalf("read stamp: %v", err) + } + if stamped != 1 { + t.Error("grantAutorunGrace did not stamp last_autorun_at") + } +}