aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md5
-rw-r--r--data/rooms/intro/1005.yaml2
-rw-r--r--data/rooms/intro/1009.yaml4
-rw-r--r--data/rooms/intro/1016.yaml21
-rw-r--r--data/rooms/intro/1017.yaml15
-rw-r--r--data/rooms/intro/1018.yaml15
-rw-r--r--internal/game/cmd_dig.go55
-rw-r--r--internal/game/cmd_room_insert.go56
-rw-r--r--internal/game/core_command_queue.go (renamed from internal/game/command_queue.go)0
-rw-r--r--internal/game/core_flagstore.go (renamed from internal/game/flagstore.go)0
-rw-r--r--internal/game/core_object_def.go (renamed from internal/game/object_def.go)0
-rw-r--r--internal/game/core_validate.go (renamed from internal/game/validate_source.go)0
-rw-r--r--internal/game/map_test.go69
-rw-r--r--internal/game/production_index.go (renamed from internal/game/craft_index.go)0
-rw-r--r--internal/game/render_map.go339
-rw-r--r--internal/game/sys_safespot_state.go (renamed from internal/game/safespot_manager.go)0
-rw-r--r--internal/game/sys_triggers.go (renamed from internal/game/triggers.go)0
-rw-r--r--internal/validate/checks.go102
-rw-r--r--internal/validate/grid_test.go61
-rw-r--r--internal/world/grid.go95
-rw-r--r--internal/world/room.go20
21 files changed, 456 insertions, 403 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 4f547ab..789b361 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -165,3 +165,8 @@ Full field reference in `item/item.go`. Key fields: equip_slot, weapon_type, att
- **Tick subscriber ordering**: `AdvanceActions`, combat subscriptions, mob attack subscriptions,
and hazard ticks are independent subscribers without priority guarantees. A trigger completing
and a mob landing a hit on the same tick have non-deterministic ordering.
+- **3D map BFS cost**: `buildGraph()` does a full 3D BFS across the entire reachable world on every
+ map render (`map`/`look`/move). For large worlds (10k+ rooms) this could become expensive. Options:
+ (a) cache the graph keyed by player room and invalidate on world edits (`dig`/`room insert`/`undig`),
+ (b) prune by visited rooms (revert to horizontal-only expansion from unvisited), or (c) compute z-level
+ assignments once at startup and use precomputed plane membership.
diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml
index 10916eb..1d6f521 100644
--- a/data/rooms/intro/1005.yaml
+++ b/data/rooms/intro/1005.yaml
@@ -4,6 +4,8 @@ color: ""
description:
- text: A featureless room.
exits:
+ northeast:
+ room: 1016
south:
room: 1006
southeast:
diff --git a/data/rooms/intro/1009.yaml b/data/rooms/intro/1009.yaml
index 0cd7569..d95cdfd 100644
--- a/data/rooms/intro/1009.yaml
+++ b/data/rooms/intro/1009.yaml
@@ -1,9 +1,11 @@
-id: 0
+id: 1009
name: New Room
color: ""
description:
- text: A featureless room.
exits:
+ north:
+ room: 1016
southwest:
room: 1006
objects: []
diff --git a/data/rooms/intro/1016.yaml b/data/rooms/intro/1016.yaml
new file mode 100644
index 0000000..287af37
--- /dev/null
+++ b/data/rooms/intro/1016.yaml
@@ -0,0 +1,21 @@
+id: 1016
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ down:
+ room: 1017
+ south:
+ room: 1009
+ southeast:
+ room: 1018
+ southwest:
+ room: 1005
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1017.yaml b/data/rooms/intro/1017.yaml
new file mode 100644
index 0000000..358ba9e
--- /dev/null
+++ b/data/rooms/intro/1017.yaml
@@ -0,0 +1,15 @@
+id: 0
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ up:
+ room: 1016
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1018.yaml b/data/rooms/intro/1018.yaml
new file mode 100644
index 0000000..1309d65
--- /dev/null
+++ b/data/rooms/intro/1018.yaml
@@ -0,0 +1,15 @@
+id: 0
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ northwest:
+ room: 1016
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index 653f045..0691b90 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -264,58 +264,31 @@ func findNextRoomID(currentRoomPath string) (int, error) {
}
-func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][2]int, roomAt map[[2]int]int) {
+func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][3]int, roomAt map[[3]int]int) {
roomIndex := g.World.RoomIndex()
- coord = map[int][2]int{fromRoomID: {0, 0}}
- roomAt = map[[2]int]int{{0, 0}: fromRoomID}
- queue := []int{fromRoomID}
-
- for len(queue) > 0 {
- rid := queue[0]
- queue = queue[1:]
- room, err := g.World.LoadRoom(rid)
- if err != nil {
- continue
- }
- c := coord[rid]
- for _, ed := range world.ExitOrder {
- exit, ok := room.Exits[ed]
- if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
- continue
+ rg := world.BuildGrid(fromRoomID,
+ func(id int) (*world.Room, bool) {
+ r, err := g.World.LoadRoom(id)
+ if err != nil {
+ return nil, false
}
- target := exit.Room
- if ed == world.Up || ed == world.Down {
- continue
- }
- gd, ok := world.DirectionDeltas[ed]
- if !ok {
- continue
- }
- want := [2]int{c[0] + gd[0], c[1] + gd[1]}
-
- if _, exists := coord[target]; exists {
- continue
- }
- if occupier, exists := roomAt[want]; exists && occupier != target {
- continue
- }
- coord[target] = want
- roomAt[want] = target
- queue = append(queue, target)
- }
- }
- return coord, roomAt
+ return r, true
+ },
+ func(id int) bool { return roomIndex[id] },
+ nil,
+ )
+ return rg.Coord, rg.RoomAt
}
func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int {
- delta, ok := world.DirectionDeltas[dir]
+ delta, ok := world.DirectionDeltas3D[dir]
if !ok {
return 0
}
_, roomAt := g.buildGridFrom(fromRoomID)
- targetCoord := [2]int{delta[0], delta[1]}
+ targetCoord := [3]int{delta[0], delta[1], delta[2]}
if occupier, ok := roomAt[targetCoord]; ok && occupier != fromRoomID {
return occupier
}
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 4fbcd10..6164e50 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -65,12 +65,16 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
oppositeDir := world.OppositeExit[dir]
- if delta, isHorizontal := world.DirectionDeltas[dir]; isHorizontal {
+ if delta3D, ok := world.DirectionDeltas3D[dir]; ok {
coord, roomAt := g.buildGridFrom(p.RoomID)
- sSet := g.bfsComponent(targetID, p.RoomID)
+ sSet := g.bfsReachable(targetID, func(_ int, _ world.ExitDir, target int) bool {
+ return target == p.RoomID
+ })
- withoutEdge := g.bfsWithoutEdge(p.RoomID, dir, targetID)
+ withoutEdge := g.bfsReachable(p.RoomID, func(rid int, d world.ExitDir, target int) bool {
+ return rid == p.RoomID && d == dir && target == targetID
+ })
for rid := range sSet {
if withoutEdge[rid] {
room, _ := g.World.LoadRoom(rid)
@@ -90,7 +94,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
continue
}
oldPos := coord[rid]
- newPos := [2]int{oldPos[0] + delta[0], oldPos[1] + delta[1]}
+ newPos := [3]int{oldPos[0] + delta3D[0], oldPos[1] + delta3D[1], oldPos[2] + delta3D[2]}
if occupier, ok := roomAt[newPos]; ok && !sSet[occupier] {
occRoom, _ := g.World.LoadRoom(occupier)
occName := fmt.Sprintf("#%d", occupier)
@@ -221,7 +225,10 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
g.checkAggro(sess)
}
-func (g *Game) bfsComponent(startID, excludeID int) map[int]bool {
+// bfsReachable returns the set of rooms reachable from startID over the room
+// exit graph, restricted to known rooms. skipEdge, when non-nil, prunes an
+// individual directed exit (the edge from rid via dir to target) from the walk.
+func (g *Game) bfsReachable(startID int, skipEdge func(rid int, dir world.ExitDir, target int) bool) map[int]bool {
roomIndex := g.World.RoomIndex()
visited := map[int]bool{startID: true}
queue := []int{startID}
@@ -234,51 +241,14 @@ func (g *Game) bfsComponent(startID, excludeID int) map[int]bool {
continue
}
for _, ed := range world.ExitOrder {
- if ed == world.Up || ed == world.Down {
- continue
- }
exit, ok := room.Exits[ed]
if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
continue
}
target := exit.Room
- if target == excludeID {
- continue
- }
- if visited[target] {
+ if skipEdge != nil && skipEdge(rid, ed, target) {
continue
}
- visited[target] = true
- queue = append(queue, target)
- }
- }
- return visited
-}
-
-func (g *Game) bfsWithoutEdge(fromID int, skipDir world.ExitDir, skipTo int) map[int]bool {
- roomIndex := g.World.RoomIndex()
- visited := map[int]bool{fromID: true}
- queue := []int{fromID}
-
- for len(queue) > 0 {
- rid := queue[0]
- queue = queue[1:]
- room, err := g.World.LoadRoom(rid)
- if err != nil {
- continue
- }
- for _, ed := range world.ExitOrder {
- if ed == world.Up || ed == world.Down {
- continue
- }
- exit, ok := room.Exits[ed]
- if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
- continue
- }
- if rid == fromID && ed == skipDir && exit.Room == skipTo {
- continue
- }
- target := exit.Room
if visited[target] {
continue
}
diff --git a/internal/game/command_queue.go b/internal/game/core_command_queue.go
index 7afc75b..7afc75b 100644
--- a/internal/game/command_queue.go
+++ b/internal/game/core_command_queue.go
diff --git a/internal/game/flagstore.go b/internal/game/core_flagstore.go
index ecefccf..ecefccf 100644
--- a/internal/game/flagstore.go
+++ b/internal/game/core_flagstore.go
diff --git a/internal/game/object_def.go b/internal/game/core_object_def.go
index 6cf2464..6cf2464 100644
--- a/internal/game/object_def.go
+++ b/internal/game/core_object_def.go
diff --git a/internal/game/validate_source.go b/internal/game/core_validate.go
index 4a2e5b9..4a2e5b9 100644
--- a/internal/game/validate_source.go
+++ b/internal/game/core_validate.go
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index ebb2197..712202b 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -325,7 +325,23 @@ func TestBuildFullMap(t *testing.T) {
func TestStripBlankRows(t *testing.T) {
input := []string{" ", "hello", "", " world ", " "}
- want := []string{"hello", " world "}
+ want := []string{"hello", "", " world "}
+ got := stripBlankRows(input)
+ if len(got) != len(want) {
+ t.Fatalf("expected %d lines, got %d", len(want), len(got))
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("line %d: want %q, got %q", i, want[i], got[i])
+ }
+ }
+}
+
+func TestStripBlankRowsPreservesInternal(t *testing.T) {
+ // Internal blank rows (between non-blank content) should be preserved.
+ // They represent real vertical distance between disconnected components.
+ input := []string{" ", "hello", "", "", "world", " "}
+ want := []string{"hello", "", "", "world"}
got := stripBlankRows(input)
if len(got) != len(want) {
t.Fatalf("expected %d lines, got %d", len(want), len(got))
@@ -374,3 +390,54 @@ func TestLeftTrimCommonZero(t *testing.T) {
t.Errorf("line 1: want %q, got %q", "world", got[1])
}
}
+
+func TestMap3DDisconnectedComponent(t *testing.T) {
+ // Tower 1 (0,0,0) has down→bridge (0,0,-1). bridge→east→tower2base (1,0,-1).
+ // tower2base→up→tower2 (1,0,0). Tower 2 is at the same z=0 as tower 1 but
+ // unreachable via horizontal exits. The 3D map should still show it.
+ dir := t.TempDir()
+ writeTempRoom(t, dir, 1, "name: Tower One\nexits:\n down: 2\n")
+ writeTempRoom(t, dir, 2, "name: Bridge\nexits:\n east: 3\n up: 1\n")
+ writeTempRoom(t, dir, 3, "name: Tower Two Base\nexits:\n up: 4\n west: 2\n")
+ writeTempRoom(t, dir, 4, "name: Tower Two\nexits:\n down: 3\n")
+
+ g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()}
+ mg := mapGlyphsForPlayer(false)
+
+ // Player has only visited tower 1. Tower 2 should appear but dimmed (unvisited).
+ sess := &net.Session{Player: &player.Player{
+ Stats: player.PlayerStats{RoomsVisited: map[int]bool{1: true}},
+ Flags: map[string]any{},
+ }}
+
+ out := strings.Join(buildTinyMap(g, sess, 1, mg), "\n")
+
+ // Tower 2 at (1,0,0) should appear on the same z-level map.
+ // Unvisited rooms render as □ (unicode) or o (ASCII).
+ if !strings.Contains(out, "□") && !strings.Contains(out, "o") {
+ t.Errorf("expected tower 2 (unvisited) to appear on the map:\n%s", out)
+ }
+}
+
+func TestMap3DDifferentZExcluded(t *testing.T) {
+ // Room 1 at (0,0,0) with up to room 2 at (0,0,1). Room 2 is at z=1,
+ // different from the player's z=0 level — should NOT show on the map.
+ dir := t.TempDir()
+ writeTempRoom(t, dir, 1, "name: Ground\nexits:\n up: 2\n")
+ writeTempRoom(t, dir, 2, "name: Upper\nexits:\n down: 1\n")
+
+ g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()}
+ mg := mapGlyphsForPlayer(false)
+ sess := &net.Session{Player: &player.Player{
+ Stats: player.PlayerStats{RoomsVisited: map[int]bool{1: true, 2: true}},
+ Flags: map[string]any{},
+ }}
+
+ out := strings.Join(buildTinyMap(g, sess, 1, mg), "\n")
+
+ // Room 2 is at a different z — it should NOT render as a room symbol.
+ // Unvisited rooms render as □ (unicode) or o (ASCII).
+ if strings.Contains(out, "□") || strings.Contains(out, " o ") {
+ t.Errorf("room at different z should not appear:\n%s", out)
+ }
+}
diff --git a/internal/game/craft_index.go b/internal/game/production_index.go
index 648ccc8..648ccc8 100644
--- a/internal/game/craft_index.go
+++ b/internal/game/production_index.go
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index 6b80688..b210e77 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -47,59 +47,19 @@ type mapCell struct {
}
type mapGraph struct {
- posToRoom map[[2]int]int
- roomToPos map[int][2]int
+ posToRoom map[[3]int]int
dist map[int]int
}
+// The map BFS always seeds the player's room at the grid origin, so the
+// player's z-plane is constant.
+const playerZ = 0
-
-func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph {
- mg := &mapGraph{
- posToRoom: make(map[[2]int]int),
- roomToPos: make(map[int][2]int),
- dist: make(map[int]int),
- }
-
- type node struct {
- roomID int
- x, y int
- }
- queue := []node{{startRoomID, 0, 0}}
- mg.posToRoom[[2]int{0, 0}] = startRoomID
- mg.roomToPos[startRoomID] = [2]int{0, 0}
- mg.dist[startRoomID] = 0
-
- for len(queue) > 0 {
- n := queue[0]
- queue = queue[1:]
-
- room, ok := loadRoom(g, n.roomID)
- if !ok {
- continue
- }
-
- if visited != nil && !visited[n.roomID] {
- continue
- }
-
- for dir, delta := range world.DirectionDeltas {
- targetID, ok := exitTarget(room, dir)
- if !ok {
- continue
- }
- if _, seen := mg.roomToPos[targetID]; seen {
- continue
- }
- nx, ny := n.x+delta[0], n.y+delta[1]
- mg.posToRoom[[2]int{nx, ny}] = targetID
- mg.roomToPos[targetID] = [2]int{nx, ny}
- mg.dist[targetID] = mg.dist[n.roomID] + 1
- queue = append(queue, node{targetID, nx, ny})
- }
- }
-
- return mg
+func buildGraph(g *Game, startRoomID int) *mapGraph {
+ rg := world.BuildGrid(startRoomID, func(id int) (*world.Room, bool) {
+ return loadRoom(g, id)
+ }, nil, nil)
+ return &mapGraph{posToRoom: rg.RoomAt, dist: rg.Dist}
}
func renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, border rune) []string {
@@ -128,7 +88,7 @@ func renderMapCells(grid [][]mapCell, colorMode string, startRow, endRow int, bo
func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string {
visited := roomsVisited(sess)
- bg := buildGraph(g, roomID, visited)
+ bg := buildGraph(g, roomID)
colorMode := colorModeFor(sess)
atSpec := resolveMapAt(g, sess)
@@ -147,9 +107,10 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
}
}
+ pz := playerZ
for y := -1; y <= 1; y++ {
for x := -1; x <= 1; x++ {
- pos := [2]int{x, y}
+ pos := [3]int{x, y, pz}
rid, ok := bg.posToRoom[pos]
if !ok {
continue
@@ -171,8 +132,8 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
for y := -1; y <= 1; y++ {
for x := -1; x <= 0; x++ {
- leftPos := [2]int{x, y}
- rightPos := [2]int{x + 1, y}
+ leftPos := [3]int{x, y, pz}
+ rightPos := [3]int{x + 1, y, pz}
leftRoom, leftOK := bg.posToRoom[leftPos]
rightRoom, rightOK := bg.posToRoom[rightPos]
if !leftOK || !rightOK {
@@ -186,8 +147,8 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
for y := -1; y <= 0; y++ {
for x := -1; x <= 1; x++ {
- topPos := [2]int{x, y}
- bottomPos := [2]int{x, y + 1}
+ topPos := [3]int{x, y, pz}
+ bottomPos := [3]int{x, y + 1, pz}
topRoom, topOK := bg.posToRoom[topPos]
bottomRoom, bottomOK := bg.posToRoom[bottomPos]
if !topOK || !bottomOK {
@@ -199,119 +160,50 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
}
}
- // NE connectors: (x,y) -> (x+1, y-1), connector at grid[2*y+1][2*x+3]
+ // Diagonal connectors. Each maps room (x,y) to a diagonal neighbor; the
+ // grid cell between them is 2*y/2*x offset by ±1 in each axis.
for y := 0; y <= 1; y++ {
for x := -1; x <= 0; x++ {
- aPos, bPos := [2]int{x, y}, [2]int{x + 1, y - 1}
- aRoom, aOK := bg.posToRoom[aPos]
- bRoom, bOK := bg.posToRoom[bPos]
- if !aOK || !bOK {
- continue
- }
- if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Northeast, world.Southwest); ok {
- gr, gc := 2*y+1, 2*x+3
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{aRoom, bRoom}
- }
+ // NE: (x,y) -> (x+1, y-1), connector at grid[2*y+1][2*x+3]
+ aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}]
+ bRoom, bOK := bg.posToRoom[[3]int{x + 1, y - 1, pz}]
+ if aOK && bOK {
+ ctx.placeDiagonal(grid, 2*y+1, 2*x+3, aRoom, bRoom, world.Northeast, world.Southwest)
}
}
}
- // NW connectors: (x,y) -> (x-1, y-1), connector at grid[2*y+1][2*x+1]
for y := 0; y <= 1; y++ {
for x := 0; x <= 1; x++ {
- aPos, bPos := [2]int{x, y}, [2]int{x - 1, y - 1}
- aRoom, aOK := bg.posToRoom[aPos]
- bRoom, bOK := bg.posToRoom[bPos]
- if !aOK || !bOK {
- continue
- }
- if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Northwest, world.Southeast); ok {
- gr, gc := 2*y+1, 2*x+1
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{aRoom, bRoom}
- }
+ // NW: (x,y) -> (x-1, y-1), connector at grid[2*y+1][2*x+1]
+ aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}]
+ bRoom, bOK := bg.posToRoom[[3]int{x - 1, y - 1, pz}]
+ if aOK && bOK {
+ ctx.placeDiagonal(grid, 2*y+1, 2*x+1, aRoom, bRoom, world.Northwest, world.Southeast)
}
}
}
- // SE connectors: (x,y) -> (x+1, y+1), connector at grid[2*y+3][2*x+3]
for y := -1; y <= 0; y++ {
for x := -1; x <= 0; x++ {
- aPos, bPos := [2]int{x, y}, [2]int{x + 1, y + 1}
- aRoom, aOK := bg.posToRoom[aPos]
- bRoom, bOK := bg.posToRoom[bPos]
- if !aOK || !bOK {
- continue
- }
- if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Southeast, world.Northwest); ok {
- gr, gc := 2*y+3, 2*x+3
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{aRoom, bRoom}
- }
+ // SE: (x,y) -> (x+1, y+1), connector at grid[2*y+3][2*x+3]
+ aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}]
+ bRoom, bOK := bg.posToRoom[[3]int{x + 1, y + 1, pz}]
+ if aOK && bOK {
+ ctx.placeDiagonal(grid, 2*y+3, 2*x+3, aRoom, bRoom, world.Southeast, world.Northwest)
}
}
}
- // SW connectors: (x,y) -> (x-1, y+1), connector at grid[2*y+3][2*x+1]
for y := -1; y <= 0; y++ {
for x := 0; x <= 1; x++ {
- aPos, bPos := [2]int{x, y}, [2]int{x - 1, y + 1}
- aRoom, aOK := bg.posToRoom[aPos]
- bRoom, bOK := bg.posToRoom[bPos]
- if !aOK || !bOK {
- continue
- }
- if cell, ok := ctx.connectorCell(aRoom, bRoom, world.Southwest, world.Northeast); ok {
- gr, gc := 2*y+3, 2*x+1
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(aRoom), ctx.nodeSpec(bRoom)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{aRoom, bRoom}
- }
+ // SW: (x,y) -> (x-1, y+1), connector at grid[2*y+3][2*x+1]
+ aRoom, aOK := bg.posToRoom[[3]int{x, y, pz}]
+ bRoom, bOK := bg.posToRoom[[3]int{x - 1, y + 1, pz}]
+ if aOK && bOK {
+ ctx.placeDiagonal(grid, 2*y+3, 2*x+1, aRoom, bRoom, world.Southwest, world.Northeast)
}
}
}
+
cur, _ := loadRoom(g, roomID)
if cur != nil {
if upTarget, hasUp := exitTarget(cur, world.Up); hasUp {
@@ -354,7 +246,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string {
visited := roomsVisited(sess)
- bg := buildGraph(g, roomID, visited)
+ bg := buildGraph(g, roomID)
colorMode := colorModeFor(sess)
atSpec := resolveMapAt(g, sess)
@@ -375,8 +267,12 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
cx := mapWidth / 2
cy := mapHeight / 2
+ pz := playerZ
for pos, rid := range bg.posToRoom {
+ if pos[2] != pz {
+ continue
+ }
gr := cy + pos[1]*2
gc := cx + pos[0]*2
if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth {
@@ -395,9 +291,12 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
}
for pos, rid := range bg.posToRoom {
+ if pos[2] != pz {
+ continue
+ }
x, y := pos[0], pos[1]
- if rightID, exists := bg.posToRoom[[2]int{x + 1, y}]; exists {
+ if rightID, exists := bg.posToRoom[[3]int{x + 1, y, pz}]; exists {
gr := cy + y*2
gc := cx + x*2 + 1
if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
@@ -407,7 +306,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
}
}
- if bottomID, exists := bg.posToRoom[[2]int{x, y + 1}]; exists {
+ if bottomID, exists := bg.posToRoom[[3]int{x, y + 1, pz}]; exists {
gr := cy + y*2 + 1
gc := cx + x*2
if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
@@ -417,96 +316,20 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
}
}
- if neID, exists := bg.posToRoom[[2]int{x + 1, y - 1}]; exists {
- gr := cy + y*2 - 1
- gc := cx + x*2 + 1
- if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- if cell, ok := ctx.connectorCell(rid, neID, world.Northeast, world.Southwest); ok {
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(neID)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{rid, neID}
- }
- }
- }
+ if neID, exists := bg.posToRoom[[3]int{x + 1, y - 1, pz}]; exists {
+ ctx.placeDiagonal(grid, cy+y*2-1, cx+x*2+1, rid, neID, world.Northeast, world.Southwest)
}
- if nwID, exists := bg.posToRoom[[2]int{x - 1, y - 1}]; exists {
- gr := cy + y*2 - 1
- gc := cx + x*2 - 1
- if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- if cell, ok := ctx.connectorCell(rid, nwID, world.Northwest, world.Southeast); ok {
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(nwID)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{rid, nwID}
- }
- }
- }
+ if nwID, exists := bg.posToRoom[[3]int{x - 1, y - 1, pz}]; exists {
+ ctx.placeDiagonal(grid, cy+y*2-1, cx+x*2-1, rid, nwID, world.Northwest, world.Southeast)
}
- if seID, exists := bg.posToRoom[[2]int{x + 1, y + 1}]; exists {
- gr := cy + y*2 + 1
- gc := cx + x*2 + 1
- if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- if cell, ok := ctx.connectorCell(rid, seID, world.Southeast, world.Northwest); ok {
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(seID)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{rid, seID}
- }
- }
- }
+ if seID, exists := bg.posToRoom[[3]int{x + 1, y + 1, pz}]; exists {
+ ctx.placeDiagonal(grid, cy+y*2+1, cx+x*2+1, rid, seID, world.Southeast, world.Northwest)
}
- if swID, exists := bg.posToRoom[[2]int{x - 1, y + 1}]; exists {
- gr := cy + y*2 + 1
- gc := cx + x*2 - 1
- if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- if cell, ok := ctx.connectorCell(rid, swID, world.Southwest, world.Northeast); ok {
- key := [2]int{gr, gc}
- if isDiagonalGlyph(grid[gr][gc].char) {
- if grid[gr][gc].char != cell.char {
- prev := ctx.diagPairs[key]
- spec := color.Average(
- color.Average(ctx.nodeSpec(prev[0]), ctx.nodeSpec(prev[1])),
- color.Average(ctx.nodeSpec(rid), ctx.nodeSpec(swID)),
- )
- grid[gr][gc] = mapCell{char: 'X', spec: spec}
- }
- } else {
- grid[gr][gc] = cell
- ctx.diagPairs[key] = [2]int{rid, swID}
- }
- }
- }
+ if swID, exists := bg.posToRoom[[3]int{x - 1, y + 1, pz}]; exists {
+ ctx.placeDiagonal(grid, cy+y*2+1, cx+x*2-1, rid, swID, world.Southwest, world.Northeast)
}
}
@@ -629,6 +452,35 @@ func (c *mapRenderCtx) coloredCell(roomA, roomB int, glyph rune) (mapCell, bool)
return mapCell{char: glyph, spec: color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB))}, true
}
+// placeDiagonal draws the diagonal link between two grid-adjacent rooms at
+// grid[gr][gc], handling the criss-cross case: when a diagonal glyph is already
+// present and the new link runs the opposite way, the cell becomes a blocked
+// 'X' colored from both crossing links' endpoints. Out-of-bounds targets and
+// rooms with no link between them are no-ops.
+func (c *mapRenderCtx) placeDiagonal(grid [][]mapCell, gr, gc, roomA, roomB int, dirAB, dirBA world.ExitDir) {
+ if gr < 0 || gr >= len(grid) || gc < 0 || gc >= len(grid[gr]) {
+ return
+ }
+ cell, ok := c.connectorCell(roomA, roomB, dirAB, dirBA)
+ if !ok {
+ return
+ }
+ key := [2]int{gr, gc}
+ if isDiagonalGlyph(grid[gr][gc].char) {
+ if grid[gr][gc].char != cell.char {
+ prev := c.diagPairs[key]
+ spec := color.Average(
+ color.Average(c.nodeSpec(prev[0]), c.nodeSpec(prev[1])),
+ color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB)),
+ )
+ grid[gr][gc] = mapCell{char: 'X', spec: spec}
+ }
+ return
+ }
+ grid[gr][gc] = cell
+ c.diagPairs[key] = [2]int{roomA, roomB}
+}
+
type exitState int
const (
@@ -763,13 +615,24 @@ func loadRoom(g *Game, roomID int) (*world.Room, bool) {
}
func stripBlankRows(lines []string) []string {
- var out []string
- for _, line := range lines {
+ top := -1
+ for i, line := range lines {
if strings.TrimSpace(line) != "" {
- out = append(out, line)
+ top = i
+ break
+ }
+ }
+ if top < 0 {
+ return nil
+ }
+ bottom := top
+ for i := len(lines) - 1; i > bottom; i-- {
+ if strings.TrimSpace(lines[i]) != "" {
+ bottom = i
+ break
}
}
- return out
+ return lines[top : bottom+1]
}
func leftTrimCommon(lines []string) []string {
diff --git a/internal/game/safespot_manager.go b/internal/game/sys_safespot_state.go
index 1520bc7..1520bc7 100644
--- a/internal/game/safespot_manager.go
+++ b/internal/game/sys_safespot_state.go
diff --git a/internal/game/triggers.go b/internal/game/sys_triggers.go
index cd1bbd3..cd1bbd3 100644
--- a/internal/game/triggers.go
+++ b/internal/game/sys_triggers.go
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index a41ad86..d835a53 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -870,20 +870,29 @@ func validateRoomWiring(s Source) []Issue {
}
-// validateRoomGrid lays each reachable horizontal plane on a 2D grid starting
-// from the configured root rooms and reports when the exit layout cannot be
-// embedded without conflict:
+// validateRoomGrid embeds the entire reachable world on a single 3D grid
+// starting from the configured root rooms and reports when the exit layout
+// cannot be embedded without conflict:
// - overlap: two distinct rooms land on the same grid cell.
// - twist: one room is forced onto two different grid cells.
//
-// Up/Down exits don't move on the grid; each leads to a new plane that is laid
-// out independently (fresh origin), so one root validates every reachable
-// floor. Exit conditions are ignored (geometry is independent of gating), and
+// All 10 exits (including Up/Down) participate in coordinate assignment:
+// horizontal exits shift (x,y); Up/Down shift z at the same (x,y).
+// Exit conditions are ignored (geometry is independent of gating), and
// exits to nonexistent rooms are skipped (covered by referential checks).
func validateRoomGrid(s Source) []Issue {
var issues []Issue
roomIndex := s.World.RoomIndex()
+ load := func(id int) (*world.Room, bool) {
+ r, err := s.World.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ }
+ include := func(id int) bool { return roomIndex[id] }
+
placed := make(map[int]bool)
var seeds []int
for _, r := range s.RootRooms {
@@ -892,71 +901,34 @@ func validateRoomGrid(s Source) []Issue {
}
}
- for len(seeds) > 0 {
- origin := seeds[0]
- seeds = seeds[1:]
+ for _, origin := range seeds {
if placed[origin] {
continue
}
- coordOf := map[int][2]int{origin: {0, 0}}
- roomAt := map[[2]int]int{{0, 0}: origin}
- placed[origin] = true
- queue := []int{origin}
-
- for len(queue) > 0 {
- rid := queue[0]
- queue = queue[1:]
- room, err := s.World.LoadRoom(rid)
- if err != nil {
- continue
+ grid := world.BuildGrid(origin, load, include, func(c world.GridConflict) {
+ switch c.Kind {
+ case "twist":
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid twist: room %d (via %d %s) maps to (%d,%d,%d) but was already placed at (%d,%d,%d) [origin %d]",
+ c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Existing[0], c.Existing[1], c.Existing[2], origin),
+ })
+ case "overlap":
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid overlap: room %d (via %d %s) wants (%d,%d,%d), already used by room %d [origin %d]",
+ c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Occupier, origin),
+ })
}
- c := coordOf[rid]
- for _, dir := range world.ExitOrder {
- exit, ok := room.Exits[dir]
- if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
- continue
- }
- target := exit.Room
-
- if dir == world.Up || dir == world.Down {
- if !placed[target] {
- seeds = append(seeds, target)
- }
- continue
- }
-
- d := world.DirectionDeltas[dir]
- want := [2]int{c[0] + d[0], c[1] + d[1]}
-
- if existing, ok := coordOf[target]; ok {
- if existing != want {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "integrity",
- Message: fmt.Sprintf(
- "Grid twist: room %d (via %d %s) maps to grid (%d,%d) but was already placed at (%d,%d) [plane origin %d]",
- target, rid, dir, want[0], want[1], existing[0], existing[1], origin),
- })
- }
- continue
- }
- if occupier, ok := roomAt[want]; ok && occupier != target {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "integrity",
- Message: fmt.Sprintf(
- "Grid overlap: room %d (via %d %s) wants grid (%d,%d), already used by room %d [plane origin %d]",
- target, rid, dir, want[0], want[1], occupier, origin),
- })
- continue
- }
+ })
- coordOf[target] = want
- roomAt[want] = target
- placed[target] = true
- queue = append(queue, target)
- }
+ for rid := range grid.Coord {
+ placed[rid] = true
}
}
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
index b3f834a..716cec1 100644
--- a/internal/validate/grid_test.go
+++ b/internal/validate/grid_test.go
@@ -87,9 +87,8 @@ func TestGridTwist(t *testing.T) {
}
func TestGridMultiPlaneViaUpDown(t *testing.T) {
- // Plane A is just room 1. Going up seeds plane B (origin 10), which has an
- // overlap. This proves up/down crosses planes and frames are independent
- // (room 1 and room 10 both sit at (0,0) without conflicting).
+ // Room 1 at (0,0,0) goes up to room 10 at (0,0,1). Rooms 13 and 14 both
+ // resolve to (1,1,1) — 3D overlap on the upper level.
rooms := map[int]string{
1: "exits:\n up: 10\n",
10: "exits:\n south: 12\n east: 11\n",
@@ -99,8 +98,60 @@ func TestGridMultiPlaneViaUpDown(t *testing.T) {
14: "name: fourteen\n",
}
issues := runGridCheck(t, rooms, 1)
- if !containsMsg(issues, "plane origin 10") {
- t.Errorf("expected an overlap in plane origin 10, got: %+v", issues)
+ if !containsMsg(issues, "Grid overlap") {
+ t.Errorf("expected a 3D grid overlap, got: %+v", issues)
+ }
+}
+
+func TestGrid3DCleanViaUpDown(t *testing.T) {
+ // Clean 3D layout: up/down maintain same (x,y) across z-levels.
+ // 1(0,0,0) up→2(0,0,1) east→3(1,0,1) down→4(1,0,0) south→5(1,1,0)
+ rooms := map[int]string{
+ 1: "exits:\n up: 2\n",
+ 2: "exits:\n east: 3\n down: 1\n",
+ 3: "exits:\n down: 4\n west: 2\n",
+ 4: "exits:\n south: 5\n up: 3\n",
+ 5: "name: five\n exits:\n north: 4\n",
+ }
+ if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
+ t.Errorf("expected no grid issues, got: %+v", issues)
+ }
+}
+
+func TestGrid3DOverlapUpDown(t *testing.T) {
+ // Two rooms land on the same 3D cell via up/down paths.
+ // 1(0,0,0) up→2(0,0,1).
+ // 1(0,0,0) east→3(1,0,0) up→4(1,0,1) west→5 wants (0,0,1) — already room 2.
+ rooms := map[int]string{
+ 1: "exits:\n up: 2\n east: 3\n",
+ 2: "name: two\n",
+ 3: "exits:\n up: 4\n west: 1\n",
+ 4: "exits:\n west: 5\n down: 3\n",
+ 5: "name: five\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid overlap") {
+ t.Errorf("expected a 3D grid overlap, got: %+v", issues)
+ }
+}
+
+func TestGrid3DTwistUpDown(t *testing.T) {
+ // Room 4 forced to two different coordinates via up/down paths.
+ // path1: 1 up→2(0,0,1) south→3(0,1,1) east→4 places 4 at (1,1,1)
+ // path2: 1 east→A(1,0,0) up→B(1,0,1) east→C(2,0,1) south→4 wants (2,1,1)
+ // 4 already at (1,1,1) from path1 → twist.
+ rooms := map[int]string{
+ 1: "exits:\n up: 2\n east: 6\n",
+ 2: "exits:\n south: 3\n down: 1\n",
+ 3: "exits:\n east: 4\n north: 2\n",
+ 4: "name: four\n",
+ 6: "exits:\n up: 7\n west: 1\n",
+ 7: "exits:\n east: 8\n down: 6\n",
+ 8: "exits:\n south: 4\n west: 7\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid twist") {
+ t.Errorf("expected a 3D grid twist, got: %+v", issues)
}
}
diff --git a/internal/world/grid.go b/internal/world/grid.go
new file mode 100644
index 0000000..837f737
--- /dev/null
+++ b/internal/world/grid.go
@@ -0,0 +1,95 @@
+package world
+
+// GridConflict describes a layout conflict found while embedding rooms onto a
+// 3D grid via their exit deltas.
+//
+// - "twist": one room is forced onto two different grid cells (it was already
+// placed at Existing but a later exit wants it at Want).
+// - "overlap": two distinct rooms want the same grid cell (Occupier already
+// holds Want).
+type GridConflict struct {
+ Kind string // "twist" or "overlap"
+ From int // room whose exit produced the conflict
+ Dir ExitDir // exit direction taken from From
+ Target int // room being placed
+ Want [3]int // grid cell Target was assigned
+ Existing [3]int // where Target was already placed (twist only)
+ Occupier int // room already at Want (overlap only)
+}
+
+// RoomGrid is the embedding of one connected component onto a 3D grid.
+type RoomGrid struct {
+ Coord map[int][3]int // room -> grid coordinate
+ RoomAt map[[3]int]int // grid coordinate -> room
+ Dist map[int]int // room -> BFS hop distance from the seed
+}
+
+// BuildGrid lays out the rooms reachable from seed onto a 3D grid, assigning
+// each room a coordinate by accumulating per-exit DirectionDeltas3D offsets in
+// BFS order. The seed sits at the origin (0,0,0).
+//
+// - load returns a room and false to skip one that cannot be read; its exits
+// are then not followed, but any coordinate already assigned to it stands.
+// - include, when non-nil, restricts placement to targets for which it returns
+// true (pass nil to place every reachable target).
+// - onConflict, when non-nil, is called for each twist/overlap encountered.
+//
+// The first assignment to a room or cell always wins; conflicting placements are
+// skipped. In a world that passes grid validation there are no conflicts, so the
+// layout is unambiguous.
+func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, onConflict func(GridConflict)) RoomGrid {
+ g := RoomGrid{
+ Coord: map[int][3]int{seed: {0, 0, 0}},
+ RoomAt: map[[3]int]int{{0, 0, 0}: seed},
+ Dist: map[int]int{seed: 0},
+ }
+ queue := []int{seed}
+
+ for len(queue) > 0 {
+ rid := queue[0]
+ queue = queue[1:]
+ room, ok := load(rid)
+ if !ok {
+ continue
+ }
+ c := g.Coord[rid]
+ for _, dir := range ExitOrder {
+ exit, ok := room.Exits[dir]
+ if !ok || exit.Room <= 0 {
+ continue
+ }
+ target := exit.Room
+ if include != nil && !include(target) {
+ continue
+ }
+ d := DirectionDeltas3D[dir]
+ want := [3]int{c[0] + d[0], c[1] + d[1], c[2] + d[2]}
+
+ if existing, seen := g.Coord[target]; seen {
+ if existing != want && onConflict != nil {
+ onConflict(GridConflict{
+ Kind: "twist", From: rid, Dir: dir,
+ Target: target, Want: want, Existing: existing,
+ })
+ }
+ continue
+ }
+ if occupier, used := g.RoomAt[want]; used && occupier != target {
+ if onConflict != nil {
+ onConflict(GridConflict{
+ Kind: "overlap", From: rid, Dir: dir,
+ Target: target, Want: want, Occupier: occupier,
+ })
+ }
+ continue
+ }
+
+ g.Coord[target] = want
+ g.RoomAt[want] = target
+ g.Dist[target] = g.Dist[rid] + 1
+ queue = append(queue, target)
+ }
+ }
+
+ return g
+}
diff --git a/internal/world/room.go b/internal/world/room.go
index 15f847f..7cc1f08 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -55,15 +55,17 @@ var ExitOrder = []ExitDir{
Up, Down,
}
-var DirectionDeltas = map[ExitDir][2]int{
- North: {0, -1},
- South: {0, 1},
- East: {1, 0},
- West: {-1, 0},
- Northeast: {1, -1},
- Northwest: {-1, -1},
- Southeast: {1, 1},
- Southwest: {-1, 1},
+var DirectionDeltas3D = map[ExitDir][3]int{
+ North: {0, -1, 0},
+ South: {0, 1, 0},
+ East: {1, 0, 0},
+ West: {-1, 0, 0},
+ Northeast: {1, -1, 0},
+ Northwest: {-1, -1, 0},
+ Southeast: {1, 1, 0},
+ Southwest: {-1, 1, 0},
+ Up: {0, 0, 1},
+ Down: {0, 0, -1},
}
type SpawnDef struct {