mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Branching zones G7: Crypt of Valdris POC graph
Hand-authored zoneCryptValdrisGraph() (8 nodes incl. fork + Perception DC 15 secret reliquary) self-registers at init. Two surgical bridges so new runs traverse the authored graph when GOGOBEE_BRANCHING_ZONES=1: startZoneRun seeds current_node from g.Entry, and DungeonRun.CurrentRoomType resolves via the live node's kind so divergent paths (secret_chamber) dispatch through the right resolver. Gate off is bit-identical to pre-G7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,8 +85,24 @@ func (r *DungeonRun) IsActive() bool {
|
||||
}
|
||||
|
||||
// CurrentRoomType returns the type of the room the player is currently
|
||||
// standing in (CurrentRoom is 0-indexed). Returns "" if out of range.
|
||||
// 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.
|
||||
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.CurrentRoom < 0 || r.CurrentRoom >= len(r.RoomSeq) {
|
||||
return ""
|
||||
}
|
||||
@@ -212,8 +228,16 @@ 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.
|
||||
// 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.
|
||||
entryNode := deriveLegacyNodeID(zoneID, 0)
|
||||
if branchingZonesEnabled() {
|
||||
if g, ok := zoneGraphRegistry[zoneID]; ok {
|
||||
entryNode = g.Entry
|
||||
}
|
||||
}
|
||||
visitedJSON, _ := json.Marshal([]string{entryNode})
|
||||
run.CurrentNode = entryNode
|
||||
run.VisitedNodes = []string{entryNode}
|
||||
|
||||
61
internal/plugin/zone_graph_crypt_valdris.go
Normal file
61
internal/plugin/zone_graph_crypt_valdris.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package plugin
|
||||
|
||||
// Phase G7 — Crypt of Valdris POC graph.
|
||||
//
|
||||
// First hand-authored branching graph. Topology per
|
||||
// gogobee_branching_zones_plan.md §3:
|
||||
//
|
||||
// entry → corridor → fork
|
||||
// ├──[unlocked]── main_hall (elite) ──┐
|
||||
// │ ├── antechamber → boss
|
||||
// └──[Perception DC 12]── side_chapel ─┘
|
||||
// │
|
||||
// └──[Perception DC 15]── secret_chamber → antechamber
|
||||
//
|
||||
// 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{
|
||||
{NodeID: "crypt_valdris.entry", Kind: NodeKindEntry, IsEntry: true,
|
||||
Label: "Crypt Threshold", PosX: 0, PosY: 1},
|
||||
{NodeID: "crypt_valdris.corridor", Kind: NodeKindExploration,
|
||||
Label: "Bone Corridor", PosX: 1, PosY: 1},
|
||||
{NodeID: "crypt_valdris.fork", Kind: NodeKindFork,
|
||||
Label: "Branching Passage", PosX: 2, PosY: 1},
|
||||
{NodeID: "crypt_valdris.main_hall", Kind: NodeKindElite,
|
||||
Label: "Main Hall", PosX: 3, PosY: 0},
|
||||
{NodeID: "crypt_valdris.side_chapel", Kind: NodeKindExploration,
|
||||
Label: "Side Chapel", PosX: 3, PosY: 2},
|
||||
{NodeID: "crypt_valdris.secret_chamber", Kind: NodeKindSecret,
|
||||
Label: "Hidden Reliquary", PosX: 4, PosY: 3,
|
||||
Content: ZoneNodeContent{LootBias: 2.0}},
|
||||
{NodeID: "crypt_valdris.antechamber", Kind: NodeKindMerge,
|
||||
Label: "Antechamber", PosX: 5, PosY: 1},
|
||||
{NodeID: "crypt_valdris.boss", Kind: NodeKindBoss, IsBoss: true,
|
||||
Label: "Valdris's Sanctum", PosX: 6, PosY: 1},
|
||||
}
|
||||
edges := []ZoneEdge{
|
||||
{From: "crypt_valdris.entry", To: "crypt_valdris.corridor", Lock: LockNone},
|
||||
{From: "crypt_valdris.corridor", To: "crypt_valdris.fork", Lock: LockNone},
|
||||
{From: "crypt_valdris.fork", To: "crypt_valdris.main_hall", Lock: LockNone, Weight: 1},
|
||||
{From: "crypt_valdris.fork", To: "crypt_valdris.side_chapel",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 12},
|
||||
Hint: "a faint draft from a crack in the wall", Weight: 2},
|
||||
{From: "crypt_valdris.main_hall", To: "crypt_valdris.antechamber", Lock: LockNone},
|
||||
{From: "crypt_valdris.side_chapel", To: "crypt_valdris.antechamber", Lock: LockNone, Weight: 1},
|
||||
{From: "crypt_valdris.side_chapel", To: "crypt_valdris.secret_chamber",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 15},
|
||||
Hint: "a faint scratching behind the altar", Weight: 2},
|
||||
{From: "crypt_valdris.secret_chamber", To: "crypt_valdris.antechamber", Lock: LockNone},
|
||||
{From: "crypt_valdris.antechamber", To: "crypt_valdris.boss", Lock: LockNone},
|
||||
}
|
||||
return BuildGraph(ZoneCryptValdris, nodes, edges)
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerZoneGraph(zoneCryptValdrisGraph())
|
||||
}
|
||||
138
internal/plugin/zone_graph_crypt_valdris_test.go
Normal file
138
internal/plugin/zone_graph_crypt_valdris_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCryptValdrisGraph_Registered verifies the POC graph is in the
|
||||
// registry (init-time registration) so loadZoneGraph returns it instead
|
||||
// of the legacy linear compile.
|
||||
func TestCryptValdrisGraph_Registered(t *testing.T) {
|
||||
g, ok := zoneGraphRegistry[ZoneCryptValdris]
|
||||
if !ok {
|
||||
t.Fatal("zoneCryptValdrisGraph not registered")
|
||||
}
|
||||
if g.Entry != "crypt_valdris.entry" {
|
||||
t.Errorf("entry node = %q, want crypt_valdris.entry", g.Entry)
|
||||
}
|
||||
if g.Boss != "crypt_valdris.boss" {
|
||||
t.Errorf("boss node = %q, want crypt_valdris.boss", g.Boss)
|
||||
}
|
||||
if len(g.Nodes) != 8 {
|
||||
t.Errorf("nodes = %d, want 8", len(g.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCryptValdrisGraph_BothPathsReachBoss confirms that both the
|
||||
// main_hall (elite) path and the side_chapel (perception-gated) path
|
||||
// can reach the boss. Validates G7 design intent: no path is a
|
||||
// dead-end.
|
||||
func TestCryptValdrisGraph_BothPathsReachBoss(t *testing.T) {
|
||||
g := zoneCryptValdrisGraph()
|
||||
if !reachable(g, "crypt_valdris.main_hall", "crypt_valdris.boss") {
|
||||
t.Error("main_hall path unreachable to boss")
|
||||
}
|
||||
if !reachable(g, "crypt_valdris.side_chapel", "crypt_valdris.boss") {
|
||||
t.Error("side_chapel path unreachable to boss")
|
||||
}
|
||||
if !reachable(g, "crypt_valdris.secret_chamber", "crypt_valdris.boss") {
|
||||
t.Error("secret_chamber path unreachable to boss")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCryptValdrisGraph_ForkLayout asserts the fork has both options
|
||||
// and that side_chapel is the locked / hinted one.
|
||||
func TestCryptValdrisGraph_ForkLayout(t *testing.T) {
|
||||
g := zoneCryptValdrisGraph()
|
||||
outs := g.outgoingEdges("crypt_valdris.fork")
|
||||
if len(outs) != 2 {
|
||||
t.Fatalf("fork outgoing edges = %d, want 2", len(outs))
|
||||
}
|
||||
// outgoingEdges sorts by weight asc — main_hall (weight 1) first.
|
||||
if outs[0].To != "crypt_valdris.main_hall" || outs[0].Lock != LockNone {
|
||||
t.Errorf("first edge = %+v, want main_hall/LockNone", outs[0])
|
||||
}
|
||||
if outs[1].To != "crypt_valdris.side_chapel" || outs[1].Lock != LockPerception {
|
||||
t.Errorf("second edge = %+v, want side_chapel/LockPerception", outs[1])
|
||||
}
|
||||
if outs[1].Hint == "" {
|
||||
t.Error("side_chapel edge missing player-facing hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCryptValdrisGraph_SecretGated verifies secret_chamber sits behind
|
||||
// a higher-DC perception lock and carries a loot bias.
|
||||
func TestCryptValdrisGraph_SecretGated(t *testing.T) {
|
||||
g := zoneCryptValdrisGraph()
|
||||
outs := g.outgoingEdges("crypt_valdris.side_chapel")
|
||||
var secretEdge *ZoneEdge
|
||||
for i := range outs {
|
||||
if outs[i].To == "crypt_valdris.secret_chamber" {
|
||||
secretEdge = &outs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if secretEdge == nil {
|
||||
t.Fatal("no edge from side_chapel to secret_chamber")
|
||||
}
|
||||
if secretEdge.Lock != LockPerception {
|
||||
t.Errorf("secret edge lock = %s, want perception", secretEdge.Lock)
|
||||
}
|
||||
if dc := lockDataInt(secretEdge.LockData, "dc", 0); dc < 15 {
|
||||
t.Errorf("secret DC = %d, want >= 15", dc)
|
||||
}
|
||||
secret := g.Nodes["crypt_valdris.secret_chamber"]
|
||||
if secret.Content.LootBias < 1.5 {
|
||||
t.Errorf("secret LootBias = %v, want >= 1.5 (cruel without loot)", secret.Content.LootBias)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.
|
||||
run := &DungeonRun{
|
||||
ZoneID: ZoneCryptValdris,
|
||||
CurrentRoom: 2,
|
||||
CurrentNode: "crypt_valdris.main_hall",
|
||||
RoomSeq: []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss},
|
||||
}
|
||||
if got := run.CurrentRoomType(); got != RoomElite {
|
||||
t.Errorf("graph mode + main_hall: got %s, want %s", got, RoomElite)
|
||||
}
|
||||
|
||||
run.CurrentNode = "crypt_valdris.boss"
|
||||
if got := run.CurrentRoomType(); got != RoomBoss {
|
||||
t.Errorf("graph mode + boss node: got %s, want %s", got, RoomBoss)
|
||||
}
|
||||
|
||||
run.CurrentNode = "crypt_valdris.secret_chamber"
|
||||
// Secret nodes flatten to RoomExploration for legacy resolveRoom
|
||||
// dispatch (per nodeKindToRoomType — no separate Secret RoomType yet).
|
||||
if got := run.CurrentRoomType(); got != RoomExploration {
|
||||
t.Errorf("graph mode + secret node: got %s, want %s", got, RoomExploration)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user