diff options
| -rw-r--r-- | AGENTS.md | 5 | ||||
| -rw-r--r-- | data/rooms/intro/1005.yaml | 2 | ||||
| -rw-r--r-- | data/rooms/intro/1009.yaml | 4 | ||||
| -rw-r--r-- | data/rooms/intro/1016.yaml | 21 | ||||
| -rw-r--r-- | data/rooms/intro/1017.yaml | 15 | ||||
| -rw-r--r-- | data/rooms/intro/1018.yaml | 15 | ||||
| -rw-r--r-- | internal/game/cmd_dig.go | 55 | ||||
| -rw-r--r-- | internal/game/cmd_room_insert.go | 56 | ||||
| -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.go | 69 | ||||
| -rw-r--r-- | internal/game/production_index.go (renamed from internal/game/craft_index.go) | 0 | ||||
| -rw-r--r-- | internal/game/render_map.go | 339 | ||||
| -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.go | 102 | ||||
| -rw-r--r-- | internal/validate/grid_test.go | 61 | ||||
| -rw-r--r-- | internal/world/grid.go | 95 | ||||
| -rw-r--r-- | internal/world/room.go | 20 |
21 files changed, 456 insertions, 403 deletions
@@ -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 { |
