diff options
| author | historia <[not public]> | 2026-06-29 17:19:40 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-29 17:19:40 -0400 |
| commit | feb80b7dde200d4121cd9f9c583a08f9869c5588 (patch) | |
| tree | 3271cae1d74c8f3b84ef3ae84f8f62e563029dff | |
| parent | c6cbf76b33ac20d071d25e74b70f6901476193a6 (diff) | |
| download | thehouseoficarus-feb80b7dde200d4121cd9f9c583a08f9869c5588.tar.gz | |
feat: incardinal directions (nw, ne, sw, se). probably janky in ways I haven't yet discovered.
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 "" |
