aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/admin.md14
-rw-r--r--data/help/room.yaml1
-rw-r--r--data/rooms/intro/1005.yaml21
-rw-r--r--data/rooms/intro/1006.yaml25
-rw-r--r--data/rooms/intro/1007.yaml19
-rw-r--r--data/rooms/intro/1008.yaml17
-rw-r--r--data/rooms/intro/1009.yaml15
-rw-r--r--data/rooms/intro/1010.yaml19
-rw-r--r--data/rooms/intro/1011.yaml17
-rw-r--r--data/rooms/intro/1012.yaml17
-rw-r--r--data/rooms/intro/1013.yaml17
-rw-r--r--data/rooms/intro/1014.yaml17
-rw-r--r--data/rooms/intro/1015.yaml15
-rw-r--r--data/rooms/intro/2001.yaml25
-rw-r--r--data/rooms/intro/2002.yaml22
-rw-r--r--data/rooms/intro/2003.yaml26
-rw-r--r--data/rooms/intro/2004.yaml22
-rw-r--r--data/rooms/intro/2005.yaml24
-rw-r--r--internal/game/cmd_close.go2
-rw-r--r--internal/game/cmd_dig.go76
-rw-r--r--internal/game/cmd_registry.go8
-rw-r--r--internal/game/cmd_room.go37
-rw-r--r--internal/game/cmd_room_insert.go290
-rw-r--r--internal/game/cmd_undig.go2
-rw-r--r--internal/game/cmd_walk.go36
-rw-r--r--internal/game/map_test.go80
-rw-r--r--internal/game/render_help.go2
-rw-r--r--internal/game/render_map.go277
-rw-r--r--internal/game/render_prompt.go30
-rw-r--r--internal/validate/checks.go17
-rw-r--r--internal/validate/grid_test.go46
-rw-r--r--internal/world/room.go63
-rw-r--r--internal/world/world.go2
33 files changed, 1202 insertions, 99 deletions
diff --git a/building_guide/admin.md b/building_guide/admin.md
index 4ec36c1..1c7db93 100644
--- a/building_guide/admin.md
+++ b/building_guide/admin.md
@@ -104,10 +104,21 @@ to see all available actions:
| `remmob` | `room remmob <mob_id>` | Remove a mob spawn |
| `addspawn` | `room addspawn <item_id> [qty] [respawn]` | Add an item spawn (must exist in data/items/) |
| `remspawn` | `room remspawn <item_id>` | Remove an item spawn |
+| `insert` | `room insert <direction> [name]` | Insert a new room into an exit, pushing rooms further out |
All room commands are admin-only. Referenced objects, mobs, and items are validated
before being added.
+`room insert` works like `dig` but for an existing exit: it creates a new room
+*between* your current room and the room the exit leads to. The far room and
+everything reachable beyond it in that direction get "pushed" one step away.
+For N/S/E/W, a grid validation ensures the push won't cause map overlap or
+twist. For up/down, no grid check is needed — the new room simply becomes an
+intermediate floor. The new room inherits the current room's outward-facing
+exit properties (conditions, blocked messages, flags), while the far room's
+reciprocal exit properties are preserved on its side. You are teleported into
+the new room, just like `dig`.
+
#### god
```
god
@@ -190,7 +201,8 @@ Dumps detailed diagnostic information about your current room:
The typical admin worldbuilding workflow:
1. **Lay out rooms:** Use `dig` to create connected rooms quickly. If `dig` would create a
- grid overlap, a two-way link is created instead — no duplicate rooms.
+ grid overlap, a two-way link is created instead — no duplicate rooms. Use `room insert`
+ to place a new room between two already-connected rooms without breaking the map grid.
2. **Flesh out rooms:** Use `room` commands to set names, descriptions, add objects/mobs/spawns
directly from inside the game. For deeper edits, edit the YAML files directly in
`data/rooms/` to add on-enter scripts, triggers, hazards, etc.
diff --git a/data/help/room.yaml b/data/help/room.yaml
index 7965374..d2cbcb4 100644
--- a/data/help/room.yaml
+++ b/data/help/room.yaml
@@ -15,4 +15,5 @@ description: |-
remmob <mob_id> — remove a mob spawn
addspawn <item_id> [qty] [respawn_ticks] — add an item spawn
remspawn <item_id> — remove an item spawn
+ insert <direction> [name] — insert a new room into an exit, pushing rooms beyond
Type 'room' alone to see this list in-game.
diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml
new file mode 100644
index 0000000..10916eb
--- /dev/null
+++ b/data/rooms/intro/1005.yaml
@@ -0,0 +1,21 @@
+id: 1005
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ south:
+ room: 1006
+ southeast:
+ room: 1007
+ southwest:
+ room: 2003
+ west:
+ room: 1008
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1006.yaml b/data/rooms/intro/1006.yaml
new file mode 100644
index 0000000..e4929a9
--- /dev/null
+++ b/data/rooms/intro/1006.yaml
@@ -0,0 +1,25 @@
+id: 1006
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ east:
+ room: 1007
+ north:
+ room: 1005
+ northeast:
+ room: 1009
+ northwest:
+ room: 1008
+ up:
+ room: 1010
+ west:
+ room: 2003
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1007.yaml b/data/rooms/intro/1007.yaml
new file mode 100644
index 0000000..5d47fa4
--- /dev/null
+++ b/data/rooms/intro/1007.yaml
@@ -0,0 +1,19 @@
+id: 1007
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ northwest:
+ room: 1005
+ south:
+ room: 1014
+ west:
+ room: 1006
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1008.yaml b/data/rooms/intro/1008.yaml
new file mode 100644
index 0000000..a889954
--- /dev/null
+++ b/data/rooms/intro/1008.yaml
@@ -0,0 +1,17 @@
+id: 1008
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ east:
+ room: 1005
+ southeast:
+ room: 1006
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1009.yaml b/data/rooms/intro/1009.yaml
new file mode 100644
index 0000000..0cd7569
--- /dev/null
+++ b/data/rooms/intro/1009.yaml
@@ -0,0 +1,15 @@
+id: 0
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ southwest:
+ room: 1006
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1010.yaml b/data/rooms/intro/1010.yaml
new file mode 100644
index 0000000..ef09603
--- /dev/null
+++ b/data/rooms/intro/1010.yaml
@@ -0,0 +1,19 @@
+id: 1010
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ down:
+ room: 1006
+ east:
+ room: 1013
+ north:
+ room: 1011
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1011.yaml b/data/rooms/intro/1011.yaml
new file mode 100644
index 0000000..0f0c477
--- /dev/null
+++ b/data/rooms/intro/1011.yaml
@@ -0,0 +1,17 @@
+id: 1011
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ east:
+ room: 1012
+ south:
+ room: 1010
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1012.yaml b/data/rooms/intro/1012.yaml
new file mode 100644
index 0000000..6d83cff
--- /dev/null
+++ b/data/rooms/intro/1012.yaml
@@ -0,0 +1,17 @@
+id: 1012
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ south:
+ room: 1013
+ west:
+ room: 1011
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1013.yaml b/data/rooms/intro/1013.yaml
new file mode 100644
index 0000000..9b7a7ce
--- /dev/null
+++ b/data/rooms/intro/1013.yaml
@@ -0,0 +1,17 @@
+id: 1013
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ north:
+ room: 1012
+ west:
+ room: 1010
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1014.yaml b/data/rooms/intro/1014.yaml
new file mode 100644
index 0000000..7d23484
--- /dev/null
+++ b/data/rooms/intro/1014.yaml
@@ -0,0 +1,17 @@
+id: 1014
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ north:
+ room: 1007
+ up:
+ room: 1015
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1015.yaml b/data/rooms/intro/1015.yaml
new file mode 100644
index 0000000..74686bd
--- /dev/null
+++ b/data/rooms/intro/1015.yaml
@@ -0,0 +1,15 @@
+id: 0
+name: New Room
+color: ""
+description:
+ - text: A featureless room.
+exits:
+ down:
+ room: 1014
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/2001.yaml b/data/rooms/intro/2001.yaml
index 5aa3145..dc82c06 100644
--- a/data/rooms/intro/2001.yaml
+++ b/data/rooms/intro/2001.yaml
@@ -1,6 +1,21 @@
-name: "Test Room"
-description: "It's a test room"
+id: 2001
+name: Test Room
+color: ""
+description:
+ - text: It's a test room
exits:
- west: 1001
- east: 2002
- south: 2006
+ east:
+ room: 2002
+ south:
+ room: 2006
+ southeast:
+ room: 2005
+ west:
+ room: 1001
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/2002.yaml b/data/rooms/intro/2002.yaml
index 71cce54..ce2dede 100644
--- a/data/rooms/intro/2002.yaml
+++ b/data/rooms/intro/2002.yaml
@@ -1,5 +1,19 @@
-name: "Test Room"
-description: "It's a test room"
+id: 2002
+name: Test Room
+color: ""
+description:
+ - text: It's a test room
exits:
- west: 2001
- east: 2003
+ east:
+ room: 2003
+ southeast:
+ room: 2004
+ west:
+ room: 2001
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/2003.yaml b/data/rooms/intro/2003.yaml
index 5969057..8e80cff 100644
--- a/data/rooms/intro/2003.yaml
+++ b/data/rooms/intro/2003.yaml
@@ -1,5 +1,23 @@
-name: "Test Room"
-description: "It's a test room"
+id: 2003
+name: Test Room
+color: ""
+description:
+ - text: It's a test room
exits:
- west: 2002
- south: 2004
+ east:
+ room: 1006
+ northeast:
+ room: 1005
+ south:
+ room: 2004
+ southwest:
+ room: 2005
+ west:
+ room: 2002
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/2004.yaml b/data/rooms/intro/2004.yaml
index 33be395..33b41f3 100644
--- a/data/rooms/intro/2004.yaml
+++ b/data/rooms/intro/2004.yaml
@@ -1,5 +1,19 @@
-name: "Test Room"
-description: "It's a test room"
+id: 2004
+name: Test Room
+color: ""
+description:
+ - text: It's a test room
exits:
- north: 2003
- west: 2005
+ north:
+ room: 2003
+ northwest:
+ room: 2002
+ west:
+ room: 2005
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/2005.yaml b/data/rooms/intro/2005.yaml
index d8bc3d4..7af3fd2 100644
--- a/data/rooms/intro/2005.yaml
+++ b/data/rooms/intro/2005.yaml
@@ -1,5 +1,21 @@
-name: "Test Room"
-description: "It's a test room"
+id: 2005
+name: Test Room
+color: ""
+description:
+ - text: It's a test room
exits:
- east: 2004
- west: 2006
+ east:
+ room: 2004
+ northeast:
+ room: 2003
+ northwest:
+ room: 2001
+ west:
+ room: 2006
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/internal/game/cmd_close.go b/internal/game/cmd_close.go
index 8dab0d9..217ae9a 100644
--- a/internal/game/cmd_close.go
+++ b/internal/game/cmd_close.go
@@ -25,7 +25,7 @@ func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) {
dir := g.World.ResolveExit(args[0])
if dir == "" {
- sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.")
+ sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.")
return
}
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index e7023b2..653f045 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -23,13 +23,13 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
return
}
if len(args) == 0 {
- sess.WriteLine("Dig where? (north/south/east/west/up/down)")
+ sess.WriteLine("Dig where? (n/s/e/w/ne/nw/se/sw/u/d)")
return
}
dir := g.World.ResolveExit(args[0])
if dir == "" {
- sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.")
+ sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.")
return
}
@@ -82,6 +82,49 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
}
sess.WriteLine(fmt.Sprintf("That spot is already occupied by %s. Created a two-way link instead.", conflictName))
+
+ oldRoom := p.RoomID
+ p.ClearMoveState()
+ if g.Combat.Get(p.Name) != nil {
+ g.stopCombat(p.Name)
+ }
+ p.Action = nil
+ g.cancelRest(p.Name)
+ g.cancelEnterSeq(p.Name)
+ if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
+ p.EnterSeqRoom = 0
+ }
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "")
+ }
+
+ p.RoomID = conflictID
+ p.HazardTimer = 0
+ p.Stats.RecordRoomVisit(conflictID)
+ g.AccountStore.SaveCharacter(p)
+
+ g.World.SeedGroundItems(conflictID)
+ g.seedRoomMobs(conflictID)
+ g.seedRoomObjects(conflictID)
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(oldRoom) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s digs to the %s and vanishes.", p.Name, dir))
+ }
+ }
+ g.Hub.EnterRoom(sess, conflictID)
+ for _, other := range g.Hub.PlayersInRoom(conflictID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s appears from a freshly dug tunnel.", p.Name))
+ }
+ }
+ }
+
+ sess.WriteLine(fmt.Sprintf("You step through the new link to %s.", conflictName))
+ g.doLook(sess)
+ g.runEnterSteps(sess, conflictID)
+ g.checkAggro(sess)
return
}
@@ -220,22 +263,11 @@ func findNextRoomID(currentRoomPath string) (int, error) {
}
}
-var gridDeltas = map[world.ExitDir][2]int{
- world.North: {0, -1},
- world.South: {0, 1},
- world.East: {1, 0},
- world.West: {-1, 0},
-}
-
-func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int {
- delta, ok := gridDeltas[dir]
- if !ok {
- return 0
- }
+func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][2]int, roomAt map[[2]int]int) {
roomIndex := g.World.RoomIndex()
- coord := map[int][2]int{fromRoomID: {0, 0}}
- roomAt := map[[2]int]int{{0, 0}: fromRoomID}
+ coord = map[int][2]int{fromRoomID: {0, 0}}
+ roomAt = map[[2]int]int{{0, 0}: fromRoomID}
queue := []int{fromRoomID}
for len(queue) > 0 {
@@ -255,7 +287,7 @@ func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int {
if ed == world.Up || ed == world.Down {
continue
}
- gd, ok := gridDeltas[ed]
+ gd, ok := world.DirectionDeltas[ed]
if !ok {
continue
}
@@ -272,6 +304,16 @@ func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int {
queue = append(queue, target)
}
}
+ return coord, roomAt
+}
+
+func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int {
+ delta, ok := world.DirectionDeltas[dir]
+ if !ok {
+ return 0
+ }
+
+ _, roomAt := g.buildGridFrom(fromRoomID)
targetCoord := [2]int{delta[0], delta[1]}
if occupier, ok := roomAt[targetCoord]; ok && occupier != fromRoomID {
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 562fc71..bc77936 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -34,6 +34,14 @@ var commandRegistry = map[string]commandDef{
"e": {(*Game).executeMove, ClassActive},
"west": {(*Game).executeMove, ClassActive},
"w": {(*Game).executeMove, ClassActive},
+ "northeast": {(*Game).executeMove, ClassActive},
+ "ne": {(*Game).executeMove, ClassActive},
+ "northwest": {(*Game).executeMove, ClassActive},
+ "nw": {(*Game).executeMove, ClassActive},
+ "southeast": {(*Game).executeMove, ClassActive},
+ "se": {(*Game).executeMove, ClassActive},
+ "southwest": {(*Game).executeMove, ClassActive},
+ "sw": {(*Game).executeMove, ClassActive},
"up": {(*Game).executeMove, ClassActive},
"u": {(*Game).executeMove, ClassActive},
"down": {(*Game).executeMove, ClassActive},
diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go
index 24763fa..971e298 100644
--- a/internal/game/cmd_room.go
+++ b/internal/game/cmd_room.go
@@ -7,6 +7,7 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
@@ -46,6 +47,10 @@ func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) {
g.roomMob(sess, action, rest)
case "addspawn", "remspawn":
g.roomSpawn(sess, action, rest)
+ case "color":
+ g.roomSetColor(sess, rest)
+ case "insert":
+ g.roomInsert(sess, rest)
default:
sess.WriteLine(fmt.Sprintf("Unknown room action: %s", action))
g.showRoomHelp(sess)
@@ -66,6 +71,8 @@ func (g *Game) showRoomHelp(sess *net.Session) {
sess.WriteLine(" room remmob <mob_id> — remove a mob spawn")
sess.WriteLine(" room addspawn <item_id> [qty] [respawn_ticks] — add an item spawn")
sess.WriteLine(" room remspawn <item_id> — remove an item spawn")
+ sess.WriteLine(" room color <spec|off> — set or clear room map color")
+ sess.WriteLine(" room insert <direction> [name] — insert a new room into the exit, pushing rooms beyond")
}
func (g *Game) roomSetName(sess *net.Session, args []string) {
@@ -92,6 +99,36 @@ func (g *Game) roomSetDesc(sess *net.Session, args []string) {
sess.WriteLine("Room description updated.")
}
+func (g *Game) roomSetColor(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room color <spec|off>")
+ sess.WriteLine("Format: <00-FF> [bg:<00-FF>] [bold] [dim] [underline]")
+ sess.WriteLine("Example: D0 bold")
+ return
+ }
+ value := strings.Join(args, " ")
+ if value == "off" {
+ value = ""
+ }
+ if value != "" {
+ spec := color.Parse(value)
+ if spec.Empty() {
+ sess.WriteLine(fmt.Sprintf("Invalid color string: %s", value))
+ sess.WriteLine("Format: <00-FF> [bg:<00-FF>] [bold] [dim] [underline]")
+ sess.WriteLine("Example: D0 bold")
+ return
+ }
+ }
+ g.writeRoom(sess, func(room *world.Room) {
+ room.Color = value
+ })
+ if value == "" {
+ sess.WriteLine("Room color cleared.")
+ } else {
+ sess.WriteLine(fmt.Sprintf("Room color set to: %s", value))
+ }
+}
+
func (g *Game) loadRoomWrite(p *player.Player) (*world.Room, string, error) {
path, ok := g.World.GetRoomPath(p.RoomID)
if !ok {
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
new file mode 100644
index 0000000..4fbcd10
--- /dev/null
+++ b/internal/game/cmd_room_insert.go
@@ -0,0 +1,290 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) roomInsert(sess *net.Session, args []string) {
+ p := sess.Player
+ if p == nil {
+ return
+ }
+
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room insert <direction> [name]")
+ return
+ }
+
+ dir := g.World.ResolveExit(args[0])
+ if dir == "" {
+ sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.")
+ return
+ }
+
+ var name string
+ if len(args) > 1 {
+ name = strings.Join(args[1:], " ")
+ } else {
+ name = "New Room"
+ }
+
+ curPath, ok := g.World.GetRoomPath(p.RoomID)
+ if !ok {
+ sess.WriteLine("Error: can't find current room file.")
+ return
+ }
+
+ curRoom, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("Error loading current room.")
+ return
+ }
+
+ exitDef, hasExit := curRoom.Exits[dir]
+ if !hasExit {
+ sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir))
+ return
+ }
+
+ targetID := exitDef.Room
+ targetRoom, err := g.World.LoadRoom(targetID)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Target room %d not found.", targetID))
+ return
+ }
+ targetName := fmt.Sprintf("#%d (%s)", targetID, targetRoom.Name)
+
+ oppositeDir := world.OppositeExit[dir]
+
+ if delta, isHorizontal := world.DirectionDeltas[dir]; isHorizontal {
+ coord, roomAt := g.buildGridFrom(p.RoomID)
+
+ sSet := g.bfsComponent(targetID, p.RoomID)
+
+ withoutEdge := g.bfsWithoutEdge(p.RoomID, dir, targetID)
+ for rid := range sSet {
+ if withoutEdge[rid] {
+ room, _ := g.World.LoadRoom(rid)
+ conflictName := fmt.Sprintf("#%d", rid)
+ if room != nil {
+ conflictName = fmt.Sprintf("#%d (%s)", rid, room.Name)
+ }
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot insert: room %s would have an ambiguous grid position after insertion (reachable via an alternate path from here).",
+ conflictName))
+ return
+ }
+ }
+
+ for rid := range sSet {
+ if _, inGrid := coord[rid]; !inGrid {
+ continue
+ }
+ oldPos := coord[rid]
+ newPos := [2]int{oldPos[0] + delta[0], oldPos[1] + delta[1]}
+ if occupier, ok := roomAt[newPos]; ok && !sSet[occupier] {
+ occRoom, _ := g.World.LoadRoom(occupier)
+ occName := fmt.Sprintf("#%d", occupier)
+ if occRoom != nil {
+ occName = fmt.Sprintf("#%d (%s)", occupier, occRoom.Name)
+ }
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot insert: pushing %s would cause grid collision with %s.",
+ targetName, occName))
+ return
+ }
+ }
+ }
+
+ newID, err := findNextRoomID(curPath)
+ if err != nil {
+ sess.WriteLine("Error scanning room directory.")
+ return
+ }
+
+ dirPath := filepath.Dir(curPath)
+ newPath := filepath.Join(dirPath, strconv.Itoa(newID)+".yaml")
+
+ newRoom := &world.Room{
+ Name: name,
+ Description: behavior.DescList{
+ {Text: "A featureless room."},
+ },
+ Exits: map[world.ExitDir]world.ExitDef{
+ dir: {Room: targetID},
+ oppositeDir: {Room: p.RoomID},
+ },
+ }
+
+ data, err := yaml.Marshal(newRoom)
+ if err != nil {
+ sess.WriteLine("Error creating room YAML.")
+ return
+ }
+ if err := os.MkdirAll(dirPath, 0755); err != nil {
+ sess.WriteLine("Error creating directory.")
+ return
+ }
+ if err := os.WriteFile(newPath, data, 0644); err != nil {
+ sess.WriteLine("Error writing room file.")
+ return
+ }
+
+ g.World.AddRoomPath(newID, newPath)
+
+ curRoom.Exits[dir] = world.ExitDef{
+ Room: newID,
+ Condition: exitDef.Condition,
+ BlockedMessage: exitDef.BlockedMessage,
+ SetFlags: exitDef.SetFlags,
+ SetPlayerFlags: exitDef.SetPlayerFlags,
+ }
+ curData, err := yaml.Marshal(curRoom)
+ if err != nil {
+ sess.WriteLine("Error updating current room YAML.")
+ return
+ }
+ if err := os.WriteFile(curPath, curData, 0644); err != nil {
+ sess.WriteLine("Error writing current room file.")
+ return
+ }
+
+ if targetExit, tok := targetRoom.Exits[oppositeDir]; tok && targetExit.Room == p.RoomID {
+ targetRoom.Exits[oppositeDir] = world.ExitDef{
+ Room: newID,
+ Condition: targetExit.Condition,
+ BlockedMessage: targetExit.BlockedMessage,
+ SetFlags: targetExit.SetFlags,
+ SetPlayerFlags: targetExit.SetPlayerFlags,
+ }
+ }
+ targetPath, tok2 := g.World.GetRoomPath(targetID)
+ if tok2 {
+ targetData, terr := yaml.Marshal(targetRoom)
+ if terr == nil {
+ os.WriteFile(targetPath, targetData, 0644)
+ }
+ }
+
+ oldRoom := p.RoomID
+ p.ClearMoveState()
+ if g.Combat.Get(p.Name) != nil {
+ g.stopCombat(p.Name)
+ }
+ p.Action = nil
+ g.cancelRest(p.Name)
+ g.cancelEnterSeq(p.Name)
+ if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
+ p.EnterSeqRoom = 0
+ }
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "")
+ }
+
+ p.RoomID = newID
+ p.HazardTimer = 0
+ p.Stats.RecordRoomVisit(newID)
+ g.AccountStore.SaveCharacter(p)
+
+ g.World.SeedGroundItems(newID)
+ g.seedRoomMobs(newID)
+ g.seedRoomObjects(newID)
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(oldRoom) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s vanishes into the tunnel.", p.Name))
+ }
+ }
+ g.Hub.EnterRoom(sess, newID)
+ for _, other := range g.Hub.PlayersInRoom(newID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s appears.", p.Name))
+ }
+ }
+ }
+
+ g.World.RebuildRoomIndex(g.DataDir)
+
+ sess.WriteLine(fmt.Sprintf("You insert a room to the %s and create Room #%d (%s).", dir, newID, name))
+ g.doLook(sess)
+ g.runEnterSteps(sess, newID)
+ g.checkAggro(sess)
+}
+
+func (g *Game) bfsComponent(startID, excludeID int) map[int]bool {
+ roomIndex := g.World.RoomIndex()
+ visited := map[int]bool{startID: true}
+ queue := []int{startID}
+
+ 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
+ }
+ target := exit.Room
+ if target == excludeID {
+ continue
+ }
+ if visited[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
+ }
+ visited[target] = true
+ queue = append(queue, target)
+ }
+ }
+ return visited
+}
diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go
index 2cfae70..1fc70c1 100644
--- a/internal/game/cmd_undig.go
+++ b/internal/game/cmd_undig.go
@@ -30,7 +30,7 @@ func (g *Game) executeUndig(sess *net.Session, args []string, rawInput string) {
dir := g.World.ResolveExit(args[0])
if dir == "" {
- sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.")
+ sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.")
return
}
diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go
index d7d51e2..ec62eec 100644
--- a/internal/game/cmd_walk.go
+++ b/internal/game/cmd_walk.go
@@ -60,9 +60,25 @@ func parseWalkDirections(input string) ([]string, error) {
var dir string
switch ch {
case 'n':
- dir = string(world.North)
+ if i < len(input) && input[i] == 'e' {
+ i++
+ dir = string(world.Northeast)
+ } else if i < len(input) && input[i] == 'w' {
+ i++
+ dir = string(world.Northwest)
+ } else {
+ dir = string(world.North)
+ }
case 's':
- dir = string(world.South)
+ if i < len(input) && input[i] == 'e' {
+ i++
+ dir = string(world.Southeast)
+ } else if i < len(input) && input[i] == 'w' {
+ i++
+ dir = string(world.Southwest)
+ } else {
+ dir = string(world.South)
+ }
case 'e':
dir = string(world.East)
case 'w':
@@ -150,6 +166,14 @@ func directionToChar(dir string) string {
return "e"
case world.West:
return "w"
+ case world.Northeast:
+ return "ne"
+ case world.Northwest:
+ return "nw"
+ case world.Southeast:
+ return "se"
+ case world.Southwest:
+ return "sw"
case world.Up:
return "u"
case world.Down:
@@ -158,7 +182,11 @@ func directionToChar(dir string) string {
return "?"
}
-var bfsSearchDirs = []world.ExitDir{world.North, world.South, world.East, world.West, world.Up, world.Down}
+var walkSearchDirs = []world.ExitDir{
+ world.North, world.South, world.East, world.West,
+ world.Northeast, world.Northwest, world.Southeast, world.Southwest,
+ world.Up, world.Down,
+}
func (g *Game) findPathToRoom(fromRoom, toRoom int) []string {
if fromRoom == toRoom {
@@ -183,7 +211,7 @@ func (g *Game) findPathToRoom(fromRoom, toRoom int) []string {
continue
}
- for _, dir := range bfsSearchDirs {
+ for _, dir := range walkSearchDirs {
exitDef, exists := room.Exits[dir]
if !exists {
continue
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index 0d529cf..ebb2197 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -31,12 +31,15 @@ func writeTempRoom(t *testing.T, dir string, id int, body string) {
func TestMapConnectorGlyphs(t *testing.T) {
const condEast = "name: One\nexits:\n east:\n room: 2\n condition:\n flag: gate_open\n"
const condSouth = "name: Three\nexits:\n south:\n room: 2\n condition:\n flag: gate_open\n"
+ const condNE = "name: One\nexits:\n northeast:\n room: 2\n condition:\n flag: gate_open\n"
+ const condSE = "name: One\nexits:\n southeast:\n room: 2\n condition:\n flag: gate_open\n"
cases := []struct {
name string
room1 string
room2 string
room3 string
+ room4 string
flagOpen bool
wantPresent string
wantAbsent []string
@@ -91,6 +94,59 @@ func TestMapConnectorGlyphs(t *testing.T) {
wantPresent: "^",
wantAbsent: []string{"X", "v", "<", ">"},
},
+ {
+ name: "bidirectional diagonal NE-SW",
+ room1: "name: One\nexits:\n northeast: 2\n",
+ room2: "name: Two\nexits:\n southwest: 1\n",
+ wantPresent: "/",
+ wantAbsent: []string{"X", "\\"},
+ },
+ {
+ name: "bidirectional diagonal NW-SE",
+ room1: "name: One\nexits:\n northwest: 2\n",
+ room2: "name: Two\nexits:\n southeast: 1\n",
+ wantPresent: "\\",
+ wantAbsent: []string{"X", "/"},
+ },
+ {
+ name: "one-way NE arrow",
+ room1: "name: One\nexits:\n northeast: 2\n",
+ room2: "name: Two\n",
+ wantPresent: "/",
+ wantAbsent: []string{"X", "\\"},
+ },
+ {
+ name: "diagonal NE blocked conditional -> X",
+ room1: condNE,
+ room2: "name: Two\n",
+ flagOpen: false,
+ wantPresent: "X",
+ wantAbsent: []string{"\\", "/"},
+ },
+ {
+ name: "diagonal SE blocked conditional -> X",
+ room1: condSE,
+ room2: "name: Two\n",
+ flagOpen: false,
+ wantPresent: "X",
+ wantAbsent: []string{"\\", "/"},
+ },
+ {
+ name: "diagonal conditional unblocked -> bar",
+ room1: condNE,
+ room2: "name: Two\nexits:\n southwest: 1\n",
+ flagOpen: true,
+ wantPresent: "/",
+ wantAbsent: []string{"X", "\\"},
+ },
+ {
+ name: "criss-crossed diagonal paths show X",
+ room1: "name: One\nexits:\n east: 2\n south: 3\n southeast: 4\n",
+ room2: "name: Two\nexits:\n southwest: 3\n",
+ room3: "name: Three\n",
+ room4: "name: Four\n",
+ wantPresent: "X",
+ },
}
for _, tc := range cases {
@@ -101,6 +157,9 @@ func TestMapConnectorGlyphs(t *testing.T) {
if tc.room3 != "" {
writeTempRoom(t, dir, 3, tc.room3)
}
+ if tc.room4 != "" {
+ writeTempRoom(t, dir, 4, tc.room4)
+ }
g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()}
if tc.flagOpen {
@@ -121,6 +180,27 @@ func TestMapConnectorGlyphs(t *testing.T) {
}
}
+// TestMapDiagonalOneWayCrossing verifies that when a one-way diagonal arrow
+// (↗) crosses a bidirectional diagonal bar (/) at the same grid cell, the
+// cell renders as X rather than silently overwriting the arrow. This covers
+// the unicode-mode criss-cross path where isDiagonalGlyph must recognize
+// both bar glyphs and arrow glyphs.
+func TestMapDiagonalOneWayCrossing(t *testing.T) {
+ dir := t.TempDir()
+ writeTempRoom(t, dir, 1, "name: One\nexits:\n north: 3\n northeast: 2\n")
+ writeTempRoom(t, dir, 2, "name: Two\n")
+ writeTempRoom(t, dir, 3, "name: Three\nexits:\n southeast: 4\n")
+ writeTempRoom(t, dir, 4, "name: Four\nexits:\n northwest: 3\n")
+
+ g := &Game{Deps: Deps{World: world.New(dir)}}
+ sess := &net.Session{Player: &player.Player{Options: map[string]any{"unicode": true}}}
+
+ out := strings.Join(buildTinyMap(g, sess, 1, mapGlyphsForPlayer(true)), "\n")
+ if !strings.Contains(out, "X") {
+ t.Errorf("expected X for crossed one-way NE arrow and SE bar:\n%s", out)
+ }
+}
+
func TestBuildTinyMap(t *testing.T) {
g := &Game{
Deps: Deps{World: world.New("../../data")},
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 798eadf..7b7efa2 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -56,7 +56,7 @@ var commandList = []cmdEntry{
{"mine", "Active", "Mine rocks (Mining)"},
{"mix", "Active", "Mix potions (Pharmacy)"},
{"mods / modlist", "Instant", "List available science modules"},
- {"north / south / east / west / up / down", "Active", "Move in a direction"},
+ {"north / south / east / west / ne / nw / se / sw / up / down", "Active", "Move in a direction"},
{"option / options", "Instant", "View or change settings"},
{"prompt", "Instant", "Set custom command prompt"},
{"pull / push", "Active", "Interact with objects"},
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index 7416d06..6b80688 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -17,6 +17,9 @@ type mapGlyphs struct {
connectorH, connectorV rune
upArrow, downArrow rune
leftArrow, rightArrow rune
+ upRight, upLeft rune
+ downRight, downLeft rune
+ connectorNE, connectorNW rune
}
func mapGlyphsForPlayer(unicode bool) mapGlyphs {
@@ -25,12 +28,16 @@ func mapGlyphsForPlayer(unicode bool) mapGlyphs {
topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝',
side: '║', topFill: '═', connectorH: '-', connectorV: '│',
upArrow: '↑', downArrow: '↓', leftArrow: '←', rightArrow: '→',
+ upRight: '↗', upLeft: '↖', downRight: '↘', downLeft: '↙',
+ connectorNE: '/', connectorNW: '\\',
}
}
return mapGlyphs{
topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':',
side: ':', topFill: '.', connectorH: '-', connectorV: '|',
upArrow: '^', downArrow: 'v', leftArrow: '<', rightArrow: '>',
+ upRight: '/', upLeft: '\\', downRight: '\\', downLeft: '/',
+ connectorNE: '/', connectorNW: '\\',
}
}
@@ -45,15 +52,7 @@ type mapGraph struct {
dist map[int]int
}
-var bfsDirs = []struct {
- dir world.ExitDir
- dx, dy int
-}{
- {world.North, 0, -1},
- {world.South, 0, 1},
- {world.East, 1, 0},
- {world.West, -1, 0},
-}
+
func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph {
mg := &mapGraph{
@@ -84,15 +83,15 @@ func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph {
continue
}
- for _, d := range bfsDirs {
- targetID, ok := exitTarget(room, d.dir)
+ 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+d.dx, n.y+d.dy
+ 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
@@ -137,6 +136,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
ctx := &mapRenderCtx{
g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID,
atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg,
+ diagPairs: make(map[[2]int][2]int),
}
grid := make([][]mapCell, 5)
@@ -199,21 +199,145 @@ 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]
+ 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}
+ }
+ }
+ }
+ }
+ // 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}
+ }
+ }
+ }
+ }
+ // 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}
+ }
+ }
+ }
+ }
+ // 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}
+ }
+ }
+ }
+ }
+
cur, _ := loadRoom(g, roomID)
if cur != nil {
- if target, ok := exitTarget(cur, world.Up); ok {
+ if upTarget, hasUp := exitTarget(cur, world.Up); hasUp {
spec := color.NoColor()
- if exitStateTo(g, sess, roomID, world.Up, target) == exitBlocked {
+ if exitStateTo(g, sess, roomID, world.Up, upTarget) == exitBlocked {
spec = ctx.blockedSpec
}
- grid[1][3] = mapCell{char: mg.upArrow, spec: spec}
+ switch {
+ case grid[1][3].char == ' ':
+ grid[1][3] = mapCell{char: mg.upArrow, spec: spec}
+ case grid[1][1].char == ' ':
+ grid[1][1] = mapCell{char: mg.upArrow, spec: spec}
+ case grid[1][2].char == ' ':
+ grid[1][2] = mapCell{char: mg.upArrow, spec: spec}
+ }
}
- if target, ok := exitTarget(cur, world.Down); ok {
+ if downTarget, hasDown := exitTarget(cur, world.Down); hasDown {
spec := color.NoColor()
- if exitStateTo(g, sess, roomID, world.Down, target) == exitBlocked {
+ if exitStateTo(g, sess, roomID, world.Down, downTarget) == exitBlocked {
spec = ctx.blockedSpec
}
- grid[3][1] = mapCell{char: mg.downArrow, spec: spec}
+ if grid[3][1].char == ' ' {
+ grid[3][1] = mapCell{char: mg.downArrow, spec: spec}
+ } else if grid[3][3].char == ' ' {
+ grid[3][3] = mapCell{char: mg.downArrow, spec: spec}
+ }
}
}
@@ -238,6 +362,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
ctx := &mapRenderCtx{
g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID,
atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg,
+ diagPairs: make(map[[2]int][2]int),
}
grid := make([][]mapCell, mapHeight)
@@ -291,6 +416,98 @@ 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 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 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 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}
+ }
+ }
+ }
+ }
}
return renderMapCells(grid, colorMode, 0, mapHeight, 0)
@@ -343,6 +560,7 @@ type mapRenderCtx struct {
dimSpec color.ColorSpec
blockedSpec color.ColorSpec
mg mapGlyphs
+ diagPairs map[[2]int][2]int
}
// nodeSpec returns the effective color a room's node is drawn with, mirroring
@@ -441,12 +659,23 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb
return exitBlocked
}
+func isDiagonalGlyph(ch rune) bool {
+ return ch == '\\' || ch == '/' || ch == '↗' || ch == '↖' || ch == '↘' || ch == '↙'
+}
+
// barGlyph returns the bidirectional connector glyph for a link's orientation.
func barGlyph(mg mapGlyphs, dir world.ExitDir) rune {
- if dir == world.East || dir == world.West {
+ switch dir {
+ case world.East, world.West:
return mg.connectorH
+ case world.North, world.South:
+ return mg.connectorV
+ case world.Northeast, world.Southwest:
+ return mg.connectorNE
+ case world.Northwest, world.Southeast:
+ return mg.connectorNW
}
- return mg.connectorV
+ return mg.connectorH
}
// arrowGlyph returns the one-way arrow pointing along the direction of travel.
@@ -460,6 +689,14 @@ func arrowGlyph(mg mapGlyphs, dir world.ExitDir) rune {
return mg.downArrow
case world.North:
return mg.upArrow
+ case world.Northeast:
+ return mg.upRight
+ case world.Northwest:
+ return mg.upLeft
+ case world.Southeast:
+ return mg.downRight
+ case world.Southwest:
+ return mg.downLeft
}
return mg.connectorH
}
diff --git a/internal/game/render_prompt.go b/internal/game/render_prompt.go
index b992c8c..68f1a9a 100644
--- a/internal/game/render_prompt.go
+++ b/internal/game/render_prompt.go
@@ -59,7 +59,7 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string {
if !ok {
continue
}
- letter := strings.ToUpper(string(dir)[:1])
+ letter := exitPromptLetter(dir)
allExits += letter
if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
continue
@@ -114,3 +114,31 @@ func xpToLevel(p *player.Player, sk player.SkillName) int {
nextLevelXP := player.XPForLevel(currentLevel + 1)
return nextLevelXP - currentXP
}
+
+// Cardinal exits get uppercase single letters; intercardinal exits get lowercase
+// two-letter strings. This visual distinction helps players scan the prompt at a glance.
+func exitPromptLetter(dir world.ExitDir) string {
+ switch dir {
+ case world.North:
+ return "N"
+ case world.South:
+ return "S"
+ case world.East:
+ return "E"
+ case world.West:
+ return "W"
+ case world.Northeast:
+ return "ne"
+ case world.Northwest:
+ return "nw"
+ case world.Southeast:
+ return "se"
+ case world.Southwest:
+ return "sw"
+ case world.Up:
+ return "U"
+ case world.Down:
+ return "D"
+ }
+ return "?"
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 87c078e..a41ad86 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -45,12 +45,14 @@ func validateRooms(s Source) []Issue {
for dir, exit := range room.Exits {
switch dir {
- case world.North, world.South, world.East, world.West, world.Up, world.Down:
+ case world.North, world.South, world.East, world.West,
+ world.Northeast, world.Northwest, world.Southeast, world.Southwest,
+ world.Up, world.Down:
default:
issues = append(issues, Issue{
Level: "ERROR",
Type: "reference",
- Message: fmt.Sprintf("Room %d: invalid exit direction %q (only north/south/east/west/up/down supported)", id, dir),
+ Message: fmt.Sprintf("Room %d: invalid exit direction %q (only n/s/e/w/ne/nw/se/sw/up/down supported)", id, dir),
})
continue
}
@@ -867,15 +869,6 @@ func validateRoomWiring(s Source) []Issue {
return issues
}
-// gridDeltas maps the horizontal exits to their grid movement. Up/Down are
-// intentionally excluded: they connect separate horizontal planes rather than
-// moving within one.
-var gridDeltas = map[world.ExitDir][2]int{
- world.North: {0, -1},
- world.South: {0, 1},
- world.East: {1, 0},
- world.West: {-1, 0},
-}
// validateRoomGrid lays each reachable horizontal plane on a 2D grid starting
// from the configured root rooms and reports when the exit layout cannot be
@@ -933,7 +926,7 @@ func validateRoomGrid(s Source) []Issue {
continue
}
- d := gridDeltas[dir]
+ d := world.DirectionDeltas[dir]
want := [2]int{c[0] + d[0], c[1] + d[1]}
if existing, ok := coordOf[target]; ok {
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
index 9a8ef02..b3f834a 100644
--- a/internal/validate/grid_test.go
+++ b/internal/validate/grid_test.go
@@ -103,3 +103,49 @@ func TestGridMultiPlaneViaUpDown(t *testing.T) {
t.Errorf("expected an overlap in plane origin 10, got: %+v", issues)
}
}
+
+func TestGridDiagonalClean(t *testing.T) {
+ // Room 1 -> NE -> Room 2 (at 1,-1). Clean diagonal placement.
+ rooms := map[int]string{
+ 1: "exits:\n northeast: 2\n",
+ 2: "name: two\n exits:\n southwest: 1\n",
+ }
+ if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
+ t.Errorf("expected no grid issues, got: %+v", issues)
+ }
+}
+
+func TestGridDiagonalOverlap(t *testing.T) {
+ // Two different rooms both resolve to (2,1):
+ // 1→SE→2→E→4 lands 4 at (2,1).
+ // 1→E→3→SE→5 lands 5 at (2,1). Room 4 != 5 → overlap.
+ rooms := map[int]string{
+ 1: "exits:\n southeast: 2\n east: 3\n",
+ 2: "exits:\n east: 4\n",
+ 3: "exits:\n southeast: 5\n",
+ 4: "name: four\n",
+ 5: "name: five\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid overlap") {
+ t.Errorf("expected a grid overlap via diagonal, got: %+v", issues)
+ }
+}
+
+func TestGridDiagonalTwist(t *testing.T) {
+ // Room 4 forced onto two different cells:
+ // path1: 1→E→2 (2 at 1,0). 2→NE→4 places 4 at (2,-1).
+ // path2: 1→N→3 (3 at 0,-1). 3→E→5 (5 at 1,-1). 5→N→4 wants 4 at (1,-2).
+ // 4 already placed at (2,-1) but wanted at (1,-2) → twist.
+ rooms := map[int]string{
+ 1: "exits:\n east: 2\n north: 3\n",
+ 2: "exits:\n northeast: 4\n",
+ 3: "exits:\n east: 5\n",
+ 4: "name: four\n",
+ 5: "exits:\n north: 4\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid twist") {
+ t.Errorf("expected a grid twist via diagonal, got: %+v", issues)
+ }
+}
diff --git a/internal/world/room.go b/internal/world/room.go
index 21e1277..15f847f 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -11,34 +11,59 @@ import (
type ExitDir string
const (
- North ExitDir = "north"
- South ExitDir = "south"
- East ExitDir = "east"
- West ExitDir = "west"
- Up ExitDir = "up"
- Down ExitDir = "down"
+ North ExitDir = "north"
+ South ExitDir = "south"
+ East ExitDir = "east"
+ West ExitDir = "west"
+ Northeast ExitDir = "northeast"
+ Northwest ExitDir = "northwest"
+ Southeast ExitDir = "southeast"
+ Southwest ExitDir = "southwest"
+ Up ExitDir = "up"
+ Down ExitDir = "down"
)
var ExitAliases = map[string]ExitDir{
- "n": North,
- "s": South,
- "e": East,
- "w": West,
- "u": Up,
- "d": Down,
+ "n": North,
+ "s": South,
+ "e": East,
+ "w": West,
+ "ne": Northeast,
+ "nw": Northwest,
+ "se": Southeast,
+ "sw": Southwest,
+ "u": Up,
+ "d": Down,
}
var OppositeExit = map[ExitDir]ExitDir{
- North: South,
- South: North,
- East: West,
- West: East,
- Up: Down,
- Down: Up,
+ North: South,
+ South: North,
+ East: West,
+ West: East,
+ Northeast: Southwest,
+ Northwest: Southeast,
+ Southeast: Northwest,
+ Southwest: Northeast,
+ Up: Down,
+ Down: Up,
}
var ExitOrder = []ExitDir{
- North, South, East, West, Up, Down,
+ North, South, East, West,
+ Northeast, Northwest, Southeast, Southwest,
+ 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},
}
type SpawnDef struct {
diff --git a/internal/world/world.go b/internal/world/world.go
index 0c992ad..3dfa99b 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -68,7 +68,7 @@ func (w *World) ResolveExit(input string) ExitDir {
}
canon := ExitDir(strings.ToLower(input))
switch canon {
- case North, South, East, West, Up, Down:
+ case North, South, East, West, Northeast, Northwest, Southeast, Southwest, Up, Down:
return canon
}
return ""