aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md4
-rw-r--r--TODO.md119
-rw-r--r--cmd/mud/main.go1
-rw-r--r--config.yaml3
-rw-r--r--data/rooms/1.yaml3
-rw-r--r--data/rooms/22.yaml7
-rw-r--r--data/rooms/23.yaml7
-rw-r--r--data/rooms/24.yaml7
-rw-r--r--data/rooms/25.yaml7
-rw-r--r--data/rooms/26.yaml7
-rw-r--r--data/rooms/27.yaml7
-rw-r--r--data/rooms/28.yaml6
-rw-r--r--data/rooms/3.yaml1
-rw-r--r--data/rooms/30.yaml6
-rw-r--r--data/rooms/31.yaml6
-rw-r--r--internal/config/config.go8
-rw-r--r--internal/game/cmd_look.go58
-rw-r--r--internal/game/cmd_toggle.go1
-rw-r--r--internal/game/game.go1
-rw-r--r--internal/game/map.go191
-rw-r--r--internal/game/map_test.go94
-rw-r--r--internal/net/server.go2
-rw-r--r--internal/player/player.go17
24 files changed, 527 insertions, 37 deletions
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/<id>.yaml`. Objects reference them with `behavior: <id>`. Mobs can also carry `behavior: <id>` — 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,
}