aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/economy/journal.jsonl0
-rw-r--r--data/help/alias.yaml3
-rw-r--r--data/help/attack.yaml3
-rw-r--r--data/help/autotrigger.yaml3
-rw-r--r--data/help/color.yaml3
-rw-r--r--data/help/description.yaml3
-rw-r--r--data/help/equipment.yaml3
-rw-r--r--data/help/farming.yaml3
-rw-r--r--data/help/fishing.yaml3
-rw-r--r--data/help/fletching.yaml3
-rw-r--r--data/help/get.yaml4
-rw-r--r--data/help/inventory.yaml4
-rw-r--r--data/help/look.yaml3
-rw-r--r--data/help/mining.yaml3
-rw-r--r--data/help/mods.yaml3
-rw-r--r--data/help/movement.yaml19
-rw-r--r--data/help/newplayer.yaml56
-rw-r--r--data/help/option.yaml3
-rw-r--r--data/help/remove.yaml4
-rw-r--r--data/help/score.yaml3
-rw-r--r--data/help/talk.yaml4
-rw-r--r--data/help/use.yaml4
-rw-r--r--data/help/wear.yaml3
-rw-r--r--data/help/woodcutting.yaml4
-rw-r--r--internal/game/render_help.go91
-rw-r--r--internal/game/render_help_test.go81
-rw-r--r--internal/ui/table.go107
-rw-r--r--internal/ui/table_test.go91
28 files changed, 486 insertions, 28 deletions
diff --git a/data/economy/journal.jsonl b/data/economy/journal.jsonl
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/data/economy/journal.jsonl
diff --git a/data/help/alias.yaml b/data/help/alias.yaml
index 4be2f98..2caf8ed 100644
--- a/data/help/alias.yaml
+++ b/data/help/alias.yaml
@@ -13,3 +13,6 @@ description: |
Aliases resolve before built-in commands. An alias can shadow an
existing command name.
+
+aliases:
+ - "unalias"
diff --git a/data/help/attack.yaml b/data/help/attack.yaml
index 5873aaa..9cf32ff 100644
--- a/data/help/attack.yaml
+++ b/data/help/attack.yaml
@@ -34,3 +34,6 @@ description: |
base_level + 9 (science, no style bonus)
See 'combat', 'stats', 'style', and 'science' for details.
+
+aliases:
+ - "kill"
diff --git a/data/help/autotrigger.yaml b/data/help/autotrigger.yaml
index 08ca57e..25582aa 100644
--- a/data/help/autotrigger.yaml
+++ b/data/help/autotrigger.yaml
@@ -28,3 +28,6 @@ description: |
autotrigger none - Disable autotrigger
See also: trigger, mods, science
+
+aliases:
+ - "auto"
diff --git a/data/help/color.yaml b/data/help/color.yaml
index 82e8fed..87148c3 100644
--- a/data/help/color.yaml
+++ b/data/help/color.yaml
@@ -104,3 +104,6 @@ description: |
You can use color blocks in your prompt:
prompt {02 bold}Ready>{/} %h/%Hhp
See 'help prompt' for full prompt customization.
+
+aliases:
+ - "colors"
diff --git a/data/help/description.yaml b/data/help/description.yaml
index 3436c5b..d527307 100644
--- a/data/help/description.yaml
+++ b/data/help/description.yaml
@@ -10,3 +10,6 @@ description: |
room look output including description, mobs, objects,
items, and exits.
When disabled, only the room name is shown on move.
+
+aliases:
+ - "desc"
diff --git a/data/help/equipment.yaml b/data/help/equipment.yaml
index 3c0d70c..14bcb67 100644
--- a/data/help/equipment.yaml
+++ b/data/help/equipment.yaml
@@ -11,3 +11,6 @@ description: |
Shows what you have in each slot (main hand, head, torso, etc.).
Use "remove <item>" to unequip.
+
+aliases:
+ - "eq"
diff --git a/data/help/farming.yaml b/data/help/farming.yaml
index 2dd2ff2..ca281a8 100644
--- a/data/help/farming.yaml
+++ b/data/help/farming.yaml
@@ -29,3 +29,6 @@ description: |
See also: help plant, help harvest, help rake, help water,
help cure, help inspect
+
+aliases:
+ - "farm"
diff --git a/data/help/fishing.yaml b/data/help/fishing.yaml
index 61d7309..7f47756 100644
--- a/data/help/fishing.yaml
+++ b/data/help/fishing.yaml
@@ -20,3 +20,6 @@ description: |
Look at a spot to see what equipment it needs.
Enable the "xpdrops" toggle to see XP gains with each catch.
+
+aliases:
+ - "fish"
diff --git a/data/help/fletching.yaml b/data/help/fletching.yaml
index b2fe254..c347954 100644
--- a/data/help/fletching.yaml
+++ b/data/help/fletching.yaml
@@ -25,3 +25,6 @@ description: |
wear arrows, wear 100 arrows, remove arrows, remove 100 arrows
See also: fletch, use, wear, remove
+
+aliases:
+ - "fletch"
diff --git a/data/help/get.yaml b/data/help/get.yaml
index 777541b..a40affa 100644
--- a/data/help/get.yaml
+++ b/data/help/get.yaml
@@ -10,3 +10,7 @@ description: |
added to your credit balance and never take inventory space.
Picking up an item cancels any active gathering or crafting action.
+
+aliases:
+ - "take"
+ - "grab"
diff --git a/data/help/inventory.yaml b/data/help/inventory.yaml
index 7a60650..73a9999 100644
--- a/data/help/inventory.yaml
+++ b/data/help/inventory.yaml
@@ -6,3 +6,7 @@ description: |
Usage: inventory, i, inv
Shows all 28 inventory slots and any equipped items.
+
+aliases:
+ - "i"
+ - "inv"
diff --git a/data/help/look.yaml b/data/help/look.yaml
index d547bb6..2ce3d5f 100644
--- a/data/help/look.yaml
+++ b/data/help/look.yaml
@@ -28,3 +28,6 @@ description: |
- Directions: shows the adjacent room as if you were there
Use "toggle depletion" to show depletion and despawn timers on objects.
+
+aliases:
+ - "l"
diff --git a/data/help/mining.yaml b/data/help/mining.yaml
index 231027f..7eecfd6 100644
--- a/data/help/mining.yaml
+++ b/data/help/mining.yaml
@@ -19,3 +19,6 @@ description: |
Use numbered targeting (e.g. "mine 2.rock") when multiple rocks of the
same type are present. "look rock" shows which rocks are depleted.
Use "toggle depletion" to see exact respawn timers.
+
+aliases:
+ - "mine"
diff --git a/data/help/mods.yaml b/data/help/mods.yaml
index d545b54..71b8abc 100644
--- a/data/help/mods.yaml
+++ b/data/help/mods.yaml
@@ -20,3 +20,6 @@ description: |
Aliases: mods, mod, module, modules
See also: trigger, autotrigger, science
+
+aliases:
+ - "modlist"
diff --git a/data/help/movement.yaml b/data/help/movement.yaml
index c9bc792..1b684dc 100644
--- a/data/help/movement.yaml
+++ b/data/help/movement.yaml
@@ -57,4 +57,21 @@ description: |
Dying refills your run energy to full (alongside your HP).
related:
- walk
- - agility \ No newline at end of file
+ - agility
+aliases:
+ - "north"
+ - "south"
+ - "east"
+ - "west"
+ - "up"
+ - "down"
+ - "ne"
+ - "nw"
+ - "se"
+ - "sw"
+ - "n"
+ - "s"
+ - "e"
+ - "w"
+ - "u"
+ - "d"
diff --git a/data/help/newplayer.yaml b/data/help/newplayer.yaml
new file mode 100644
index 0000000..5f0eb17
--- /dev/null
+++ b/data/help/newplayer.yaml
@@ -0,0 +1,56 @@
+name: "newplayer"
+aliases:
+ - "new player"
+ - "getting started"
+ - "guide"
+ - "basics"
+category: "Getting Started"
+description: |
+ Welcome to The House of Icarus! This guide covers the basics.
+
+ Getting around
+ Move with north, south, east, west, up, down (or the shortcuts
+ n, s, e, w, u, d). "exits" lists the ways out of a room, "map"
+ draws a local map, and "walk <room>" auto-pathfinds for you.
+ Movement uses run energy; see 'help movement' for the details.
+
+ Looking around
+ "look" describes the room, "look <thing>" examines an item,
+ object, or mob. Use numbers to pick between identical targets,
+ e.g. "look 2.rock".
+
+ Your character
+ "score" shows your stats, skills, and combat level. "inventory"
+ (i) shows your 28-slot pack, and "equipment" (eq) shows what
+ you're wearing and wielding. "stats" lists your combat bonuses.
+
+ Items
+ "get <item>" picks things up ("get all" grabs everything),
+ "drop <item>" puts them down, "wear <item>" and "wield <item>"
+ equip gear, "remove <item>" unequips it. "eat <food>" and
+ "drink <potion>" restore hitpoints. "use <item> on <object>"
+ interacts with the world.
+
+ Skills
+ Gathering: mine (Mining), fish (Fishing), chop (Woodcutting),
+ farm (Farming), search (Scavenging)
+ Crafting: cook, smelt, smith, craft, fletch, mix, construct
+ Most skills need the right tool, either wielded or in your pack.
+ Many actions take a few ticks, so you can queue several commands.
+
+ Combat
+ "attack <mob>" starts a fight (e.g. "attack man" or "attack 1.man").
+ Combat runs automatically on the tick; "style" changes whether you
+ train accuracy, strength, or defense. To flee, move in any
+ direction - see 'help combat' for the rules.
+
+ Talking
+ "talk <mob>" starts an NPC conversation (pick numbered options;
+ type "bye" to leave). "say <msg>" chats to your room and
+ "global <msg>" to everyone online.
+
+ Tips
+ "help <command>" shows detailed usage of any command.
+ "options" tweaks display settings (width, colors, unicode, map).
+ "help <topic>" pages include a "related:" list of nearby topics -
+ follow them as you explore.
diff --git a/data/help/option.yaml b/data/help/option.yaml
index 75b1794..0eb871a 100644
--- a/data/help/option.yaml
+++ b/data/help/option.yaml
@@ -19,3 +19,6 @@ description: |
Use help <option> for details on a specific option.
Use 'prompt' to set a custom per-character prompt.
+
+aliases:
+ - "options"
diff --git a/data/help/remove.yaml b/data/help/remove.yaml
index cce21f4..b44630d 100644
--- a/data/help/remove.yaml
+++ b/data/help/remove.yaml
@@ -13,3 +13,7 @@ description: |
enough inventory space for everything, nothing is removed.
Examples: remove axe, remove main_hand, remove all
+
+aliases:
+ - "unwear"
+ - "unwield"
diff --git a/data/help/score.yaml b/data/help/score.yaml
index cacf0ba..df8040d 100644
--- a/data/help/score.yaml
+++ b/data/help/score.yaml
@@ -10,3 +10,6 @@ description: |
The vitals block includes three bars: HP (red/green/yellow), BAT
(battery, blue), and ENG (run energy, green). Run energy maximum
is 3x your Agility level; see 'help movement' for run energy rules.
+
+aliases:
+ - "sc"
diff --git a/data/help/talk.yaml b/data/help/talk.yaml
index 4a59f68..3ac60ef 100644
--- a/data/help/talk.yaml
+++ b/data/help/talk.yaml
@@ -9,3 +9,7 @@ description: |
choices. Type the number to select an option.
Type "bye", "exit", or "end" to leave the conversation at any time.
+
+aliases:
+ - "speak"
+ - "ask"
diff --git a/data/help/use.yaml b/data/help/use.yaml
index d9bcfd4..34f5a4b 100644
--- a/data/help/use.yaml
+++ b/data/help/use.yaml
@@ -36,3 +36,7 @@ description: |
push gate Push a gate open (shortcut for use)
See also: cook, smelt, smith, recipe
+
+aliases:
+ - "pull"
+ - "push"
diff --git a/data/help/wear.yaml b/data/help/wear.yaml
index 2a92fbd..72d7e64 100644
--- a/data/help/wear.yaml
+++ b/data/help/wear.yaml
@@ -6,3 +6,6 @@ description: |
Usage: wear <item> Equip an item from inventory
wear all Auto-equip best items per slot
wear View equipped items
+
+aliases:
+ - "wield"
diff --git a/data/help/woodcutting.yaml b/data/help/woodcutting.yaml
index 80179a9..2ce502f 100644
--- a/data/help/woodcutting.yaml
+++ b/data/help/woodcutting.yaml
@@ -20,3 +20,7 @@ description: |
nest alongside the logs. Use "search nest" to open a nest for loot.
Use the "depletion" toggle to see timers on trees and rocks.
+
+aliases:
+ - "chop"
+ - "cut"
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index e2893b5..2d3a304 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -13,9 +13,11 @@ import (
)
type HelpDef struct {
- Name string `yaml:"name"`
- Category string `yaml:"category"`
- Content string `yaml:"description"`
+ Name string `yaml:"name"`
+ Aliases []string `yaml:"aliases"`
+ Related []string `yaml:"related"`
+ Category string `yaml:"category"`
+ Content string `yaml:"description"`
}
type cmdEntry struct {
@@ -63,7 +65,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 / ne / nw / se / sw / up / down", "Active", "Move in a direction"},
+ {"north / south / east / west / 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"},
@@ -94,16 +96,56 @@ func LoadHelp(dataDir string) ([]HelpDef, error) {
dir := filepath.Join(dataDir, "help")
var helps []HelpDef
behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
- var h HelpDef
- if err := yaml.Unmarshal(data, &h); err != nil {
+ h := parseHelpDef(data)
+ if h == nil {
return nil
}
- helps = append(helps, h)
+ helps = append(helps, *h)
return nil
})
return helps, nil
}
+// helpRaw accepts both the canonical schema (name/description) and the
+// legacy variant (title/body) used by a few help files.
+type helpRaw struct {
+ Name string `yaml:"name"`
+ Title string `yaml:"title"`
+ Aliases []string `yaml:"aliases"`
+ Related []string `yaml:"related"`
+ Category string `yaml:"category"`
+ Description string `yaml:"description"`
+ Body string `yaml:"body"`
+}
+
+// parseHelpDef unmarshals a help definition, normalizing legacy title/body
+// files onto the canonical HelpDef. Returns nil for files that are not help
+// topics (no name at all).
+func parseHelpDef(data []byte) *HelpDef {
+ var raw helpRaw
+ if err := yaml.Unmarshal(data, &raw); err != nil {
+ return nil
+ }
+ name := raw.Name
+ if name == "" {
+ name = raw.Title
+ }
+ if name == "" {
+ return nil
+ }
+ content := raw.Description
+ if content == "" {
+ content = raw.Body
+ }
+ return &HelpDef{
+ Name: name,
+ Aliases: raw.Aliases,
+ Related: raw.Related,
+ Category: raw.Category,
+ Content: content,
+ }
+}
+
func (g *Game) doHelp(sess *net.Session, topic string) {
if topic == "" {
unicode := true
@@ -141,16 +183,47 @@ func (g *Game) doHelp(sess *net.Session, topic string) {
return
}
- topic = strings.ToLower(topic)
+ topic = strings.ToLower(strings.TrimSpace(topic))
for _, h := range helps {
- if strings.ToLower(h.Name) == topic {
+ if matchesHelpTopic(h, topic) {
sess.WriteLine(fmt.Sprintf(" %s", h.Content))
+ if len(h.Related) > 0 {
+ sess.WriteLine("")
+ sess.WriteLine(fmt.Sprintf(" Related topics: %s", strings.Join(h.Related, ", ")))
+ }
return
}
}
+
+ // No dedicated help page. If it's a known command, at least show its
+ // summary from the command table instead of a dead end.
+ for _, c := range commandList {
+ for _, name := range strings.Split(c.Name, " / ") {
+ if strings.ToLower(strings.TrimSpace(name)) == topic {
+ sess.WriteLine(fmt.Sprintf(" %s - %s", c.Name, c.Desc))
+ sess.WriteLine("")
+ sess.WriteLine(" (No detailed help page for this command yet.)")
+ return
+ }
+ }
+ }
sess.WriteLine(fmt.Sprintf("No help found for '%s'.", topic))
}
+// matchesHelpTopic reports whether the help entry answers the given topic,
+// matching its name or any declared alias (case-insensitive).
+func matchesHelpTopic(h HelpDef, topic string) bool {
+ if strings.EqualFold(h.Name, topic) {
+ return true
+ }
+ for _, a := range h.Aliases {
+ if strings.EqualFold(strings.TrimSpace(a), topic) {
+ return true
+ }
+ }
+ return false
+}
+
func firstLine(s string) string {
if idx := strings.Index(s, "\n"); idx >= 0 {
return s[:idx]
diff --git a/internal/game/render_help_test.go b/internal/game/render_help_test.go
new file mode 100644
index 0000000..b8ad614
--- /dev/null
+++ b/internal/game/render_help_test.go
@@ -0,0 +1,81 @@
+package game
+
+import (
+ "strings"
+ "testing"
+)
+
+func loadHelpOrSkip(t *testing.T) []HelpDef {
+ t.Helper()
+ helps, err := LoadHelp("../../data")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(helps) == 0 {
+ t.Fatal("no help topics loaded")
+ }
+ return helps
+}
+
+func TestLoadHelpAllFilesParse(t *testing.T) {
+ helps := loadHelpOrSkip(t)
+ seen := map[string]bool{}
+ for _, h := range helps {
+ if strings.TrimSpace(h.Name) == "" {
+ t.Errorf("help entry with empty name: %q", h.Content)
+ }
+ if seen[h.Name] {
+ t.Errorf("duplicate help topic %q", h.Name)
+ }
+ seen[h.Name] = true
+ }
+}
+
+func TestNewPlayerHelpExists(t *testing.T) {
+ helps := loadHelpOrSkip(t)
+ found := false
+ for _, h := range helps {
+ if h.Name == "newplayer" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("help topic 'newplayer' missing (referenced by intro room)")
+ }
+}
+
+func TestHelpAliasesResolveWithoutCollision(t *testing.T) {
+ helps := loadHelpOrSkip(t)
+ owner := map[string]string{}
+ for _, h := range helps {
+ for _, a := range h.Aliases {
+ a = strings.ToLower(strings.TrimSpace(a))
+ if a == "" {
+ continue
+ }
+ if prev, dup := owner[a]; dup {
+ t.Errorf("alias %q claimed by both %q and %q", a, prev, h.Name)
+ }
+ owner[a] = h.Name
+ }
+ }
+ for topic, want := range map[string]string{
+ "mine": "mining",
+ "chop": "woodcutting",
+ "fish": "fishing",
+ "fletch": "fletch",
+ "newplayer": "newplayer",
+ "new player": "newplayer",
+ } {
+ resolved := ""
+ for _, h := range helps {
+ if matchesHelpTopic(h, topic) {
+ resolved = h.Name
+ break
+ }
+ }
+ if resolved != want {
+ t.Errorf("help %q resolved to %q, want %q", topic, resolved, want)
+ }
+ }
+}
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 5ef2c99..e85e505 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -38,6 +38,68 @@ type Table struct {
Rows [][]string
}
+// rebalanceWidths shrinks column widths in place so their sum fits budget.
+// Rather than crushing only the last column, every column wider than the
+// fair share (budget/nCols) is capped first, then columns are trimmed one
+// rune at a time from the widest until the table fits. Leftover slack is
+// handed back round-robin to columns that lost width so wide layouts still
+// use the full line.
+func rebalanceWidths(colWidths []int, budget int) {
+ original := make([]int, len(colWidths))
+ copy(original, colWidths)
+
+ fair := budget / len(colWidths)
+ if fair < 1 {
+ fair = 1
+ }
+ for i, w := range colWidths {
+ if w > fair {
+ colWidths[i] = fair
+ }
+ }
+
+ sum := func() int {
+ t := 0
+ for _, w := range colWidths {
+ t += w
+ }
+ return t
+ }
+
+ // Tight tables: trim the widest column until we fit (or hit 1).
+ for sum() > budget {
+ widest := 0
+ for i, w := range colWidths {
+ if w > colWidths[widest] {
+ widest = i
+ }
+ }
+ if colWidths[widest] <= 1 {
+ break
+ }
+ colWidths[widest]--
+ }
+
+ // Give unused width back to the columns that were reduced.
+ for {
+ extra := budget - sum()
+ if extra <= 0 {
+ break
+ }
+ progress := false
+ for i := 0; i < len(colWidths) && extra > 0; i++ {
+ if colWidths[i] < original[i] {
+ colWidths[i]++
+ extra--
+ progress = true
+ }
+ }
+ if !progress {
+ break
+ }
+ }
+}
+
func (t *Table) Render(unicode bool, maxWidth int) []string {
g := tableGlyphSet(unicode)
@@ -65,29 +127,38 @@ func (t *Table) Render(unicode bool, maxWidth int) []string {
}
}
+ headerCells := t.Columns
+ bodyRows := t.Rows
+
if maxWidth > 0 {
+ // Cell padding: one space on each side of every cell, plus one
+ // separator between columns.
overhead := 3*nCols + 1
- total := overhead
+ budget := maxWidth - overhead
+ if budget < nCols {
+ budget = nCols
+ }
+
+ total := 0
for _, w := range colWidths {
total += w
}
- if total > maxWidth {
- last := nCols - 1
- budget := maxWidth - overhead
- for i := 0; i < last; i++ {
- budget -= colWidths[i]
- }
- if budget < 5 {
- budget = 5
- }
- colWidths[last] = budget
-
- if len(t.Columns) > last {
- t.Columns[last] = color.TruncateVisible(t.Columns[last], budget, unicode)
+ if total > budget {
+ rebalanceWidths(colWidths, budget)
+
+ // Truncate every cell to its (possibly reduced) column width.
+ // Work on copies so rendering never mutates the table.
+ headerCells = make([]string, len(t.Columns))
+ copy(headerCells, t.Columns)
+ for i := range headerCells {
+ headerCells[i] = color.TruncateVisible(headerCells[i], colWidths[i], unicode)
}
+ bodyRows = make([][]string, len(t.Rows))
for i := range t.Rows {
- if len(t.Rows[i]) > last {
- t.Rows[i][last] = color.TruncateVisible(t.Rows[i][last], budget, unicode)
+ bodyRows[i] = make([]string, len(t.Rows[i]))
+ copy(bodyRows[i], t.Rows[i])
+ for j := 0; j < len(bodyRows[i]) && j < nCols; j++ {
+ bodyRows[i][j] = color.TruncateVisible(bodyRows[i][j], colWidths[j], unicode)
}
}
}
@@ -164,11 +235,11 @@ func (t *Table) Render(unicode bool, maxWidth int) []string {
}
if hasCols {
- out = append(out, makeRow(t.Columns))
+ out = append(out, makeRow(headerCells))
out = append(out, makeSep(g.sepLeft, g.sepCross, g.sepRight, g.fillHSep))
}
- for _, row := range t.Rows {
+ for _, row := range bodyRows {
out = append(out, makeRow(row))
}
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
new file mode 100644
index 0000000..68b6d32
--- /dev/null
+++ b/internal/ui/table_test.go
@@ -0,0 +1,91 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/color"
+)
+
+func renderWidths(t *testing.T, tt *Table, unicode bool, maxWidth int) []string {
+ t.Helper()
+ lines := tt.Render(unicode, maxWidth)
+ for _, l := range lines {
+ if vl := color.VisibleLen(l); maxWidth > 0 && vl > maxWidth+1 {
+ t.Errorf("line exceeds maxWidth %d: %d (%q)", maxWidth, vl, l)
+ }
+ }
+ // All border/content lines must be the same visible width so the box
+ // lines up.
+ widths := map[int]bool{}
+ for _, l := range lines {
+ widths[color.VisibleLen(l)] = true
+ }
+ if len(widths) > 1 {
+ var ws []string
+ for w := range widths {
+ ws = append(ws, string(rune('0'+w)))
+ }
+ t.Errorf("inconsistent line widths: %v", strings.Join(ws, ","))
+ }
+ return lines
+}
+
+func TestTableFitsWithinWidth(t *testing.T) {
+ tt := &Table{
+ Title: "Commands",
+ Columns: []string{"Command", "Type", "Description"},
+ Rows: [][]string{
+ {"north / south / east / west / ne / nw / se / sw / up / down", "Active", "Move in a direction"},
+ {"bet", "Active", "Place a casino wager (e.g. bet 100 on banker)"},
+ {"attack / kill", "Active", "Attack a mob"},
+ },
+ }
+ for _, w := range []int{0, 40, 80, 100} {
+ lines := renderWidths(t, tt, true, w)
+ // Description column must never be crushed: with three columns the
+ // description always keeps at least a fair share of the budget.
+ if w == 80 {
+ // lines: border, title, sep, header, sep, rows...
+ if !strings.Contains(lines[5], "Move i") {
+ t.Errorf("description column crushed at width 80: %q", lines[5])
+ }
+ }
+ }
+}
+
+func TestTableNoMutation(t *testing.T) {
+ tt := &Table{
+ Title: "T",
+ Columns: []string{"A", "B"},
+ Rows: [][]string{{"very long cell content here", "x"}},
+ }
+ before := tt.Columns[0]
+ _ = tt.Render(true, 20)
+ if tt.Columns[0] != before {
+ t.Errorf("Render mutated table columns: %q", tt.Columns[0])
+ }
+}
+
+func TestTableASCIIFallback(t *testing.T) {
+ tt := &Table{Title: "T", Columns: []string{"A", "B"}, Rows: [][]string{{"1", "2"}}}
+ lines := renderWidths(t, tt, false, 0)
+ if !strings.Contains(lines[0], "+") {
+ t.Errorf("expected ASCII border, got %q", lines[0])
+ }
+}
+
+func TestTableColoredCellWidth(t *testing.T) {
+ // ANSI-colored cells must be measured and padded by visible length.
+ red := "\x1b[31m"
+ reset := "\x1b[0m"
+ tt := &Table{Columns: []string{"A", "B"}, Rows: [][]string{{red + "hi" + reset, "x"}}}
+ lines := renderWidths(t, tt, true, 0)
+ // lines: top border, header, sep, data row, bottom border
+ if len(lines) < 5 {
+ t.Fatalf("expected 5 lines, got %d: %q", len(lines), lines)
+ }
+ if !strings.Contains(lines[3], "hi") || !strings.Contains(lines[3], "x") {
+ t.Errorf("unexpected row: %q", lines[3])
+ }
+}