aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
-rw-r--r--TODO.md2
-rw-r--r--WORLDBUILDING.md10
-rw-r--r--data/help/queue_silently.yaml (renamed from data/help/queue_actions_silently.yaml)6
-rw-r--r--data/help/room_desc_width.yaml14
-rw-r--r--data/help/unicode.yaml14
-rw-r--r--internal/game/action_burn.go4
-rw-r--r--internal/game/action_gather.go9
-rw-r--r--internal/game/action_state.go7
-rw-r--r--internal/game/action_use.go2
-rw-r--r--internal/game/cmd_attack.go4
-rw-r--r--internal/game/cmd_look.go27
-rw-r--r--internal/game/cmd_map.go2
-rw-r--r--internal/game/cmd_option.go9
-rw-r--r--internal/game/game.go8
-rw-r--r--internal/game/map.go76
-rw-r--r--internal/game/map_test.go8
-rw-r--r--internal/game/table.go100
-rw-r--r--internal/game/tick.go4
-rw-r--r--internal/player/player.go16
20 files changed, 249 insertions, 75 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 0b829a5..8401f4c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -201,7 +201,7 @@ Each 600ms tick, `ProcessQueuedCommands` in `internal/game/game.go`:
**ActionState** (`internal/game/action_state.go`) tracks what a player is doing for `look` display. Timed actions (gather, combat, burn, stoke, use) persist the state until interrupted. One-tick actions (move, get, drop) clear after one tick.
-Queue feedback is controlled by the `queue_actions_silently` player option (default on).
+Queue feedback is controlled by the `queue_silently` player option (default on).
## Places to Be Careful
diff --git a/TODO.md b/TODO.md
index 1a91969..75cdc59 100644
--- a/TODO.md
+++ b/TODO.md
@@ -214,7 +214,7 @@ See WORLDBUILDING.md for full examples of every data type with explanations.
### Phase 1: ASCII map implemented with BFS
- [x] Replaced `toggle` command with `option`/`options` — supports bool (on/off), string (choice lists), and int option types
-- [x] All old toggles migrated to the option system (tinymap, left-tinymap, xpdrops, exits, mobenter, mobleave, mobspawn, reserve, depletion, description)
+- [x] All old toggles migrated to the option system (tiny_map, xp_drops, exits, mob_enter, mob_leave, mob_spawn, reserve, depletion, description)
- [x] Added `color` option (none, ansi, xterm256)
- [x] Added `mapwidth` and `mapheight` options for the `map` command viewport size
- [x] `map` command — renders a full ASCII map centered on the player using BFS graph traversal with geometric coordinate assignment
diff --git a/WORLDBUILDING.md b/WORLDBUILDING.md
index 93fa16d..93c40aa 100644
--- a/WORLDBUILDING.md
+++ b/WORLDBUILDING.md
@@ -905,12 +905,12 @@ Players can toggle personal settings with the `toggle` command:
| Toggle | Effect |
|---|---|
| `description` | Show full room description when moving |
-| `tinymap` | Mini-map display (not yet implemented) |
-| `xpdrops` | Show XP gained in gather/craft/combat messages |
+| `tiny_map` | Mini-map display: `off`/`right`/`left` |
+| `xp_drops` | Show XP gained in gather/craft/combat messages |
| `exits` | Show exit destinations inline in look output |
-| `mobenter` | Notify when a mob enters the room |
-| `mobleave` | Notify when a mob leaves the room |
-| `mobspawn` | Notify when a mob spawns in the area |
+| `mob_enter` | Notify when a mob enters the room |
+| `mob_leave` | Notify when a mob leaves the room |
+| `mob_spawn` | Notify when a mob spawns in the area |
| `reserve` | Show full reserved item details in look |
| `depletion` | Show depletion and despawn timers on objects |
diff --git a/data/help/queue_actions_silently.yaml b/data/help/queue_silently.yaml
index 8101c5c..f1ca8b6 100644
--- a/data/help/queue_actions_silently.yaml
+++ b/data/help/queue_silently.yaml
@@ -1,4 +1,4 @@
-name: "queue_actions_silently"
+name: "queue_silently"
category: "Options"
description: |
Controls whether queued game actions produce confirmation messages.
@@ -9,7 +9,7 @@ description: |
When off: A brief "You prepare to X." message is shown for each queued
action, confirming that it has been added to the queue.
- Usage: option queue_actions_silently on
- option queue_actions_silently off
+ Usage: option queue_silently on
+ option queue_silently off
See also: help queued, help actions, help option
diff --git a/data/help/room_desc_width.yaml b/data/help/room_desc_width.yaml
new file mode 100644
index 0000000..7c67a38
--- /dev/null
+++ b/data/help/room_desc_width.yaml
@@ -0,0 +1,14 @@
+name: "room_desc_width"
+category: "Options"
+description: |
+ Maximum width for room description text when displayed alongside
+ the mini-map.
+
+ Type: integer
+ Default: 70
+
+ When the tinymap option is enabled, room descriptions are wrapped
+ to this width and displayed side-by-side with the mini-map.
+
+ Usage: option room_desc_width 80
+ option room_desc_width 50
diff --git a/data/help/unicode.yaml b/data/help/unicode.yaml
new file mode 100644
index 0000000..77cd847
--- /dev/null
+++ b/data/help/unicode.yaml
@@ -0,0 +1,14 @@
+name: "unicode"
+category: "Options"
+description: |
+ Enable unicode box-drawing characters for map and table display.
+
+ Type: boolean (on/off)
+ Default: on
+
+ When on, maps use box-drawing characters for borders and the
+ options table uses a formatted grid. When off, ASCII fallback
+ characters are used instead (. and : for maps, + and | for tables).
+
+ Usage: option unicode off
+ option unicode on
diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go
index 917a6fe..b16ba42 100644
--- a/internal/game/action_burn.go
+++ b/internal/game/action_burn.go
@@ -225,7 +225,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) {
g.CancelAction(p)
msg := fmt.Sprintf("You manage to get a fire going!")
- if xp > 0 && p.OptionBool("xpdrops") {
+ if xp > 0 && p.OptionBool("xp_drops") {
msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.Firemaking])
}
sess.WriteLine(msg)
@@ -282,7 +282,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) {
g.AccountStore.SaveCharacter(p)
msg := fmt.Sprintf("You throw %s onto the fire.", name)
- if xp > 0 && p.OptionBool("xpdrops") {
+ if xp > 0 && p.OptionBool("xp_drops") {
msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.Firemaking])
}
sess.WriteLine(msg)
diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go
index 512be81..4e1e609 100644
--- a/internal/game/action_gather.go
+++ b/internal/game/action_gather.go
@@ -138,7 +138,12 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
}
}
}
- p.ActionState = &ActionState{Type: ActionGathering, TargetName: obj.Name, ToolName: toolName}
+ verb := cfg.Skill
+ switch cfg.Skill {
+ case "woodcutting":
+ verb = "chopping"
+ }
+ p.ActionState = &ActionState{Type: ActionGathering, TargetName: obj.Name, ToolName: toolName, Verb: verb}
data := map[string]any{
"behavior_id": obj.BehaviorID,
@@ -257,7 +262,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
if msg == "" {
msg = fmt.Sprintf("You manage to get some %s.", itemName)
}
- if xp > 0 && p.OptionBool("xpdrops") {
+ if xp > 0 && p.OptionBool("xp_drops") {
msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)])
}
sess.WriteLine(msg)
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index fba5319..8db4a7b 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -25,6 +25,7 @@ type ActionState struct {
TargetName string
ToolName string
Direction string
+ Verb string
}
func (a *ActionState) Description() string {
@@ -33,7 +34,11 @@ func (a *ActionState) Description() string {
}
switch a.Type {
case ActionGathering:
- return "mining a " + a.TargetName
+ desc := a.Verb + " a " + a.TargetName
+ if a.ToolName != "" {
+ desc += " with a " + a.ToolName
+ }
+ return desc
case ActionCombating:
return "fighting a " + a.TargetName
case ActionMoving:
diff --git a/internal/game/action_use.go b/internal/game/action_use.go
index 8987ee0..ec63723 100644
--- a/internal/game/action_use.go
+++ b/internal/game/action_use.go
@@ -108,7 +108,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
itemName = def.Name
}
line := fmt.Sprintf("You make a %s.", itemName)
- if cfg.XP > 0 && p.OptionBool("xpdrops") {
+ if cfg.XP > 0 && p.OptionBool("xp_drops") {
line += fmt.Sprintf(" (+%dxp %s)", cfg.XP, player.SkillAbbr[player.SkillName(cfg.Skill)])
}
sess.WriteLine(line)
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index ef80189..ca5bc4c 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -215,7 +215,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
prefix := fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)
hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP)
line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)
- if p.OptionBool("xpdrops") && len(gains) > 0 {
+ if p.OptionBool("xp_drops") && len(gains) > 0 {
var parts []string
for _, gain := range gains {
parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)]))
@@ -462,7 +462,7 @@ func (g *Game) respawnMob(instanceID string) {
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(homeRoom) {
- if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mobspawn") {
+ if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mob_spawn") {
sess.WriteLine(fmt.Sprintf("\n%s (level %d) spawns in the area.", mobDisplayName(inst, false), mobCombatLevel(inst)))
}
}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 3b81f80..7efb65a 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -25,20 +25,23 @@ func (g *Game) doLook(sess *net.Session) {
room.Name,
)
- mapWidth := p.OptionInt("max_room_description_width")
- if mapWidth <= 0 {
- mapWidth = 70
+ descWidth := p.OptionInt("room_desc_width")
+ if descWidth <= 0 {
+ descWidth = 70
}
- if p.OptionBool("tinymap") && mapWidth > 0 {
- descLines := wrapText(room.Description, mapWidth)
- mapLines := buildTinyMap(g, p.RoomID)
+ descLines := wrapText(room.Description, descWidth)
+ wroteDesc := false
+ if p.OptionString("tiny_map") != "off" {
+ mapLines := buildTinyMap(g, p.RoomID, mapGlyphsForPlayer(p.OptionBool("unicode")))
if len(mapLines) == 7 {
g.writeLookSideBySide(sess, p, descLines, mapLines)
- } else {
- sess.WriteLine(room.Description)
+ wroteDesc = true
+ }
+ }
+ if !wroteDesc {
+ for _, line := range descLines {
+ sess.WriteLine(line)
}
- } else {
- sess.WriteLine(room.Description)
}
mobs := g.MobStore.MobsInRoom(p.RoomID)
@@ -574,8 +577,8 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine
if len(mapLines) > total {
total = len(mapLines)
}
- leftMap := p.OptionBool("left_tinymap")
- mapWidth := p.OptionInt("max_room_description_width")
+ leftMap := p.OptionString("tiny_map") == "left"
+ mapWidth := p.OptionInt("room_desc_width")
if mapWidth <= 0 {
mapWidth = 70
}
diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go
index e0413d3..f4ec11c 100644
--- a/internal/game/cmd_map.go
+++ b/internal/game/cmd_map.go
@@ -16,7 +16,7 @@ func (g *Game) doMap(sess *net.Session) {
mapHeight = 5
}
- lines := buildFullMap(g, p.RoomID, mapWidth, mapHeight)
+ lines := buildFullMap(g, p.RoomID, mapWidth, mapHeight, mapGlyphsForPlayer(p.OptionBool("unicode")))
mode := p.OptionString("mappadding")
if mode == "none" || mode == "x" {
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 49085db..9302baa 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -14,11 +14,16 @@ func (g *Game) doOption(sess *net.Session, input string) {
if input == "" {
sess.WriteLine("")
- sess.WriteLine(fmt.Sprintf(" %-14s %-10s %-22s %s", "Option", "Value", "Valid", "Description"))
+ table := &Table{
+ Columns: []string{"Option", "Value", "Valid", "Description"},
+ }
for _, def := range player.OptionDefs {
val := formatOptionValue(p, &def)
valid := formatValidValues(&def)
- sess.WriteLine(fmt.Sprintf(" %-14s %-10s %-22s %s", def.Name, val, valid, def.Description))
+ table.Rows = append(table.Rows, []string{def.Name, val, valid, def.Description})
+ }
+ for _, line := range table.Render(p.OptionBool("unicode")) {
+ sess.WriteLine(line)
}
return
}
diff --git a/internal/game/game.go b/internal/game/game.go
index 37a1f3f..7447215 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -177,10 +177,9 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
Args: strings.Join(parts[1:], " "),
Timestamp: time.Now(),
})
- if !p.OptionBool("queue_actions_silently") {
+ if !p.OptionBool("queue_silently") {
sess.WriteLine(fmt.Sprintf("\nYou prepare to %s.", cmd))
}
- sess.Write("\r\n> ")
return
}
@@ -191,10 +190,9 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
Timestamp: time.Now(),
}
g.cancelRest(p.Name)
- if !p.OptionBool("queue_actions_silently") {
+ if !p.OptionBool("queue_silently") {
sess.WriteLine(fmt.Sprintf("\nYou prepare to %s.", cmd))
}
- sess.Write("\r\n> ")
}
func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) {
@@ -401,6 +399,7 @@ func (g *Game) ProcessQueuedCommands() {
if len(parts) > 0 {
g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args)
}
+ qc.Session.Write("\r\n> ")
}
delete(g.freeQueue, p.Name)
}
@@ -418,6 +417,7 @@ func (g *Game) ProcessQueuedCommands() {
if len(parts) > 0 {
g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args)
}
+ qc.Session.Write("\r\n> ")
}
g.activeQueue = make(map[string]*QueuedCommand)
diff --git a/internal/game/map.go b/internal/game/map.go
index e7738bd..8b14e25 100644
--- a/internal/game/map.go
+++ b/internal/game/map.go
@@ -6,6 +6,30 @@ import (
"thirdcollapse/internal/world"
)
+type mapGlyphs struct {
+ topLeft, topRight rune
+ bottomLeft, bottomRight rune
+ side rune
+ topFill rune
+ connectorH, connectorV rune
+ upArrow, downArrow rune
+}
+
+func mapGlyphsForPlayer(unicode bool) mapGlyphs {
+ if unicode {
+ return mapGlyphs{
+ topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝',
+ side: '║', topFill: '═', connectorH: '─', connectorV: '│',
+ upArrow: '↑', downArrow: '↓',
+ }
+ }
+ return mapGlyphs{
+ topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':',
+ side: ':', topFill: '.', connectorH: '-', connectorV: '|',
+ upArrow: '^', downArrow: 'v',
+ }
+}
+
type mapGraph struct {
posToRoom map[[2]int]int
roomToPos map[int][2]int
@@ -62,8 +86,8 @@ func buildGraph(g *Game, startRoomID int) *mapGraph {
return mg
}
-func buildTinyMap(g *Game, roomID int) []string {
- mg := buildGraph(g, roomID)
+func buildTinyMap(g *Game, roomID int, mg mapGlyphs) []string {
+ bg := buildGraph(g, roomID)
grid := make([][]rune, 5)
for i := range grid {
@@ -76,7 +100,7 @@ func buildTinyMap(g *Game, roomID int) []string {
for y := -1; y <= 1; y++ {
for x := -1; x <= 1; x++ {
pos := [2]int{x, y}
- rid, ok := mg.posToRoom[pos]
+ rid, ok := bg.posToRoom[pos]
if !ok {
continue
}
@@ -94,13 +118,13 @@ func buildTinyMap(g *Game, roomID int) []string {
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]
+ leftRoom, leftOK := bg.posToRoom[leftPos]
+ rightRoom, rightOK := bg.posToRoom[rightPos]
if !leftOK || !rightOK {
continue
}
if exitsConnect(g, leftRoom, rightRoom, world.East, world.West) {
- grid[(y+1)*2][(x+1)*2+1] = '─'
+ grid[(y+1)*2][(x+1)*2+1] = mg.connectorH
}
}
}
@@ -109,13 +133,13 @@ func buildTinyMap(g *Game, roomID int) []string {
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]
+ topRoom, topOK := bg.posToRoom[topPos]
+ bottomRoom, bottomOK := bg.posToRoom[bottomPos]
if !topOK || !bottomOK {
continue
}
if exitsConnect(g, topRoom, bottomRoom, world.South, world.North) {
- grid[(y+1)*2+1][(x+1)*2] = '│'
+ grid[(y+1)*2+1][(x+1)*2] = mg.connectorV
}
}
}
@@ -123,19 +147,21 @@ func buildTinyMap(g *Game, roomID int) []string {
cur, _ := loadRoom(g, roomID)
if cur != nil {
if _, ok := exitTarget(cur, world.Up); ok {
- grid[1][3] = '↑'
+ grid[1][3] = mg.upArrow
}
if _, ok := exitTarget(cur, world.Down); ok {
- grid[3][1] = '↓'
+ grid[3][1] = mg.downArrow
}
}
+ topFill := strings.Repeat(string(mg.topFill), 5)
lines := make([]string, 7)
- lines[0] = "╔═════╗"
+ lines[0] = string(mg.topLeft) + topFill + string(mg.topRight)
for row := 0; row < 5; row++ {
- lines[row+1] = "║" + string(grid[row]) + "║"
+ lines[row+1] = string(mg.side) + string(grid[row]) + string(mg.side)
}
- lines[6] = "╚═════╝"
+ botFill := strings.Repeat(string(mg.topFill), 5)
+ lines[6] = string(mg.bottomLeft) + botFill + string(mg.bottomRight)
return lines
}
@@ -152,10 +178,8 @@ func exitsConnect(g *Game, room1, room2 int, dir12, dir21 world.ExitDir) bool {
if !ok {
return false
}
- if id, ok := exitTarget(r2, dir21); ok && id == room1 {
- return true
- }
- return false
+ id, ok := exitTarget(r2, dir21)
+ return ok && id == room1
}
func exitTarget(room *world.Room, dir world.ExitDir) (int, bool) {
@@ -234,8 +258,8 @@ func leftTrimCommon(lines []string) []string {
return result
}
-func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string {
- mg := buildGraph(g, roomID)
+func buildFullMap(g *Game, roomID, mapWidth, mapHeight int, mg mapGlyphs) []string {
+ bg := buildGraph(g, roomID)
grid := make([][]rune, mapHeight)
for i := range grid {
@@ -248,7 +272,7 @@ func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string {
cx := mapWidth / 2
cy := mapHeight / 2
- for pos, rid := range mg.posToRoom {
+ for pos, rid := range bg.posToRoom {
gr := cy + pos[1]*2
gc := cx + pos[0]*2
if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth {
@@ -261,25 +285,25 @@ func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string {
}
}
- for pos, rid := range mg.posToRoom {
+ for pos, rid := range bg.posToRoom {
x, y := pos[0], pos[1]
- if rightID, ok := mg.posToRoom[[2]int{x + 1, y}]; ok {
+ if rightID, ok := bg.posToRoom[[2]int{x + 1, y}]; ok {
if exitsConnect(g, rid, rightID, world.East, world.West) {
gr := cy + y*2
gc := cx + x*2 + 1
if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- grid[gr][gc] = '─'
+ grid[gr][gc] = mg.connectorH
}
}
}
- if bottomID, ok := mg.posToRoom[[2]int{x, y + 1}]; ok {
+ if bottomID, ok := bg.posToRoom[[2]int{x, y + 1}]; ok {
if exitsConnect(g, rid, bottomID, world.South, world.North) {
gr := cy + y*2 + 1
gc := cx + x*2
if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- grid[gr][gc] = '│'
+ grid[gr][gc] = mg.connectorV
}
}
}
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index 3453117..8c82f4a 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -12,6 +12,8 @@ func TestBuildTinyMap(t *testing.T) {
World: world.New("../../data"),
}
+ mg := mapGlyphsForPlayer(true)
+
tests := []struct {
name string
roomID int
@@ -49,7 +51,7 @@ func TestBuildTinyMap(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- lines := buildTinyMap(g, tt.roomID)
+ lines := buildTinyMap(g, tt.roomID, mg)
if len(lines) != 7 {
t.Fatalf("expected 7 lines, got %d", len(lines))
}
@@ -97,6 +99,8 @@ func TestBuildFullMap(t *testing.T) {
World: world.New("../../data"),
}
+ mg := mapGlyphsForPlayer(true)
+
tests := []struct {
name string
roomID int
@@ -111,7 +115,7 @@ func TestBuildFullMap(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- lines := buildFullMap(g, tt.roomID, tt.width, tt.height)
+ lines := buildFullMap(g, tt.roomID, tt.width, tt.height, mg)
if len(lines) != tt.height {
t.Errorf("expected %d lines, got %d", tt.height, len(lines))
}
diff --git a/internal/game/table.go b/internal/game/table.go
new file mode 100644
index 0000000..e3dad97
--- /dev/null
+++ b/internal/game/table.go
@@ -0,0 +1,100 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+)
+
+type tableGlyphs struct {
+ topLeft, topSep, topRight rune
+ side, colSep rune
+ sepLeft, sepCross, sepRight rune
+ botLeft, botSep, botRight rune
+ fillH, fillHSep rune
+}
+
+func tableGlyphSet(unicode bool) tableGlyphs {
+ if unicode {
+ return tableGlyphs{
+ topLeft: '╔', topSep: '╤', topRight: '╗', side: '║', colSep: '│',
+ sepLeft: '╟', sepCross: '┼', sepRight: '╢',
+ botLeft: '╚', botSep: '╧', botRight: '╝', fillH: '═', fillHSep: '─',
+ }
+ }
+ return tableGlyphs{
+ topLeft: '+', topSep: '+', topRight: '+', side: '|', colSep: '|',
+ sepLeft: '+', sepCross: '+', sepRight: '+',
+ botLeft: '+', botSep: '+', botRight: '+', fillH: '-', fillHSep: '-',
+ }
+}
+
+type Table struct {
+ Columns []string
+ Rows [][]string
+}
+
+func (t *Table) Render(unicode bool) []string {
+ g := tableGlyphSet(unicode)
+
+ nCols := len(t.Columns)
+ if nCols == 0 {
+ return nil
+ }
+
+ colWidths := make([]int, nCols)
+ for i, h := range t.Columns {
+ colWidths[i] = len(h)
+ }
+ for _, row := range t.Rows {
+ for i, cell := range row {
+ if i >= nCols {
+ break
+ }
+ if len(cell) > colWidths[i] {
+ colWidths[i] = len(cell)
+ }
+ }
+ }
+
+ makeSep := func(left, cross, right rune, fill rune) string {
+ var b strings.Builder
+ b.WriteRune(left)
+ for i := 0; i < nCols; i++ {
+ if i > 0 {
+ b.WriteRune(cross)
+ }
+ b.WriteString(strings.Repeat(string(fill), colWidths[i]+2))
+ }
+ b.WriteRune(right)
+ return b.String()
+ }
+
+ makeRow := func(cells []string) string {
+ var b strings.Builder
+ b.WriteRune(g.side)
+ for i := 0; i < nCols; i++ {
+ if i > 0 {
+ b.WriteRune(g.colSep)
+ }
+ cell := ""
+ if i < len(cells) {
+ cell = cells[i]
+ }
+ b.WriteString(fmt.Sprintf(" %-*s ", colWidths[i], cell))
+ }
+ b.WriteRune(g.side)
+ return b.String()
+ }
+
+ var out []string
+
+ out = append(out, makeSep(g.topLeft, g.topSep, g.topRight, g.fillH))
+ out = append(out, makeRow(t.Columns))
+ out = append(out, makeSep(g.sepLeft, g.sepCross, g.sepRight, g.fillHSep))
+ for _, row := range t.Rows {
+ out = append(out, makeRow(row))
+ }
+ out = append(out, makeSep(g.botLeft, g.botSep, g.botRight, g.fillH))
+
+ return out
+}
diff --git a/internal/game/tick.go b/internal/game/tick.go
index 4d037ec..767109e 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -126,14 +126,14 @@ func (g *Game) WanderTick() {
if !ok {
continue
}
- if p.RoomID == m.fromRoom && p.OptionBool("mobleave") {
+ if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") {
if m.level > 0 {
sess.WriteLine(fmt.Sprintf("\n%s (level %d) leaves.", m.name, m.level))
} else {
sess.WriteLine(fmt.Sprintf("\n%s moves away.", m.name))
}
}
- if p.RoomID == m.toRoom && p.OptionBool("mobenter") {
+ if p.RoomID == m.toRoom && p.OptionBool("mob_enter") {
if m.level > 0 {
sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters.", m.name, m.level))
} else {
diff --git a/internal/player/player.go b/internal/player/player.go
index 15521a3..95cf948 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -106,13 +106,12 @@ type OptionDef struct {
var OptionDefs = []OptionDef{
{"description", OptBool, true, nil, "Long room descriptions when moving"},
- {"tinymap", OptBool, true, nil, "Mini-map display"},
- {"left_tinymap", OptBool, false, nil, "Mini-map on left side of descriptions"},
- {"xpdrops", OptBool, true, nil, "XP drop messages"},
+ {"tiny_map", OptString, "right", []string{"off", "right", "left"}, "Mini-map display (off/right/left)"},
+ {"xp_drops", OptBool, true, nil, "XP drop messages"},
{"exits", OptBool, true, nil, "Long exit display in look"},
- {"mobenter", OptBool, true, nil, "Messages when mobs enter the room"},
- {"mobleave", OptBool, true, nil, "Messages when mobs leave the room"},
- {"mobspawn", OptBool, true, nil, "Messages when mobs spawn in the area"},
+ {"mob_enter", OptBool, true, nil, "Messages when mobs enter the room"},
+ {"mob_leave", OptBool, true, nil, "Messages when mobs leave the room"},
+ {"mob_spawn", OptBool, true, nil, "Messages when mobs spawn in the area"},
{"reserve", OptBool, true, nil, "Show full reserved item details"},
{"depletion", OptBool, false, nil, "Show depletion and despawn timers"},
{"despawn", OptBool, false, nil, "Show ground item despawn timers"},
@@ -121,8 +120,9 @@ var OptionDefs = []OptionDef{
{"mapheight", OptInt, 20, nil, "Map height for the map command"},
{"mappadding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"},
{"automap", OptBool, false, nil, "Show map automatically after moving"},
- {"queue_actions_silently", OptBool, true, nil, "Suppress 'You prepare to...' messages for queued actions"},
- {"max_room_description_width", OptInt, 70, nil, "Maximum width for room descriptions"},
+ {"queue_silently", OptBool, true, nil, "Suppress messages for queued tick actions"},
+ {"room_desc_width", OptInt, 70, nil, "Maximum width for room descriptions"},
+ {"unicode", OptBool, true, nil, "Unicode box-drawing characters"},
}
var optionByName map[string]*OptionDef