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.
262 lines
9.3 KiB
Go
262 lines
9.3 KiB
Go
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")
|
|
}
|
|
}
|