aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-25 04:46:40 -0400
committerhistoria <[not public]>2026-06-25 04:46:40 -0400
commit2725e2927a1595c7b100d942d1f14146252adeb7 (patch)
treee8bc2292634a2d686c751981ddb3097517cccc6a /internal
parent94a06fbbe682701ca4d4bd2a70cd74d8df29c932 (diff)
downloadthehouseoficarus-2725e2927a1595c7b100d942d1f14146252adeb7.tar.gz
feat: who, global chat, removed redundant YAML IDs
Diffstat (limited to 'internal')
-rw-r--r--internal/action/action.go1
-rw-r--r--internal/color/color.go42
-rw-r--r--internal/color/color_test.go55
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/game/cmd_color.go1
-rw-r--r--internal/game/cmd_global.go36
-rw-r--r--internal/game/cmd_look.go88
-rw-r--r--internal/game/cmd_registry.go2
-rw-r--r--internal/game/cmd_verbs.go2
-rw-r--r--internal/game/cmd_who.go32
-rw-r--r--internal/game/core_course.go1
-rw-r--r--internal/game/sys_science.go1
-rw-r--r--internal/game/sys_technology.go1
-rw-r--r--internal/game/ui_help.go2
-rw-r--r--internal/net/server.go22
-rw-r--r--internal/object/item_store.go2
-rw-r--r--internal/object/store.go1
-rw-r--r--internal/player/player.go1
-rw-r--r--internal/world/hazard.go2
19 files changed, 243 insertions, 50 deletions
diff --git a/internal/action/action.go b/internal/action/action.go
index a72f6ca..d66d88e 100644
--- a/internal/action/action.go
+++ b/internal/action/action.go
@@ -184,6 +184,5 @@ type DropEntry struct {
}
type DropTableDef struct {
- ID string `yaml:"id"`
Drops []DropEntry `yaml:"drops"`
}
diff --git a/internal/color/color.go b/internal/color/color.go
index dcbb280..22fa143 100644
--- a/internal/color/color.go
+++ b/internal/color/color.go
@@ -180,6 +180,48 @@ func VisibleLen(s string) int {
return utf8.RuneCountInString(ansiRe.ReplaceAllString(s, ""))
}
+// WrapANSI wraps text to the given visible width, ignoring ANSI color codes
+// when measuring. Each input line (split on "\n") is handled on its own: a line
+// that already fits is returned untouched, so indentation and aligned UI are
+// preserved. Only over-long lines are word-wrapped. The result is joined with
+// "\r\n". A width <= 0 disables wrapping.
+func WrapANSI(s string, width int) string {
+ if width <= 0 {
+ return s
+ }
+ var out []string
+ for _, line := range strings.Split(s, "\n") {
+ line = strings.TrimRight(line, "\r")
+ out = append(out, wrapLine(line, width)...)
+ }
+ return strings.Join(out, "\r\n")
+}
+
+func wrapLine(line string, width int) []string {
+ if VisibleLen(line) <= width {
+ return []string{line}
+ }
+ words := strings.Fields(line)
+ if len(words) == 0 {
+ return []string{line}
+ }
+ cur := words[0]
+ curWidth := VisibleLen(words[0])
+ var lines []string
+ for _, w := range words[1:] {
+ ww := VisibleLen(w)
+ if curWidth+1+ww <= width {
+ cur += " " + w
+ curWidth += 1 + ww
+ } else {
+ lines = append(lines, cur)
+ cur = w
+ curWidth = ww
+ }
+ }
+ return append(lines, cur)
+}
+
func ContrastFg(mode string, bgIndex int) string {
r, g, b := Xterm256ToRGB(bgIndex)
if r+g+b > 384 {
diff --git a/internal/color/color_test.go b/internal/color/color_test.go
new file mode 100644
index 0000000..c1f5e5b
--- /dev/null
+++ b/internal/color/color_test.go
@@ -0,0 +1,55 @@
+package color
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestWrapANSIDisabled(t *testing.T) {
+ s := "this is a very long line that would normally be wrapped if enabled"
+ if got := WrapANSI(s, 0); got != s {
+ t.Errorf("WrapANSI(_, 0) = %q, want unchanged", got)
+ }
+}
+
+func TestWrapANSIShortLineUntouched(t *testing.T) {
+ s := " indented menu item"
+ if got := WrapANSI(s, 80); got != s {
+ t.Errorf("short line was modified: got %q, want %q", got, s)
+ }
+}
+
+func TestWrapANSIWrapsLongLine(t *testing.T) {
+ s := "alpha bravo charlie delta echo foxtrot golf hotel india juliet"
+ got := WrapANSI(s, 20)
+ lines := strings.Split(got, "\r\n")
+ if len(lines) < 2 {
+ t.Fatalf("expected multiple lines, got %d: %q", len(lines), got)
+ }
+ for _, l := range lines {
+ if VisibleLen(l) > 20 {
+ t.Errorf("line exceeds width: %q (%d)", l, VisibleLen(l))
+ }
+ }
+ if strings.Join(strings.Fields(got), " ") != s {
+ t.Errorf("words not preserved: %q", got)
+ }
+}
+
+func TestWrapANSIIgnoresColorCodes(t *testing.T) {
+ colored := FgCode("xterm256", 182) + "hello world" + Reset
+ if VisibleLen(colored) > 80 {
+ t.Fatalf("test setup wrong, visible len = %d", VisibleLen(colored))
+ }
+ if got := WrapANSI(colored, 80); got != colored {
+ t.Errorf("colored line within width was wrapped: %q", got)
+ }
+}
+
+func TestWrapANSISplitsOnNewlines(t *testing.T) {
+ got := WrapANSI("first line\nsecond line", 80)
+ want := "first line\r\nsecond line"
+ if got != want {
+ t.Errorf("WrapANSI newline handling = %q, want %q", got, want)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index af00297..b01997c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -40,6 +40,7 @@ func DefaultColors() ColorsConfig {
"miss": "243",
"error": "209",
"say": "230",
+ "global": "45",
"dialog": "117",
"broadcast": "215",
"fire": "208",
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
index 51f8158..7bf073d 100644
--- a/internal/game/cmd_color.go
+++ b/internal/game/cmd_color.go
@@ -154,6 +154,7 @@ var colorCategoryOrder = []string{
"miss",
"error",
"say",
+ "global",
"dialog",
"broadcast",
"fire",
diff --git a/internal/game/cmd_global.go b/internal/game/cmd_global.go
new file mode 100644
index 0000000..d06a233
--- /dev/null
+++ b/internal/game/cmd_global.go
@@ -0,0 +1,36 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeGlobal(sess *net.Session, args []string, rawInput string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: global <message> — sends a message to everyone on the world.")
+ return
+ }
+
+ msgStart := strings.Index(strings.ToLower(rawInput), "global ") + 7
+ if msgStart >= 7 && msgStart < len(rawInput) {
+ g.doGlobal(sess, rawInput[msgStart:])
+ } else {
+ g.doGlobal(sess, strings.Join(args, " "))
+ }
+}
+
+func (g *Game) doGlobal(sess *net.Session, msg string) {
+ p := sess.Player
+
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player == nil {
+ continue
+ }
+ other.WriteLine(fmt.Sprintf("\n%s%s: %s",
+ g.colorize(other, "global", "[Global] "),
+ g.colorize(other, "player_name", p.Name),
+ g.colorize(other, "global", msg)))
+ }
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 3f31a15..5cdc15d 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -540,26 +540,26 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
fmt.Sprintf("%s %s", g.colorize(sess, mobColor, best.Name), levelStr),
)
if best.IdleDescription != "" {
- sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription))
+ sess.WriteLine(best.IdleDescription)
}
sess.WriteLines(
"",
- fmt.Sprintf(" Attack: %d", best.Attack),
- fmt.Sprintf(" Strength: %d", best.Strength),
- fmt.Sprintf(" Defense: %d", best.Defense),
- fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP),
+ fmt.Sprintf("Attack: %d", best.Attack),
+ fmt.Sprintf("Strength: %d", best.Strength),
+ fmt.Sprintf("Defense: %d", best.Defense),
+ fmt.Sprintf("HP: %d/%d", best.HP, best.MaxHP),
)
if best.StabDefense != 0 || best.SlashDefense != 0 || best.CrushDefense != 0 ||
best.ScienceDefense != 0 || best.RangedDefense != 0 {
sess.WriteLines(
"",
- " Defense bonuses:",
- fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense),
- fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense),
+ "Defense bonuses:",
+ fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense),
+ fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense),
)
}
if best.Weakness != "" {
- sess.WriteLine(fmt.Sprintf(" Weakness: %s", best.Weakness))
+ sess.WriteLine(fmt.Sprintf("Weakness: %s", best.Weakness))
}
return
}
@@ -612,12 +612,10 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine("")
if len(instances) > 1 {
sess.WriteLine(fmt.Sprintf("%d %ss:", len(instances), def.Name))
- } else {
- sess.WriteLine(def.Name)
}
if objDescText != "" && !strings.Contains(objDescText, "{quality}") {
- sess.WriteLine(fmt.Sprintf(" %s", objDescText))
+ sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText))
}
if def.Safespot != nil {
@@ -635,9 +633,9 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
if def.Safespot.MaxBlockSize != "" {
blockInfo = fmt.Sprintf("up to %s mobs", def.Safespot.MaxBlockSize)
}
- sess.WriteLine(fmt.Sprintf(" Safespot: %s — blocks %s", levelStr, blockInfo))
+ sess.WriteLine(fmt.Sprintf("Safespot: %s — blocks %s", levelStr, blockInfo))
if len(realSt.SafespotOccupants) > 0 {
- sess.WriteLine(fmt.Sprintf(" Occupied by: %s", strings.Join(realSt.SafespotOccupants, ", ")))
+ sess.WriteLine(fmt.Sprintf("Occupied by: %s", strings.Join(realSt.SafespotOccupants, ", ")))
}
}
}
@@ -646,15 +644,15 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
for _, ist := range instances {
if ist.Depleted {
if len(instances) > 1 {
- sess.WriteLine(fmt.Sprintf(" %s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, int(ist.DepleteTimer)))
+ sess.WriteLine(fmt.Sprintf("%s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, int(ist.DepleteTimer)))
} else {
- sess.WriteLine(fmt.Sprintf(" Depleted, respawns in %d ticks.", int(ist.DepleteTimer)))
+ sess.WriteLine(fmt.Sprintf("Depleted, respawns in %d ticks.", int(ist.DepleteTimer)))
}
} else if ist.SharedMax > 0 && float64(ist.SharedTimer) < ist.SharedMax {
if len(instances) > 1 {
- sess.WriteLine(fmt.Sprintf(" %s %d: despawn timer %d/%.0f.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax))
+ sess.WriteLine(fmt.Sprintf("%s %d: despawn timer %d/%.0f.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax))
} else {
- sess.WriteLine(fmt.Sprintf(" Despawn timer: %d/%.0f ticks.", ist.SharedTimer, ist.SharedMax))
+ sess.WriteLine(fmt.Sprintf("Despawn timer: %d/%.0f ticks.", ist.SharedTimer, ist.SharedMax))
}
}
}
@@ -662,7 +660,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
for _, ist := range instances {
if ist.Quality > 0 && strings.Contains(def.Description, "{quality}") {
desc := strings.ReplaceAll(def.Description, "{quality}", fmt.Sprintf("%.0f", ist.Quality))
- sess.WriteLine(fmt.Sprintf(" %s", desc))
+ sess.WriteLine(color.ExpandTags(g.colorMode(sess), desc))
}
}
@@ -682,8 +680,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLines(
"",
g.itemColorize(sess, def, def.Name),
- fmt.Sprintf(" %s", def.Description),
- fmt.Sprintf(" Value: %d credits", def.Value),
+ color.ExpandTags(g.colorMode(sess), def.Description),
+ fmt.Sprintf("Value: %d credits", def.Value),
)
g.showItemStats(sess, def)
return
@@ -701,11 +699,11 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
lines := []string{
"",
g.itemColorize(sess, def, def.Name),
- fmt.Sprintf(" %s", def.Description),
- fmt.Sprintf(" Value: %d credits", def.Value),
+ color.ExpandTags(g.colorMode(sess), def.Description),
+ fmt.Sprintf("Value: %d credits", def.Value),
}
if slot.MaxQuality > 0 {
- lines = append(lines, fmt.Sprintf(" Has %d units of butane left.", slot.Quality))
+ lines = append(lines, fmt.Sprintf("Has %d units of butane left.", slot.Quality))
}
sess.WriteLines(lines...)
g.showItemStats(sess, def)
@@ -735,18 +733,18 @@ func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) {
sess.WriteLines(
"",
p.Name,
- fmt.Sprintf(" Combat Level: %s", levelStr),
- fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()),
+ fmt.Sprintf("Combat Level: %s", levelStr),
+ fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()),
"",
)
for _, s := range player.AllSkills {
level := p.Level(s)
- sess.WriteLine(fmt.Sprintf(" %-12s Level: %d", s, level))
+ sess.WriteLine(fmt.Sprintf("%-12s Level: %d", s, level))
}
sess.WriteLine("")
- sess.WriteLine(" Equipment:")
+ sess.WriteLine("Equipment:")
for _, slot := range EquipSlots {
itemID, ok := p.Equipment[slot]
if !ok {
@@ -758,12 +756,12 @@ func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) {
name = def.Name
itemDef = def
}
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, itemDef, name)))
+ sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, itemDef, name)))
}
if p.Description != "" {
sess.WriteLine("")
- sess.WriteLine(fmt.Sprintf(" %s", p.Description))
+ sess.WriteLine(p.Description)
}
}
@@ -916,40 +914,40 @@ func (g *Game) showItemStats(sess *net.Session, def *object.ItemDef) {
sess.WriteLine("")
if hasAttack || hasDefense {
- sess.WriteLine(" Attack bonuses: Defense bonuses:")
- sess.WriteLine(fmt.Sprintf(" Stab: %+4d Stab: %+4d", s.StabAttack, s.StabDefense))
- sess.WriteLine(fmt.Sprintf(" Slash: %+4d Slash: %+4d", s.SlashAttack, s.SlashDefense))
- sess.WriteLine(fmt.Sprintf(" Crush: %+4d Crush: %+4d", s.CrushAttack, s.CrushDefense))
- sess.WriteLine(fmt.Sprintf(" Science:%+4d Science:%+4d", s.ScienceAttack, s.ScienceDefense))
- sess.WriteLine(fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", s.RangedAttack, s.RangedDefense))
+ sess.WriteLine("Attack bonuses: Defense bonuses:")
+ sess.WriteLine(fmt.Sprintf(" Stab: %+4d Stab: %+4d", s.StabAttack, s.StabDefense))
+ sess.WriteLine(fmt.Sprintf(" Slash: %+4d Slash: %+4d", s.SlashAttack, s.SlashDefense))
+ sess.WriteLine(fmt.Sprintf(" Crush: %+4d Crush: %+4d", s.CrushAttack, s.CrushDefense))
+ sess.WriteLine(fmt.Sprintf(" Science:%+4d Science:%+4d", s.ScienceAttack, s.ScienceDefense))
+ sess.WriteLine(fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", s.RangedAttack, s.RangedDefense))
}
if hasOther {
sess.WriteLine("")
- sess.WriteLine(" Other bonuses:")
+ sess.WriteLine("Other bonuses:")
if s.StrengthBonus != 0 {
- sess.WriteLine(fmt.Sprintf(" Melee strength: %+d", s.StrengthBonus))
+ sess.WriteLine(fmt.Sprintf(" Melee strength: %+d", s.StrengthBonus))
}
if s.RangedStrength != 0 {
- sess.WriteLine(fmt.Sprintf(" Ranged strength: %+d", s.RangedStrength))
+ sess.WriteLine(fmt.Sprintf(" Ranged strength: %+d", s.RangedStrength))
}
if s.ScienceDamage != 0 {
- sess.WriteLine(fmt.Sprintf(" Science damage: %+d", s.ScienceDamage))
+ sess.WriteLine(fmt.Sprintf(" Science damage: %+d", s.ScienceDamage))
}
if s.TechnologyBonus != 0 {
- sess.WriteLine(fmt.Sprintf(" Technology: %+d", s.TechnologyBonus))
+ sess.WriteLine(fmt.Sprintf(" Technology: %+d", s.TechnologyBonus))
}
}
if def.AttackType != "" {
- sess.WriteLine(fmt.Sprintf(" Attack type: %s", def.AttackType))
+ sess.WriteLine(fmt.Sprintf("Attack type: %s", def.AttackType))
}
if def.Speed > 0 {
- sess.WriteLine(fmt.Sprintf(" Speed: %.0f", def.Speed))
+ sess.WriteLine(fmt.Sprintf("Speed: %.0f", def.Speed))
}
if len(def.Requirements) > 0 {
sess.WriteLine("")
- sess.WriteLine(" Requirements:")
+ sess.WriteLine("Requirements:")
for skill, level := range def.Requirements {
- sess.WriteLine(fmt.Sprintf(" %s: %d", skill, level))
+ sess.WriteLine(fmt.Sprintf(" %s: %d", skill, level))
}
}
}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index be1c276..bc910ed 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -39,6 +39,8 @@ var commandRegistry = map[string]commandDef{
"down": {(*Game).executeMove, ClassActive},
"d": {(*Game).executeMove, ClassActive},
"say": {(*Game).executeSay, ClassInstant},
+ "global": {(*Game).executeGlobal, ClassInstant},
+ "who": {(*Game).executeWho, ClassInstant},
"sc": {(*Game).executeScore, ClassInstant},
"score": {(*Game).executeScore, ClassInstant},
"skills": {(*Game).executeSkills, ClassInstant},
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index 9ee1607..f76ee6c 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -11,6 +11,8 @@ import (
var alwaysAvailable = []string{
"look", "l",
"say",
+ "global",
+ "who",
"exits",
"map",
"walk",
diff --git a/internal/game/cmd_who.go b/internal/game/cmd_who.go
new file mode 100644
index 0000000..87c8a9c
--- /dev/null
+++ b/internal/game/cmd_who.go
@@ -0,0 +1,32 @@
+package game
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) executeWho(sess *net.Session, args []string, rawInput string) {
+ myP := sess.Player
+
+ var players []*player.Player
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player == nil {
+ continue
+ }
+ players = append(players, other.Player)
+ }
+ sort.Slice(players, func(i, j int) bool {
+ return strings.ToLower(players[i].Name) < strings.ToLower(players[j].Name)
+ })
+
+ sess.WriteLine(fmt.Sprintf("\nPlayers online (%d):", len(players)))
+ for _, p := range players {
+ lvl := p.CombatLevel()
+ levelStr := g.levelColorize(sess, myP.CombatLevel(), lvl, fmt.Sprintf("(level %d)", lvl))
+ sess.WriteLine(fmt.Sprintf(" %s %s", p.Name, levelStr))
+ }
+}
diff --git a/internal/game/core_course.go b/internal/game/core_course.go
index 908466d..7d7fe08 100644
--- a/internal/game/core_course.go
+++ b/internal/game/core_course.go
@@ -96,6 +96,7 @@ func (cs *CourseStore) loadAllLocked() {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil
}
+ cfg.ID = id
cs.courses[cfg.ID] = &cfg
totalObstacles := len(cfg.Obstacles)
diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go
index 1129fee..517e051 100644
--- a/internal/game/sys_science.go
+++ b/internal/game/sys_science.go
@@ -49,6 +49,7 @@ func (g *Game) LoadMods() error {
if err := yaml.Unmarshal(data, &m); err != nil {
return nil
}
+ m.ID = id
AllMods = append(AllMods, &m)
return nil
})
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index 6afc757..fa0301f 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -50,6 +50,7 @@ func (g *Game) LoadTechs() error {
if err := yaml.Unmarshal(data, &t); err != nil {
return nil
}
+ t.ID = id
AllTechs = append(AllTechs, &t)
return nil
})
diff --git a/internal/game/ui_help.go b/internal/game/ui_help.go
index d482fce..28f4371 100644
--- a/internal/game/ui_help.go
+++ b/internal/game/ui_help.go
@@ -45,6 +45,7 @@ var commandList = []cmdEntry{
{"fish", "Active", "Fish at fishing spots (Fishing)"},
{"fletch", "Free", "Fletch logs into bows (Fletching)"},
{"get / take / pick", "Active", "Pick up items from the ground"},
+ {"global", "Instant", "Chat with everyone on the server"},
{"help", "Instant", "Show help topics"},
{"id / identify", "Active", "Identify herbs (Pharmacy)"},
{"inventory / i / inv", "Instant", "Show your inventory"},
@@ -78,6 +79,7 @@ var commandList = []cmdEntry{
{"use", "Active", "Use an object (crafting)"},
{"walk", "Active", "Pathfind to a room or multi-step walk"},
{"wear / wield", "Free", "Equip items"},
+ {"who", "Instant", "List everyone online and their combat level"},
}
func LoadHelp(dataDir string) ([]HelpDef, error) {
diff --git a/internal/net/server.go b/internal/net/server.go
index 6626592..5c98b62 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -9,6 +9,7 @@ import (
"sync"
"thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/config"
"thehouseoficarus/internal/player"
)
@@ -329,6 +330,21 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
}
}
+// wrap applies the player's wrap_width option to outgoing text. It is a no-op
+// before login (no Player) so the welcome banner is never reflowed. The width
+// is clamped to a minimum of 80 columns.
+func (sess *Session) wrap(msg string) string {
+ if sess.Player == nil {
+ return msg
+ }
+ width := sess.Player.OptionInt("wrap_width")
+ if width < 80 {
+ width = 80
+ }
+ return color.WrapANSI(msg, width)
+}
+
+// Write sends raw text with no wrapping. Used for prompts and partial lines.
func (sess *Session) Write(msg string) {
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
@@ -336,6 +352,7 @@ func (sess *Session) Write(msg string) {
}
func (sess *Session) WriteLine(msg string) {
+ msg = sess.wrap(msg)
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
sess.Conn.Write([]byte(msg + "\r\n"))
@@ -345,14 +362,15 @@ func (sess *Session) WriteLines(lines ...string) {
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
for _, l := range lines {
- sess.Conn.Write([]byte(l + "\r\n"))
+ sess.Conn.Write([]byte(sess.wrap(l) + "\r\n"))
}
}
func (sess *Session) Writef(format string, args ...interface{}) {
+ msg := sess.wrap(fmt.Sprintf(format, args...))
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
- sess.Conn.Write([]byte(fmt.Sprintf(format, args...)))
+ sess.Conn.Write([]byte(msg))
}
func (sess *Session) Close() error {
diff --git a/internal/object/item_store.go b/internal/object/item_store.go
index 8bc85e4..e930220 100644
--- a/internal/object/item_store.go
+++ b/internal/object/item_store.go
@@ -40,6 +40,7 @@ func (s *ItemStore) Load(id string) (*ItemDef, error) {
if err := yaml.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parse item %s: %w", id, err)
}
+ def.ID = id
s.cache[id] = &def
return &def, nil
}
@@ -56,6 +57,7 @@ func (s *ItemStore) LoadAll() ([]*ItemDef, error) {
if err := yaml.Unmarshal(data, &def); err != nil {
return fmt.Errorf("parse item %s: %w", id, err)
}
+ def.ID = id
s.cache[id] = &def
defs = append(defs, &def)
return nil
diff --git a/internal/object/store.go b/internal/object/store.go
index a11ff7a..cf8b4f9 100644
--- a/internal/object/store.go
+++ b/internal/object/store.go
@@ -39,6 +39,7 @@ func (s *ObjectStore) Load(id string) (*ObjectDef, error) {
if err := yaml.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parse object %s: %w", id, err)
}
+ def.ID = id
s.cache[id] = &def
return &def, nil
}
diff --git a/internal/player/player.go b/internal/player/player.go
index 4b74db7..2cc19bb 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -126,6 +126,7 @@ var OptionDefs = []OptionDef{
{"automap", OptBool, false, nil, "Show map automatically after moving"},
{"queue_silently", OptBool, true, nil, "Suppress messages for queued tick actions"},
{"room_desc_width", OptInt, 70, nil, "Maximum width for room descriptions"},
+ {"wrap_width", OptInt, 120, nil, "Wrap all output to this many columns (minimum 80)"},
{"unicode", OptBool, true, nil, "Unicode box-drawing characters"},
{"run_countdown", OptBool, false, nil, "Show countdown messages when fleeing combat"},
{"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"},
diff --git a/internal/world/hazard.go b/internal/world/hazard.go
index 396343b..1361cff 100644
--- a/internal/world/hazard.go
+++ b/internal/world/hazard.go
@@ -21,7 +21,6 @@ import (
// Tech protection (Kinetic Barrier / Projectile Screen / Neural Firewall) keys
// off AttackType automatically.
type HazardDef struct {
- ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
AttackType string `yaml:"attack_type"` // stab/slash/crush/ranged/science
@@ -59,7 +58,6 @@ func (w *World) LoadHazard(id string) (*HazardDef, error) {
if err := yaml.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parse hazard %s: %w", id, err)
}
- def.ID = id
w.hazardMu.Lock()
w.hazardDefs[id] = &def