mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Branching zones G9a: remove GOGOBEE_BRANCHING_ZONES gate
All 9 zones have hand-authored graphs (G8a–G8i), so the POC gate has served its purpose. Make graph mode the only runtime path: - Drop branchingZonesEnabled() and the os import in zone_graph_nav.go. - Inline the gate-on branches in zoneCmdMap, zoneCmdAdvance, CurrentRoomType, startZoneRun, and the expedition map renderer. - Keep the legacy linear functions (markRoomCleared, renderZoneMap, generateRoomSequence) standing — their tests still exercise them and they'll retire alongside current_room / room_seq_json in G9b. - Strip gate test (TestBranchingZonesGate, TestCurrentRoomType_GateOff) and t.Setenv calls from the surviving graph tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,19 +113,24 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
|
||||
// Per-run room map. The expedition's zone run is the per-region
|
||||
// dungeon instance; for single-region zones it's the only run.
|
||||
run, _ := getActiveZoneRun(ctx.Sender)
|
||||
if run != nil && len(run.RoomSeq) > 0 {
|
||||
cleared := map[int]bool{}
|
||||
for _, r := range run.RoomsCleared {
|
||||
cleared[r] = true
|
||||
}
|
||||
b.WriteString("```\n")
|
||||
b.WriteString(renderZoneMap(run.RoomSeq, run.CurrentRoom, cleared))
|
||||
b.WriteString("\n```\n")
|
||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss · ✓ cleared ▶ here · pending_")
|
||||
if chain != "" {
|
||||
b.WriteString("\n_Regions: ")
|
||||
b.WriteString(renderRegionLegend(exp))
|
||||
b.WriteString(" · ⛺ base camp pitched_")
|
||||
if run != nil {
|
||||
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
||||
b.WriteString("```\n")
|
||||
b.WriteString(renderZoneGraphMap(g, run))
|
||||
b.WriteString("\n```\n")
|
||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_")
|
||||
if chain != "" {
|
||||
b.WriteString("\n_Regions: ")
|
||||
b.WriteString(renderRegionLegend(exp))
|
||||
b.WriteString(" · ⛺ base camp pitched_")
|
||||
}
|
||||
} else {
|
||||
b.WriteString("_(no room layout — between rooms or pre-entry)_")
|
||||
if chain != "" {
|
||||
b.WriteString("\n_Regions: ")
|
||||
b.WriteString(renderRegionLegend(exp))
|
||||
b.WriteString(" · ⛺ base camp pitched_")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b.WriteString("_(no room layout — between rooms or pre-entry)_")
|
||||
|
||||
@@ -288,14 +288,13 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("**%s — Map**\n```\n", zone.Display))
|
||||
if branchingZonesEnabled() {
|
||||
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
||||
b.WriteString(renderZoneGraphMap(g, run))
|
||||
b.WriteString("\n```\n")
|
||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_")
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
||||
b.WriteString(renderZoneGraphMap(g, run))
|
||||
b.WriteString("\n```\n")
|
||||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_")
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
// No registered graph (defensive — every zone has one post-G8).
|
||||
cleared := map[int]bool{}
|
||||
for _, r := range run.RoomsCleared {
|
||||
cleared[r] = true
|
||||
@@ -415,60 +414,18 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, outcome)
|
||||
}
|
||||
|
||||
// Graph-mode (POC gate): clear the current room, then either auto-advance
|
||||
// down a single edge, surface a fork prompt for 2+ edges, or complete the
|
||||
// run when there are 0 outgoing edges. Legacy linear path stays on
|
||||
// markRoomCleared so non-gated deployments are bit-identical to G4.
|
||||
if branchingZonesEnabled() {
|
||||
c, cerr := LoadDnDCharacter(ctx.Sender)
|
||||
if cerr != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load character: "+cerr.Error())
|
||||
}
|
||||
next, forkMsg, complete, gerr := p.advanceTransitionGraph(run, zone, c)
|
||||
if gerr != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't advance: "+gerr.Error())
|
||||
}
|
||||
if complete {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
||||
var b strings.Builder
|
||||
if outcome != "" {
|
||||
b.WriteString(outcome)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
|
||||
if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 {
|
||||
b.WriteString("**Loot:**\n")
|
||||
for _, id := range granted {
|
||||
b.WriteString("• " + id + "\n")
|
||||
}
|
||||
}
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
|
||||
}
|
||||
if forkMsg != "" {
|
||||
var b strings.Builder
|
||||
if outcome != "" {
|
||||
b.WriteString(outcome)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
||||
b.WriteString(forkMsg)
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
|
||||
}
|
||||
// Single-edge auto-advance — fall through to the shared next-room
|
||||
// teaser using the resolved next room type.
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases,
|
||||
p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next))
|
||||
// Graph-mode advance (G9a — only mode now): clear the current room,
|
||||
// then either auto-advance down a single edge, surface a fork prompt
|
||||
// for 2+ edges, or complete the run when there are 0 outgoing edges.
|
||||
c, cerr := LoadDnDCharacter(ctx.Sender)
|
||||
if cerr != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load character: "+cerr.Error())
|
||||
}
|
||||
|
||||
next, err := markRoomCleared(run.RunID)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
|
||||
next, forkMsg, complete, gerr := p.advanceTransitionGraph(run, zone, c)
|
||||
if gerr != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't advance: "+gerr.Error())
|
||||
}
|
||||
if next == "" {
|
||||
if complete {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
||||
var b strings.Builder
|
||||
if outcome != "" {
|
||||
@@ -488,7 +445,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
}
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
|
||||
}
|
||||
|
||||
if forkMsg != "" {
|
||||
var b strings.Builder
|
||||
if outcome != "" {
|
||||
b.WriteString(outcome)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
||||
b.WriteString(forkMsg)
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
|
||||
}
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases,
|
||||
p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next))
|
||||
}
|
||||
|
||||
@@ -85,21 +85,17 @@ func (r *DungeonRun) IsActive() bool {
|
||||
}
|
||||
|
||||
// CurrentRoomType returns the type of the room the player is currently
|
||||
// standing in. In gated graph mode (GOGOBEE_BRANCHING_ZONES=1) with a
|
||||
// hand-authored ZoneGraph registered for the run's zone, the live
|
||||
// CurrentNode's kind is authoritative — that's the only way side paths
|
||||
// (e.g. the Crypt of Valdris secret chamber) resolve to the right
|
||||
// room-type when the player diverges from the canonical RoomSeq. The
|
||||
// legacy RoomSeq fallback covers gate-off runs and runs in zones whose
|
||||
// graph is still the linear-compiled one. Returns "" if no resolution
|
||||
// is possible.
|
||||
// standing in. The live CurrentNode's kind is authoritative — that's
|
||||
// the only way side paths (e.g. the Crypt of Valdris secret chamber)
|
||||
// resolve to the right room-type when the player diverges from the
|
||||
// canonical RoomSeq. The legacy RoomSeq fallback covers in-flight runs
|
||||
// from before the G4 dual-write deploy that lack a CurrentNode entry.
|
||||
// Returns "" if no resolution is possible.
|
||||
func (r *DungeonRun) CurrentRoomType() RoomType {
|
||||
if branchingZonesEnabled() && r.CurrentNode != "" {
|
||||
if _, authored := zoneGraphRegistry[r.ZoneID]; authored {
|
||||
if g, ok := loadZoneGraph(r.ZoneID); ok {
|
||||
if n, exists := g.Nodes[r.CurrentNode]; exists {
|
||||
return nodeKindToRoomType(n.Kind)
|
||||
}
|
||||
if r.CurrentNode != "" {
|
||||
if g, ok := loadZoneGraph(r.ZoneID); ok {
|
||||
if n, exists := g.Nodes[r.CurrentNode]; exists {
|
||||
return nodeKindToRoomType(n.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,15 +224,12 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
}
|
||||
// G4 dual-write: persist the entry node id and seed visited_nodes
|
||||
// with it, so navigation surfaces in G5 can read graph state without
|
||||
// further migration. G7: when graph mode is on AND the zone has a
|
||||
// hand-authored graph, start at that graph's Entry node so the player
|
||||
// actually traverses the authored topology rather than falling off
|
||||
// into the legacy `<zone>.r1` namespace.
|
||||
// further migration. New runs always start at the registered graph's
|
||||
// Entry node; only zones that lack a registered graph (none, post-G8)
|
||||
// would fall back to the legacy linear `<zone>.r1` namespace.
|
||||
entryNode := deriveLegacyNodeID(zoneID, 0)
|
||||
if branchingZonesEnabled() {
|
||||
if g, ok := zoneGraphRegistry[zoneID]; ok {
|
||||
entryNode = g.Entry
|
||||
}
|
||||
if g, ok := zoneGraphRegistry[zoneID]; ok {
|
||||
entryNode = g.Entry
|
||||
}
|
||||
visitedJSON, _ := json.Marshal([]string{entryNode})
|
||||
run.CurrentNode = entryNode
|
||||
|
||||
@@ -14,9 +14,6 @@ package plugin
|
||||
//
|
||||
// Two paths to the boss (main_hall vs side_chapel); secret_chamber
|
||||
// dangles off side_chapel for an extra Perception gate with loot.
|
||||
// Registration is unconditional — the runtime gate
|
||||
// (GOGOBEE_BRANCHING_ZONES) decides whether new runs adopt this graph
|
||||
// or stay on the legacy linear template.
|
||||
|
||||
func zoneCryptValdrisGraph() ZoneGraph {
|
||||
nodes := []ZoneNode{
|
||||
|
||||
@@ -87,14 +87,12 @@ func TestCryptValdrisGraph_SecretGated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentRoomType_GraphAuthored verifies that in gated graph mode,
|
||||
// CurrentRoomType resolves via the registered graph's node kind rather
|
||||
// than the legacy RoomSeq lookup. This is what makes side_path nodes
|
||||
// (e.g. the secret_chamber) resolve to the right RoomType when the
|
||||
// player diverges from the canonical RoomSeq layout.
|
||||
// TestCurrentRoomType_GraphAuthored verifies that CurrentRoomType
|
||||
// resolves via the registered graph's node kind rather than the legacy
|
||||
// RoomSeq lookup. This is what makes side_path nodes (e.g. the
|
||||
// secret_chamber) resolve to the right RoomType when the player
|
||||
// diverges from the canonical RoomSeq layout.
|
||||
func TestCurrentRoomType_GraphAuthored(t *testing.T) {
|
||||
t.Setenv("GOGOBEE_BRANCHING_ZONES", "1")
|
||||
|
||||
// Mismatched RoomSeq vs. live CurrentNode: CurrentRoom=2 would land
|
||||
// on RoomTrap in the legacy fallback, but the graph node kind is
|
||||
// Elite (main_hall). Graph wins.
|
||||
@@ -121,18 +119,3 @@ func TestCurrentRoomType_GraphAuthored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentRoomType_GateOffUsesRoomSeq asserts the legacy behavior is
|
||||
// untouched when the gate is off: RoomSeq[CurrentRoom] is authoritative
|
||||
// even though Crypt of Valdris has a registered graph.
|
||||
func TestCurrentRoomType_GateOffUsesRoomSeq(t *testing.T) {
|
||||
t.Setenv("GOGOBEE_BRANCHING_ZONES", "")
|
||||
run := &DungeonRun{
|
||||
ZoneID: ZoneCryptValdris,
|
||||
CurrentRoom: 2,
|
||||
CurrentNode: "crypt_valdris.main_hall",
|
||||
RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss},
|
||||
}
|
||||
if got := run.CurrentRoomType(); got != RoomTrap {
|
||||
t.Errorf("gate off: got %s, want %s (RoomSeq lookup)", got, RoomTrap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package plugin
|
||||
|
||||
// Phase G6 — graph-aware !zone map renderer.
|
||||
//
|
||||
// Replaces the linear renderZoneMap() output when GOGOBEE_BRANCHING_ZONES
|
||||
// is on. Walks the registered (or legacy-compiled) ZoneGraph in BFS
|
||||
// order rooted at Entry, lays nodes out by PosX/PosY, and prints a
|
||||
// horizontal glyph row per layer with a status row underneath. Locked
|
||||
// outgoing edges show as `╳`; secret nodes stay hidden until the
|
||||
// player has visited them (prevents spoilers from `!zone map`).
|
||||
// Replaces the linear renderZoneMap() output. Walks the registered
|
||||
// (or legacy-compiled) ZoneGraph in BFS order rooted at Entry, lays
|
||||
// nodes out by PosX/PosY, and prints a horizontal glyph row per layer
|
||||
// with a status row underneath. Locked outgoing edges show as `╳`;
|
||||
// secret nodes stay hidden until the player has visited them
|
||||
// (prevents spoilers from `!zone map`).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
@@ -8,28 +8,19 @@ package plugin
|
||||
// consumes the choice, validates the chosen edge is unlocked, and
|
||||
// advances the run state to the chosen node.
|
||||
//
|
||||
// Behavior is gated by GOGOBEE_BRANCHING_ZONES=1 during the POC week
|
||||
// (plan §G5 / §9.5). With the gate off, !zone advance still uses the
|
||||
// linear markRoomCleared path and forks never fire — even on zones
|
||||
// that have a hand-authored graph registered.
|
||||
// G9a retired the GOGOBEE_BRANCHING_ZONES POC gate: graph mode is the
|
||||
// only runtime path now that all 9 zones have hand-authored graphs.
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// branchingZonesEnabled — POC gate. Default off. Operator flips it for
|
||||
// the POC soak week, then it's removed in G9.
|
||||
func branchingZonesEnabled() bool {
|
||||
return os.Getenv("GOGOBEE_BRANCHING_ZONES") == "1"
|
||||
}
|
||||
|
||||
// pendingFork is the typed shape of dnd_zone_run.node_choices when the
|
||||
// player is paused at a fork. Persisted as JSON inside the
|
||||
// map[string]any NodeChoices column; helpers below round-trip via JSON
|
||||
|
||||
@@ -241,17 +241,3 @@ func TestNodeKindToRoomType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchingZonesGate(t *testing.T) {
|
||||
t.Setenv("GOGOBEE_BRANCHING_ZONES", "")
|
||||
if branchingZonesEnabled() {
|
||||
t.Error("gate should be off when env empty")
|
||||
}
|
||||
t.Setenv("GOGOBEE_BRANCHING_ZONES", "1")
|
||||
if !branchingZonesEnabled() {
|
||||
t.Error("gate should be on when env=1")
|
||||
}
|
||||
t.Setenv("GOGOBEE_BRANCHING_ZONES", "0")
|
||||
if branchingZonesEnabled() {
|
||||
t.Error("gate should be off when env=0")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user