From 6b2b4bf470b655f01c5a21c5f558a6102402c214 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 11 Jun 2026 19:24:25 -0400 Subject: feat: map implemented with BFS --- .gitignore | 1 + README.md | 4 +- TODO.md | 119 +++++++++++++++++++++------ cmd/mud/main.go | 1 + config.yaml | 3 + data/rooms/1.yaml | 3 + data/rooms/22.yaml | 7 ++ data/rooms/23.yaml | 7 ++ data/rooms/24.yaml | 7 ++ data/rooms/25.yaml | 7 ++ data/rooms/26.yaml | 7 ++ data/rooms/27.yaml | 7 ++ data/rooms/28.yaml | 6 ++ data/rooms/3.yaml | 1 + data/rooms/30.yaml | 6 ++ data/rooms/31.yaml | 6 ++ internal/config/config.go | 8 ++ internal/game/cmd_look.go | 58 +++++++++++++- internal/game/cmd_toggle.go | 1 + internal/game/game.go | 1 + internal/game/map.go | 191 ++++++++++++++++++++++++++++++++++++++++++++ internal/game/map_test.go | 94 ++++++++++++++++++++++ internal/net/server.go | 2 +- internal/player/player.go | 17 ++-- 24 files changed, 527 insertions(+), 37 deletions(-) create mode 100644 data/rooms/22.yaml create mode 100644 data/rooms/23.yaml create mode 100644 data/rooms/24.yaml create mode 100644 data/rooms/25.yaml create mode 100644 data/rooms/26.yaml create mode 100644 data/rooms/27.yaml create mode 100644 data/rooms/28.yaml create mode 100644 data/rooms/30.yaml create mode 100644 data/rooms/31.yaml create mode 100644 internal/game/map.go create mode 100644 internal/game/map_test.go diff --git a/.gitignore b/.gitignore index 6a21779..b693e66 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ tc .local/ .config/ node_modules/ +REFERENCE_ONLY/ diff --git a/README.md b/README.md index 4c3caeb..102eed4 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ There is not much here yet. - Type and wait skilling - Attack and wait combat -- Semi-AFK, active, and tick-manipulating gameplay styles. +- Semi-AFK and active gameplay styles. - 600ms game ticks - 28 slot inventory - Classless. All attributes are skills that automatically level 1-99 as you use them - 100% solo friendly just like you remember from 2001. Grind and chat with friends. -- Ironman-style, slow, grind up your own gear progression. Vendors don't sell craftables. +- Ironman-style, slow, grind up your own gear progression. Vendors don't sell craftables like armor. - A detailed casino for you to waste all the credits you grind up for ## Skills diff --git a/TODO.md b/TODO.md index 244fdf4..c049acf 100644 --- a/TODO.md +++ b/TODO.md @@ -9,10 +9,10 @@ Not generic fantasy. Takes place on a terraformed asteroid. Technology has regre ## Skills (23 total, all go from 1-99) ``` -Combat: Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology -Gathering: Fishing, Woodcutting, Mining, Hacking, Scavenging, Farming +Combat: Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology, Assassin +Gathering: Fishing, Woodcutting, Mining, Hacking, Scavenging, Farming, Thieving Production: Cooking, Smithing, Crafting, Fletching, Alchemy, Construction, Firemaking -Utility: Thieving, Agility, Assassin +Utility: Agility ``` ## Architecture @@ -46,12 +46,12 @@ data/ Objects and mobs in rooms can have behaviors. Four types exist: -| Type | Verbs | Use | -|------|-------|-----| +| Type | Verbs | Use | +| -------- | --------------------- | -------------------------------------------------------------------------- | | `gather` | mine, chop, cut, fish | Resource gathering with skill checks, tool requirements, depletion/respawn | -| `use` | use | Crafting stations — consume items, produce output, optional skill checks | -| `talk` | talk, speak, ask | NPC conversation trees with choices, conditions, actions | -| `toggle` | pull, push | Levers, switches, gates — set world flags, conditional on existing values | +| `use` | use | Crafting stations — consume items, produce output, optional skill checks | +| `talk` | talk, speak, ask | NPC conversation trees with choices, conditions, actions | +| `toggle` | pull, push | Levers, switches, gates — set world flags, conditional on existing values | Behaviors are defined in `data/behaviors/.yaml`. Objects reference them with `behavior: `. Mobs can also carry `behavior: ` — StartAction falls back to mob lookup when no object matches. @@ -69,27 +69,27 @@ Set `nest_chance: 256` on a gather behavior to give a 1/256 independent chance t Used by exits, talk options, on-enter scripts, and toggle checks: -| Field | Scope | Example | -|-------|-------|---------| -| `flag` | World (shared by all players) | `flag: gate_open` | +| Field | Scope | Example | +| ------------- | ------------------------------ | -------------------------------- | +| `flag` | World (shared by all players) | `flag: gate_open` | | `player_flag` | Per-character (quest progress) | `player_flag: finished_tutorial` | -| `has_item` | Player inventory check | `has_item: bronze_key` | -| `all_of` | All sub-conditions must pass | Nested list of conditions | -| `any_of` | Any sub-condition passes | Nested list of conditions | -| `not` | Invert the check | `not: true` | +| `has_item` | Player inventory check | `has_item: bronze_key` | +| `all_of` | All sub-conditions must pass | Nested list of conditions | +| `any_of` | Any sub-condition passes | Nested list of conditions | +| `not` | Invert the check | `not: true` | ### Node Actions (talk dialog) Set on nodes in talk behaviors: -| Field | Effect | -|---|---| -| `set_flags` | Sets world flags (global) | +| Field | Effect | +| ------------------ | ------------------------------------------------------ | +| `set_flags` | Sets world flags (global) | | `set_player_flags` | Sets player-local flags (per-character, saved to YAML) | -| `give_item` | Gives an item to inventory | -| `take_item` | Removes an item from inventory | -| `teleport` | Moves player to a room ID | -| `heal` | Restores hitpoints | +| `give_item` | Gives an item to inventory | +| `take_item` | Removes an item from inventory | +| `teleport` | Moves player to a room ID | +| `heal` | Restores hitpoints | All fields in a single action are processed together. @@ -193,14 +193,83 @@ See WORLDBUILDING.md for full examples of every data type with explanations. ## Action Plan -### Phase 1: Core Skill Chains (breadth over depth) +### Phase 1: Tiny ASCII Map + +- [ ] Limit the room description to 70 characters wide. Add a setting named "maxWidth" in an appropriate section of config.yaml along with comment explaining it. + +Next to the block of room description text should be an ASCII mini-map showing the current room as @ and a grid of the rooms and connections surrounding it. A full-size ASCII map for the "map" command will eventually be implemented so keep that in mind for methodology and potential functions we can reuse. +Each line of the map should print on the maxWidth+2 character after the description (so there's always at least one space between the description and map) +If the map is longer than the description, just keep printing new lines for the map. + +The first line is always ╔═════╗ +The last line is always ╚═════╝ +Each of the 5 inner lines begins and ends with ║ + +The map shows up to 9 rooms and any exits connecting them. The 5 inner lines have 7 characters each (including the border) which be defined like this: + +Line 1: [║, Room A (shown if there is a west exit from North Room or north exit from West Room), — if there is a west exit from North Room, North Room, — if there is an east exit from North Room, Room B (shown if there is an east exit from North Room or north exit from East Room), ║] +Line 2: [║, | if there is a north exit from West Room, Blank, | if there is a north exit from Current Room, ↑ if there is an up exit from Current Room, | if there is a north exit from East Room, ║] +Line 3: [║, West Room, — if there is a west exit from Current Room, @, — if there is an east exit from Current Room, East Room, ║] +Line 4: [║, | if there is a south exit from West Room, ↓ if there is a down exit from Current Room, | if there is a south exit from Current Room, Blank, | if there is a south exit from East Room, ║] +Line 5: [║, Room C (shown if there is a west exit from South Room or south exit from West Room), — if there is a west exit from South Room, South Room, — if there is an east exit from South Room, Room D (shown if there is an east exit from South Room or south exit from East Room), ║] + +Note that North Room, East Room, South Room, and West Room can still show up even if there isn't a direct link from the current room to them. For example if there is a to East Room, then that has an exit north, then that room has an exit west, the 'north room' still shows up on the map. + +Each room has a parameter map_symbol to identify it on the map. + +This is how the map looks when every possible room and exit is shown. o is the map_symbol for each room in this example: + +╔═════╗ +║o—o—o║ +║| |↑|║ +║o—@—o║ +║|↓| |║ +║o—o—o║ +╚═════╝ + +This is a map with fewer connections. Note that 'Room A' and 'North Room' show up despite only being connected through East Room and Room B. + +╔═════╗ +║o—o—o║ +║ |║ +║o—@—o║ +║| ║ +║o ║ +╚═════╝ + + +Here's how the room description would look limited to 70 characters long with the map to the right: + +Central Market +The market square pulses with the unbridled energy of a thousand ╔═════╗ +voices, a symphony of commerce and humanity that hasn't changed in ║o—o—o║ +generations. Overhead, September clouds drift lazily across the sky, ║| |↑|║ +occasionally releasing weak sunlight that transforms the ancient ║o—@—o║ +stone beneath countless feet into patches of burnished gold. ║|↓| |║ + ║o—o—o║ + ╚═════╝ + +- [ ] This is a feature that can be toggled with the "tinymap" toggle. If the toggle is off, don't print the map at all. +- [ ] Add a toggle "left-tinymap". If this is enabled, the map should be printed on each line before the room description. For example: + +Central Market +╔═════╗ The market square pulses with the unbridled energy of a thousand +║o—o—o║ voices, a symphony of commerce and humanity that hasn't changed in +║| |↑|║ generations. Overhead, September clouds drift lazily across the sky, +║o—@—o║ occasionally releasing weak sunlight that transforms the ancient +║|↓| |║ stone beneath countless feet into patches of burnished gold. +║o—o—o║ +╚═════╝ + + +### Phase 2: Core Skill Chains (breadth over depth) - [x] Woodcutting — tree objects + axes, shared depletion, bird's nests - [ ] Cooking — fire/range object (`use` type), raw fish/meat → cooked food - [ ] Smithing — furnace + anvil objects (`use` type), ore → bars → weapons/armor - [ ] Fletching — fletching table object (`use` type), logs → arrow shafts/bows - [ ] Equipment: bronze/iron weapons, axes, cooked trout, arrows, bows -### Phase 2: World Depth +### Phase 3: World Depth - [ ] Shop system — talk node action for buy/sell interface with credits - [ ] Bank system — deposit/withdraw items at bank booth objects - [ ] Ranged combat — bows/crossbows/ammo, add Ranged to combat formulas @@ -208,7 +277,7 @@ See WORLDBUILDING.md for full examples of every data type with explanations. - [ ] Flee command - [ ] Equipment skill requirements -### Phase 3: Polish +### Phase 4: Polish - [ ] Hacking, Thieving, Agility, Alchemy, Construction, Crafting, Scavenging, Farming, Assassin - [ ] Quest system (already supported via talk nodes + world/player flags) - [ ] PvP combat, multi-combat zones diff --git a/cmd/mud/main.go b/cmd/mud/main.go index e3e3d32..161392c 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -34,6 +34,7 @@ func main() { } g := game.New(dataDir) + g.MapWidth = cfg.Game.MaxWidth g.Ticks.Start() defer g.Ticks.Stop() diff --git a/config.yaml b/config.yaml index e980438..448aa68 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,6 @@ +game: + maxWidth: 70 # maximum width for room description text when displayed alongside the mini-map + telnet: enabled: true port: 4000 diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml index 77c49a0..c8d8aa1 100644 --- a/data/rooms/1.yaml +++ b/data/rooms/1.yaml @@ -4,6 +4,9 @@ description: "The central square of Lumbridge. A fountain gurgles peacefully in map_symbol: "S" exits: east: 2 + west: 22 + up: 30 + down: 31 objects: - id: lumby_fountain mobs: diff --git a/data/rooms/22.yaml b/data/rooms/22.yaml new file mode 100644 index 0000000..478995b --- /dev/null +++ b/data/rooms/22.yaml @@ -0,0 +1,7 @@ +id: 22 +name: "West of Town Square" +description: "" +map_symbol: "W" +exits: + east: 1 + west: 23 diff --git a/data/rooms/23.yaml b/data/rooms/23.yaml new file mode 100644 index 0000000..f7301f6 --- /dev/null +++ b/data/rooms/23.yaml @@ -0,0 +1,7 @@ +id: 23 +name: "West of West of Town Square" +description: "Here is a description of where we are!" +map_symbol: "D" +exits: + east: 22 + south: 24 diff --git a/data/rooms/24.yaml b/data/rooms/24.yaml new file mode 100644 index 0000000..60bf0d8 --- /dev/null +++ b/data/rooms/24.yaml @@ -0,0 +1,7 @@ +id: 24 +name: "South of West of West of Town Square" +description: "Here is a description of where we are!" +map_symbol: "N" +exits: + north: 23 + east: 25 diff --git a/data/rooms/25.yaml b/data/rooms/25.yaml new file mode 100644 index 0000000..a3dd843 --- /dev/null +++ b/data/rooms/25.yaml @@ -0,0 +1,7 @@ +id: 25 +name: "East of South of West of West of Town Square" +description: "Here is a description of where we are!" +map_symbol: "N" +exits: + west: 24 + east: 26 diff --git a/data/rooms/26.yaml b/data/rooms/26.yaml new file mode 100644 index 0000000..a2899e7 --- /dev/null +++ b/data/rooms/26.yaml @@ -0,0 +1,7 @@ +id: 26 +name: "East of South of West of West of Town Square" +description: "Here is a description of where we are!" +map_symbol: "Q" +exits: + west: 25 + south: 27 diff --git a/data/rooms/27.yaml b/data/rooms/27.yaml new file mode 100644 index 0000000..d435424 --- /dev/null +++ b/data/rooms/27.yaml @@ -0,0 +1,7 @@ +id: 27 +name: "East of South of West of West of Town Square" +description: "Here is a description of where we are!" +map_symbol: "J" +exits: + west: 28 + north: 26 diff --git a/data/rooms/28.yaml b/data/rooms/28.yaml new file mode 100644 index 0000000..03913d5 --- /dev/null +++ b/data/rooms/28.yaml @@ -0,0 +1,6 @@ +id: 28 +name: "East of South of West of West of Town Square" +description: "Here is a description of where we are!" +map_symbol: "r" +exits: + east: 27 diff --git a/data/rooms/3.yaml b/data/rooms/3.yaml index b54546f..8e4d1ed 100644 --- a/data/rooms/3.yaml +++ b/data/rooms/3.yaml @@ -1,6 +1,7 @@ id: 3 name: "Fly Fishing Pool" description: "A calm pool where insects hover over the water. Perfect for fly fishing." +map_symbol: "X" exits: south: 2 objects: diff --git a/data/rooms/30.yaml b/data/rooms/30.yaml new file mode 100644 index 0000000..8c167eb --- /dev/null +++ b/data/rooms/30.yaml @@ -0,0 +1,6 @@ +id: 30 +name: "Above Town Square" +description: "The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district." +map_symbol: "A" +exits: + down: 1 diff --git a/data/rooms/31.yaml b/data/rooms/31.yaml new file mode 100644 index 0000000..fc57b2d --- /dev/null +++ b/data/rooms/31.yaml @@ -0,0 +1,6 @@ +id: 31 +name: "Below Town Square" +description: "The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district." +map_symbol: "b" +exits: + up: 1 diff --git a/internal/config/config.go b/internal/config/config.go index 8ff5740..1682a01 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,11 +7,16 @@ import ( ) type Config struct { + Game GameConfig `yaml:"game"` Telnet TelnetConfig `yaml:"telnet"` HTTP HTTPConfig `yaml:"http"` HTTPS HTTPSConfig `yaml:"https"` } +type GameConfig struct { + MaxWidth int `yaml:"maxWidth"` +} + type TelnetConfig struct { Enabled bool `yaml:"enabled"` Port int `yaml:"port"` @@ -31,6 +36,9 @@ type HTTPSConfig struct { func Default() *Config { return &Config{ + Game: GameConfig{ + MaxWidth: 70, + }, Telnet: TelnetConfig{ Enabled: true, Port: 4000, diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 48b7daf..0bec915 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -23,9 +23,20 @@ func (g *Game) doLook(sess *net.Session) { sess.WriteLines( "", room.Name, - room.Description, ) + if p.Toggles["tinymap"] && g.MapWidth > 0 { + descLines := wrapText(room.Description, g.MapWidth) + mapLines := buildTinyMap(g, p.RoomID) + if len(mapLines) == 7 { + g.writeLookSideBySide(sess, p, descLines, mapLines) + } else { + sess.WriteLine(room.Description) + } + } else { + sess.WriteLine(room.Description) + } + mobs := g.MobStore.MobsInRoom(p.RoomID) if len(mobs) > 0 { sort.Slice(mobs, func(i, j int) bool { @@ -486,3 +497,48 @@ func mobInstanceIdx(mob *world.MobInstance, roomMobs []*world.MobInstance) int { } return 0 } + +func wrapText(text string, width int) []string { + if width <= 0 { + return []string{text} + } + words := strings.Fields(text) + if len(words) == 0 { + return nil + } + var lines []string + current := words[0] + for _, word := range words[1:] { + if len(current)+1+len(word) <= width { + current += " " + word + } else { + lines = append(lines, current) + current = word + } + } + lines = append(lines, current) + return lines +} + +func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLines []string, mapLines []string) { + total := len(descLines) + if len(mapLines) > total { + total = len(mapLines) + } + leftMap := p.Toggles["left-tinymap"] + for i := 0; i < total; i++ { + desc := "" + if i < len(descLines) { + desc = descLines[i] + } + mapLine := "" + if i < len(mapLines) { + mapLine = mapLines[i] + } + if leftMap { + sess.WriteLine(fmt.Sprintf("%s %s", mapLine, desc)) + } else { + sess.WriteLine(fmt.Sprintf("%-*s %s", g.MapWidth, desc, mapLine)) + } + } +} diff --git a/internal/game/cmd_toggle.go b/internal/game/cmd_toggle.go index 25b8955..a399483 100644 --- a/internal/game/cmd_toggle.go +++ b/internal/game/cmd_toggle.go @@ -14,6 +14,7 @@ var toggles = []struct { }{ {"description", "Long room descriptions"}, {"tinymap", "Mini-map display"}, + {"left-tinymap", "Mini-map on left side of descriptions"}, {"xpdrops", "XP drop messages"}, {"exits", "Long exit display in look"}, {"mobenter", "Messages when mobs enter the room"}, diff --git a/internal/game/game.go b/internal/game/game.go index 50c8f3d..0675a13 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -28,6 +28,7 @@ type Game struct { charsMu sync.Mutex loggedInChars map[string]*net.Session combatPadWidth int + MapWidth int } func New(dataDir string) *Game { diff --git a/internal/game/map.go b/internal/game/map.go new file mode 100644 index 0000000..cd1012b --- /dev/null +++ b/internal/game/map.go @@ -0,0 +1,191 @@ +package game + +import ( + "thirdcollapse/internal/world" +) + +type mapGraph struct { + posToRoom map[[2]int]int + roomToPos map[int][2]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) *mapGraph { + mg := &mapGraph{ + posToRoom: make(map[[2]int]int), + roomToPos: make(map[int][2]int), + } + + type node struct { + roomID int + x, y int + } + queue := []node{{startRoomID, 0, 0}} + mg.posToRoom[[2]int{0, 0}] = startRoomID + mg.roomToPos[startRoomID] = [2]int{0, 0} + + for len(queue) > 0 { + n := queue[0] + queue = queue[1:] + + room, ok := loadRoom(g, n.roomID) + if !ok { + continue + } + + for _, d := range bfsDirs { + targetID, ok := exitTarget(room, d.dir) + if !ok { + continue + } + if _, visited := mg.roomToPos[targetID]; visited { + continue + } + nx, ny := n.x + d.dx, n.y + d.dy + mg.posToRoom[[2]int{nx, ny}] = targetID + mg.roomToPos[targetID] = [2]int{nx, ny} + queue = append(queue, node{targetID, nx, ny}) + } + } + + return mg +} + +func buildTinyMap(g *Game, roomID int) []string { + mg := buildGraph(g, roomID) + + grid := make([][]rune, 5) + for i := range grid { + grid[i] = make([]rune, 5) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + + for y := -1; y <= 1; y++ { + for x := -1; x <= 1; x++ { + pos := [2]int{x, y} + rid, ok := mg.posToRoom[pos] + if !ok { + continue + } + gr := (y + 1) * 2 + gc := (x + 1) * 2 + if rid == roomID { + grid[gr][gc] = '@' + } else { + grid[gr][gc] = roomMapSymbol(g, rid) + } + } + } + + for y := -1; y <= 1; y++ { + for x := -1; x <= 0; x++ { + leftPos := [2]int{x, y} + rightPos := [2]int{x + 1, y} + leftRoom, leftOK := mg.posToRoom[leftPos] + rightRoom, rightOK := mg.posToRoom[rightPos] + if !leftOK || !rightOK { + continue + } + if exitsConnect(g, leftRoom, rightRoom, world.East, world.West) { + grid[(y+1)*2][(x+1)*2+1] = '─' + } + } + } + + for y := -1; y <= 0; y++ { + for x := -1; x <= 1; x++ { + topPos := [2]int{x, y} + bottomPos := [2]int{x, y + 1} + topRoom, topOK := mg.posToRoom[topPos] + bottomRoom, bottomOK := mg.posToRoom[bottomPos] + if !topOK || !bottomOK { + continue + } + if exitsConnect(g, topRoom, bottomRoom, world.South, world.North) { + grid[(y+1)*2+1][(x+1)*2] = '│' + } + } + } + + cur, _ := loadRoom(g, roomID) + if cur != nil { + if _, ok := exitTarget(cur, world.Up); ok { + grid[1][3] = '↑' + } + if _, ok := exitTarget(cur, world.Down); ok { + grid[3][1] = '↓' + } + } + + lines := make([]string, 7) + lines[0] = "╔═════╗" + for row := 0; row < 5; row++ { + lines[row+1] = "║" + string(grid[row]) + "║" + } + lines[6] = "╚═════╝" + + return lines +} + +func exitsConnect(g *Game, room1, room2 int, dir12, dir21 world.ExitDir) bool { + r1, ok := loadRoom(g, room1) + if !ok { + return false + } + if id, ok := exitTarget(r1, dir12); ok && id == room2 { + return true + } + r2, ok := loadRoom(g, room2) + if !ok { + return false + } + if id, ok := exitTarget(r2, dir21); ok && id == room1 { + return true + } + return false +} + +func exitTarget(room *world.Room, dir world.ExitDir) (int, bool) { + if room == nil { + return 0, false + } + exit, ok := room.Exits[dir] + if !ok { + return 0, false + } + return exit.Room, true +} + +func loadRoom(g *Game, roomID int) (*world.Room, bool) { + if roomID == 0 { + return nil, false + } + room, err := g.World.LoadRoom(roomID) + if err != nil { + return nil, false + } + return room, true +} + +func roomMapSymbol(g *Game, roomID int) rune { + room, ok := loadRoom(g, roomID) + if !ok { + return '?' + } + if room.MapSymbol != "" { + runes := []rune(room.MapSymbol) + return runes[0] + } + return 'o' +} diff --git a/internal/game/map_test.go b/internal/game/map_test.go new file mode 100644 index 0000000..a50c2c2 --- /dev/null +++ b/internal/game/map_test.go @@ -0,0 +1,94 @@ +package game + +import ( + "strings" + "testing" + + "thirdcollapse/internal/world" +) + +func TestBuildTinyMap(t *testing.T) { + g := &Game{ + World: world.New("../../data"), + MapWidth: 70, + } + + tests := []struct { + name string + roomID int + want []string // expected lines, or nil to just check count + }{ + { + name: "room 1 has east exit", + roomID: 1, + }, + { + name: "room 2 has west/north/east", + roomID: 2, + }, + { + name: "room 4 has west/north/east", + roomID: 4, + }, + { + name: "room 21 has west only", + roomID: 21, + }, + { + name: "room 22 west of town square", + roomID: 22, + }, + { + name: "room 25 east of south of west", + roomID: 25, + }, + { + name: "room 8 hacking lab", + roomID: 8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines := buildTinyMap(g, tt.roomID) + if len(lines) != 7 { + t.Fatalf("expected 7 lines, got %d", len(lines)) + } + if lines[0] != "╔═════╗" { + t.Errorf("line 0: want ╔═════╗, got %s", lines[0]) + } + if lines[6] != "╚═════╝" { + t.Errorf("line 6: want ╚═════╝, got %s", lines[6]) + } + for i := 1; i <= 5; i++ { + if !strings.HasPrefix(lines[i], "║") || !strings.HasSuffix(lines[i], "║") { + t.Errorf("line %d: should have ║ borders, got %s", i, lines[i]) + } + if len([]rune(lines[i])) != 7 { + t.Errorf("line %d: expected 7 runes, got %d in %q", i, len([]rune(lines[i])), lines[i]) + } + } + t.Logf("Room %d map:\n%s", tt.roomID, strings.Join(lines, "\n")) + }) + } +} + +func TestWrapText(t *testing.T) { + tests := []struct { + text string + width int + want int // expected number of lines + }{ + {"hello world", 70, 1}, + {"hello world", 5, 2}, + {"", 70, 0}, + {"a b c d e f g h i j", 5, 4}, + } + + for _, tt := range tests { + result := wrapText(tt.text, tt.width) + if len(result) != tt.want { + t.Errorf("wrapText(%q, %d) = %d lines, want %d: %v", tt.text, tt.width, len(result), tt.want, result) + } + } +} diff --git a/internal/net/server.go b/internal/net/server.go index 7e73bc9..084641f 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -241,7 +241,7 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { . * , , welcome to third collapse . * - 3333333 333333 333 , 333 333333 3333333 333333 33333333 + 3333333 333333 333 , 333 333333 3333333 333333 33333333 !33 33! 333 33! 33! 33! 333 33! 333 !33 33! !3! 3!3 !3! 3!! 3!! + 3!3!3!3! 3!33!3! !33!! 3!!!:! :!! !!: !!! !!: !!: !!: !!! !!: !:! !!: diff --git a/internal/player/player.go b/internal/player/player.go index cccf979..56ab06c 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -141,14 +141,15 @@ func New(name string) *Player { Equipment: make(map[object.EquipSlot]string), AttackStyle: Accurate, Toggles: map[string]bool{ - "description": true, - "tinymap": true, - "xpdrops": true, - "exits": true, - "mobenter": true, - "mobleave": true, - "mobspawn": true, - "reserve": true, + "description": true, + "tinymap": true, + "left-tinymap": false, + "xpdrops": true, + "exits": true, + "mobenter": true, + "mobleave": true, + "mobspawn": true, + "reserve": true, }, RoomID: 0, } -- cgit v1.2.3