mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
187
internal/plugin/zone_revisit.go
Normal file
187
internal/plugin/zone_revisit.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
261
internal/plugin/zone_revisit_r2_test.go
Normal file
261
internal/plugin/zone_revisit_r2_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Revisit R2 (gogobee_revisit_plan.md §R2) — `!revisit <N>` 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user