aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/color/color.go37
-rw-r--r--internal/color/color_test.go64
-rw-r--r--internal/config/config.go77
-rw-r--r--internal/game/cmd_aps.go12
-rw-r--r--internal/game/cmd_color.go15
-rw-r--r--internal/game/cmd_colortable.go79
-rw-r--r--internal/game/cmd_mods.go16
-rw-r--r--internal/game/cmd_option.go16
-rw-r--r--internal/game/cmd_score.go8
-rw-r--r--internal/game/cmd_skills.go20
-rw-r--r--internal/game/cmd_symbol.go4
-rw-r--r--internal/game/cmd_tech.go12
-rw-r--r--internal/game/core_login_account.go16
-rw-r--r--internal/game/hacking/mastermind.go12
-rw-r--r--internal/game/hacking/wumpus.go6
-rw-r--r--internal/game/look_entities.go2
-rw-r--r--internal/game/look_room.go26
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/map_test.go99
-rw-r--r--internal/game/production_menu.go2
-rw-r--r--internal/game/render_color.go10
-rw-r--r--internal/game/render_map.go179
-rw-r--r--internal/game/validate_source.go2
-rw-r--r--internal/player/player.go2
-rw-r--r--internal/validate/checks.go113
-rw-r--r--internal/validate/grid_test.go105
-rw-r--r--internal/validate/validate.go3
-rw-r--r--internal/world/room.go1
28 files changed, 712 insertions, 228 deletions
diff --git a/internal/color/color.go b/internal/color/color.go
index 22fa143..13fca69 100644
--- a/internal/color/color.go
+++ b/internal/color/color.go
@@ -243,6 +243,24 @@ func NoColor() ColorSpec {
return ColorSpec{Fg: -1, Bg: -1}
}
+// Average returns a ColorSpec whose foreground is the RGB midpoint of the two
+// inputs' foregrounds. If only one input has a foreground, that one is used; if
+// neither does, NoColor is returned. Styles and background are not carried over.
+func Average(a, b ColorSpec) ColorSpec {
+ if a.Fg < 0 && b.Fg < 0 {
+ return NoColor()
+ }
+ if a.Fg < 0 {
+ return ColorSpec{Fg: b.Fg, Bg: -1}
+ }
+ if b.Fg < 0 {
+ return ColorSpec{Fg: a.Fg, Bg: -1}
+ }
+ r1, g1, b1 := Xterm256ToRGB(a.Fg)
+ r2, g2, b2 := Xterm256ToRGB(b.Fg)
+ return ColorSpec{Fg: NearestXterm256((r1+r2)/2, (g1+g2)/2, (b1+b2)/2), Bg: -1}
+}
+
func Parse(input string) ColorSpec {
spec := ColorSpec{Fg: -1, Bg: -1}
for _, token := range strings.Fields(input) {
@@ -254,14 +272,14 @@ func Parse(input string) ColorSpec {
case token == "underline":
spec.Underline = true
case strings.HasPrefix(token, "bg:"):
- if n, err := strconv.Atoi(strings.TrimPrefix(token, "bg:")); err == nil && n >= 0 && n <= 255 {
+ if n, ok := parseColorIndex(strings.TrimPrefix(token, "bg:")); ok {
spec.Bg = n
}
case strings.HasPrefix(token, "g:"):
parts := strings.Split(token[2:], ",")
var stops []int
for _, p := range parts {
- if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil && n >= 0 && n <= 255 {
+ if n, ok := parseColorIndex(strings.TrimSpace(p)); ok {
stops = append(stops, n)
}
}
@@ -269,7 +287,7 @@ func Parse(input string) ColorSpec {
spec.Gradient = stops
}
default:
- if n, err := strconv.Atoi(token); err == nil && n >= 0 && n <= 255 {
+ if n, ok := parseColorIndex(token); ok {
spec.Fg = n
}
}
@@ -277,6 +295,19 @@ func Parse(input string) ColorSpec {
return spec
}
+// parseColorIndex parses a 1-2 digit hexadecimal color index (00-FF, case
+// insensitive) into its 0-255 value. Out-of-range or non-hex input fails.
+func parseColorIndex(s string) (int, bool) {
+ if s == "" {
+ return 0, false
+ }
+ n, err := strconv.ParseUint(s, 16, 0)
+ if err != nil || n > 255 {
+ return 0, false
+ }
+ return int(n), true
+}
+
func (s ColorSpec) Empty() bool {
return s.Fg < 0 && s.Bg < 0 && !s.Bold && !s.Dim && !s.Underline && len(s.Gradient) == 0
}
diff --git a/internal/color/color_test.go b/internal/color/color_test.go
index c1f5e5b..bffc0d7 100644
--- a/internal/color/color_test.go
+++ b/internal/color/color_test.go
@@ -53,3 +53,67 @@ func TestWrapANSISplitsOnNewlines(t *testing.T) {
t.Errorf("WrapANSI newline handling = %q, want %q", got, want)
}
}
+
+func TestParseHexIndex(t *testing.T) {
+ cases := []struct {
+ in string
+ want int
+ }{
+ {"00", 0},
+ {"0F", 15},
+ {"0f", 15},
+ {"D0", 208},
+ {"FF", 255},
+ {"F", 15},
+ }
+ for _, c := range cases {
+ if got := Parse(c.in).Fg; got != c.want {
+ t.Errorf("Parse(%q).Fg = %d, want %d", c.in, got, c.want)
+ }
+ }
+}
+
+func TestParseHexRejectsInvalid(t *testing.T) {
+ for _, in := range []string{"ZZ", "100", "G1"} {
+ if got := Parse(in).Fg; got != -1 {
+ t.Errorf("Parse(%q).Fg = %d, want -1 (rejected)", in, got)
+ }
+ }
+}
+
+func TestParseHexBgAndGradient(t *testing.T) {
+ s := Parse("D0 bg:00 g:C4,52 bold")
+ if s.Fg != 208 {
+ t.Errorf("Fg = %d, want 208", s.Fg)
+ }
+ if s.Bg != 0 {
+ t.Errorf("Bg = %d, want 0", s.Bg)
+ }
+ if !s.Bold {
+ t.Error("Bold not set")
+ }
+ if len(s.Gradient) != 2 || s.Gradient[0] != 196 || s.Gradient[1] != 82 {
+ t.Errorf("Gradient = %v, want [196 82]", s.Gradient)
+ }
+}
+
+func TestAverage(t *testing.T) {
+ // black (0) and white (15) average to a mid grey.
+ mid := Average(Parse("00"), Parse("0F"))
+ if mid.Fg < 0 {
+ t.Fatalf("expected a foreground, got %d", mid.Fg)
+ }
+ r, g, b := Xterm256ToRGB(mid.Fg)
+ if r > 200 || r < 55 || g > 200 || g < 55 || b > 200 || b < 55 {
+ t.Errorf("midpoint of black/white not grey: rgb=%d,%d,%d (idx %d)", r, g, b, mid.Fg)
+ }
+
+ // one side colorless -> use the other.
+ if got := Average(NoColor(), Parse("D0")); got.Fg != 208 {
+ t.Errorf("Average(none, D0).Fg = %d, want 208", got.Fg)
+ }
+ // both colorless -> NoColor.
+ if got := Average(NoColor(), NoColor()); got.Fg != -1 {
+ t.Errorf("Average(none, none).Fg = %d, want -1", got.Fg)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 33e25fd..91d5986 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -19,46 +19,47 @@ type ColorsConfig map[string]string
func DefaultColors() ColorsConfig {
return ColorsConfig{
- "room_name": "81 bold",
- "room_number": "240",
- "room_desc": "252",
- "direction": "75",
- "exit_direction": "75",
- "exit_name": "114",
+ "room_name": "51 bold",
+ "room_number": "F0",
+ "room_desc": "FC",
+ "direction": "4B",
+ "exit_direction": "4B",
+ "exit_name": "72",
"protected_mob": "off",
"mob": "off",
- "damage_dealt": "33",
- "damage_taken": "196",
- "damage": "196",
+ "damage_dealt": "21",
+ "damage_taken": "C4",
+ "damage": "C4",
- "xp": "222",
- "level_up": "226 bold",
- "item": "223",
- "player_name": "189",
- "death": "196 bold",
- "victory": "83",
- "miss": "243",
- "error": "209",
- "say": "230",
- "global": "45",
- "dialog": "117",
- "broadcast": "215",
+ "xp": "DE",
+ "level_up": "E2 bold",
+ "item": "DF",
+ "player_name": "BD",
+ "death": "C4 bold",
+ "victory": "53",
+ "miss": "F3",
+ "error": "D1",
+ "say": "E6",
+ "global": "2D",
+ "dialog": "75",
+ "broadcast": "D7",
"sequence": "off",
- "fire": "208",
- "eat_food": "156",
- "drop_message": "186",
- "credits_pickup": "220",
- "visual_tick": "33 dim",
- "visual_first_tick": "196 bold",
- "battery": "45",
- "tech_depleted": "196",
- "science_mod": "99",
- "assassin_task": "219",
- "dim": "243 dim",
- "warning": "208",
- "farm_grow": "34",
- "farm_disease": "196",
- "map_at": "15",
+ "fire": "D0",
+ "eat_food": "9C",
+ "drop_message": "BA",
+ "credits_pickup": "DC",
+ "visual_tick": "21 dim",
+ "visual_first_tick": "C4 bold",
+ "battery": "2D",
+ "tech_depleted": "C4",
+ "science_mod": "63",
+ "assassin_task": "DB",
+ "dim": "F3 dim",
+ "warning": "D0",
+ "farm_grow": "22",
+ "farm_disease": "C4",
+ "map_at": "0F",
+ "map_blocked": "C4",
}
}
@@ -68,7 +69,7 @@ type GameConfig struct {
}
type ValidationConfig struct {
- CheckSources []int `yaml:"check_sources"`
+ RootRooms []int `yaml:"root_rooms"`
IgnoreUnreachable []int `yaml:"ignore_unreachable"`
}
@@ -101,7 +102,7 @@ func Default() *Config {
Game: GameConfig{
TickLength: 600,
StartupValidation: ValidationConfig{
- CheckSources: []int{1},
+ RootRooms: []int{1},
IgnoreUnreachable: []int{},
},
},
diff --git a/internal/game/cmd_aps.go b/internal/game/cmd_aps.go
index 265277a..5e209a6 100644
--- a/internal/game/cmd_aps.go
+++ b/internal/game/cmd_aps.go
@@ -129,11 +129,11 @@ func displayApsNodes(g *Game, sess *net.Session, p *player.Player, known []int)
for i, info := range infos {
distStr := strconv.Itoa(info.Distance)
if info.Distance == 0 {
- distStr = color.Render(mode, color.Parse("82"), "here")
+ distStr = color.Render(mode, color.Parse("52"), "here")
}
rows[i] = []string{
- color.Render(mode, color.Parse("245"), fmt.Sprintf("#%d", info.ID)),
- color.Render(mode, color.Parse("75"), info.Name),
+ color.Render(mode, color.Parse("F5"), fmt.Sprintf("#%d", info.ID)),
+ color.Render(mode, color.Parse("4B"), info.Name),
distStr,
}
}
@@ -141,9 +141,9 @@ func displayApsNodes(g *Game, sess *net.Session, p *player.Player, known []int)
t := &ui.Table{
Title: "APS Datapad",
Columns: []string{
- color.Render(mode, color.Parse("245"), "Room"),
- color.Render(mode, color.Parse("75"), "Name"),
- color.Render(mode, color.Parse("230"), "Distance"),
+ color.Render(mode, color.Parse("F5"), "Room"),
+ color.Render(mode, color.Parse("4B"), "Name"),
+ color.Render(mode, color.Parse("E6"), "Distance"),
},
Rows: rows,
}
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
index 33b406d..8e90f02 100644
--- a/internal/game/cmd_color.go
+++ b/internal/game/cmd_color.go
@@ -57,8 +57,8 @@ func (g *Game) doColor(sess *net.Session, input string) {
spec := color.Parse(value)
if spec.Empty() && value != "" {
sess.WriteLine(fmt.Sprintf("Invalid color string: %s", value))
- sess.WriteLine("Format: <0-255> [bg:<0-255>] [bold] [dim] [underline]")
- sess.WriteLine("Example: 208 bold")
+ sess.WriteLine("Format: <00-FF> [bg:<00-FF>] [bold] [dim] [underline]")
+ sess.WriteLine("Example: D0 bold")
return
}
@@ -162,6 +162,9 @@ var colorCategoryOrder = []string{
"eat_food",
"drop_message",
"credits_pickup",
+
+ "map_at",
+ "map_blocked",
}
func showColorTable(g *Game, sess *net.Session) {
@@ -169,9 +172,9 @@ func showColorTable(g *Game, sess *net.Session) {
mode := g.colorMode(sess)
table := &ui.Table{
Columns: []string{
- color.Render(mode, color.Parse("75"), "Target"),
+ color.Render(mode, color.Parse("4B"), "Target"),
"Color",
- color.Render(mode, color.Parse("243"), "Source"),
+ color.Render(mode, color.Parse("F3"), "Source"),
},
}
for _, cat := range colorCategoryOrder {
@@ -185,9 +188,9 @@ func showColorTable(g *Game, sess *net.Session) {
}
}
table.Rows = append(table.Rows, []string{
- color.Render(mode, color.Parse("75"), cat),
+ color.Render(mode, color.Parse("4B"), cat),
coloredVal,
- color.Render(mode, color.Parse("243"), source),
+ color.Render(mode, color.Parse("F3"), source),
})
}
for _, line := range table.Render(p.OptionBool("unicode")) {
diff --git a/internal/game/cmd_colortable.go b/internal/game/cmd_colortable.go
index baa30e8..d228a34 100644
--- a/internal/game/cmd_colortable.go
+++ b/internal/game/cmd_colortable.go
@@ -15,44 +15,18 @@ func (g *Game) doColortable(sess *net.Session) {
return
}
- p := sess.Player
- unicode := p.OptionBool("unicode")
-
- sess.WriteLine(fmt.Sprintf("Color mode: %s", mode))
-
- sess.WriteLine("")
- writeSectionHeader(sess, "Foreground Colors", unicode)
writeColorBlock(sess, mode, true)
-
sess.WriteLine("")
- writeSectionHeader(sess, "Background Colors", unicode)
writeColorBlock(sess, mode, false)
-
- sess.WriteLine("")
- writeSectionHeader(sess, "Combinations", unicode)
- writeCombinations(sess, mode)
-
- sess.WriteLine("")
- writeSectionHeader(sess, "ANSI Mode (16 colors)", unicode)
- writeANSITable(sess, mode)
}
func (g *Game) executeColortable(sess *net.Session, args []string, rawInput string) {
g.doColortable(sess)
}
-func writeSectionHeader(sess *net.Session, title string, unicode bool) {
- if unicode {
- sess.WriteLine(fmt.Sprintf("── %s ──", title))
- } else {
- sess.WriteLine(fmt.Sprintf("-- %s --", title))
- }
-}
-
func writeColorBlock(sess *net.Session, mode string, fg bool) {
- sess.WriteLine("Standard:")
+ sess.WriteLine("ANSI 16 Colors:")
writeColorRow(sess, mode, 0, 8, fg)
- sess.WriteLine("Bright:")
writeColorRow(sess, mode, 8, 16, fg)
sess.WriteLine("Color cube:")
@@ -72,7 +46,7 @@ func writeColorBlock(sess *net.Session, mode string, fg bool) {
sb.WriteString(color.ContrastFg(mode, idx))
sb.WriteString(color.BgCode(mode, idx))
}
- sb.WriteString(fmt.Sprintf("%4d", idx))
+ sb.WriteString(fmt.Sprintf(" %02X", idx))
sb.WriteString(color.Reset)
}
if block < 2 {
@@ -100,55 +74,8 @@ func writeColorRow(sess *net.Session, mode string, start, end int, fg bool) {
sb.WriteString(color.ContrastFg(mode, i))
sb.WriteString(color.BgCode(mode, i))
}
- sb.WriteString(fmt.Sprintf("%4d", i))
+ sb.WriteString(fmt.Sprintf(" %02X", i))
sb.WriteString(color.Reset)
}
sess.WriteLine(sb.String())
}
-
-func writeCombinations(sess *net.Session, mode string) {
- type combo struct {
- fg int
- bg int
- desc string
- }
- combos := []combo{
- {15, 0, "white on black"},
- {208, 0, "orange on black"},
- {0, 178, "black on gold"},
- {15, 24, "white on dark blue"},
- {0, 15, "black on white"},
- {15, 88, "white on dark red"},
- {178, 52, "gold on brown"},
- {14, 0, "bright cyan on black"},
- {0, 228, "black on pale yellow"},
- {15, 22, "white on dark green"},
- {9, 0, "bright red on black"},
- {11, 17, "bright yellow on navy"},
- }
- for _, c := range combos {
- spec := color.ColorSpec{Fg: c.fg, Bg: c.bg}
- sample := color.Render(mode, spec, fmt.Sprintf(" %3d on %3d ", c.fg, c.bg))
- sess.WriteLine(fmt.Sprintf(" %s %s", sample, c.desc))
- }
-}
-
-func writeANSITable(sess *net.Session, mode string) {
- names := [16]string{
- "black", "red", "green", "yellow",
- "blue", "magenta", "cyan", "white",
- "brt black", "brt red", "brt green", "brt yellow",
- "brt blue", "brt magenta", "brt cyan", "brt white",
- }
- for row := 0; row < 2; row++ {
- var sb strings.Builder
- for col := 0; col < 8; col++ {
- idx := row*8 + col
- sb.WriteString(color.FgCode(mode, idx))
- sb.WriteString(fmt.Sprintf("%3d", idx))
- sb.WriteString(color.Reset)
- sb.WriteString(fmt.Sprintf(" %-12s", names[idx]))
- }
- sess.WriteLine(sb.String())
- }
-}
diff --git a/internal/game/cmd_mods.go b/internal/game/cmd_mods.go
index 2fe9ae5..4f3f9ff 100644
--- a/internal/game/cmd_mods.go
+++ b/internal/game/cmd_mods.go
@@ -55,10 +55,10 @@ func (g *Game) doMods(sess *net.Session, showAll bool) {
t := &ui.Table{
Title: cat.Name,
Columns: []string{
- color.Render(mode, color.Parse("75"), "Module"),
- color.Render(mode, color.Parse("245"), "Level"),
- color.Render(mode, color.Parse("222"), "Cost"),
- color.Render(mode, color.Parse("179"), "XP"),
+ color.Render(mode, color.Parse("4B"), "Module"),
+ color.Render(mode, color.Parse("F5"), "Level"),
+ color.Render(mode, color.Parse("DE"), "Cost"),
+ color.Render(mode, color.Parse("B3"), "XP"),
},
}
@@ -77,10 +77,10 @@ func (g *Game) doMods(sess *net.Session, showAll bool) {
})
} else if unlocked {
t.Rows = append(t.Rows, []string{
- color.Render(mode, color.Parse("75"), m.Name),
- color.Render(mode, color.Parse("245"), levelStr),
- color.Render(mode, color.Parse("222"), costStr),
- color.Render(mode, color.Parse("179"), xpStr),
+ color.Render(mode, color.Parse("4B"), m.Name),
+ color.Render(mode, color.Parse("F5"), levelStr),
+ color.Render(mode, color.Parse("DE"), costStr),
+ color.Render(mode, color.Parse("B3"), xpStr),
})
} else {
t.Rows = append(t.Rows, []string{
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 836a150..a2e54c2 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -18,10 +18,10 @@ func (g *Game) doOption(sess *net.Session, input string) {
if input == "" {
table := &ui.Table{
Columns: []string{
- color.Render(mode, color.Parse("75"), "Option"),
- color.Render(mode, color.Parse("230"), "Value"),
- color.Render(mode, color.Parse("243"), "Valid"),
- color.Render(mode, color.Parse("252"), "Description"),
+ color.Render(mode, color.Parse("4B"), "Option"),
+ color.Render(mode, color.Parse("E6"), "Value"),
+ color.Render(mode, color.Parse("F3"), "Valid"),
+ color.Render(mode, color.Parse("FC"), "Description"),
},
}
for _, def := range player.OptionDefs {
@@ -29,10 +29,10 @@ func (g *Game) doOption(sess *net.Session, input string) {
val = truncateForTable(val)
valid := formatValidValues(&def)
table.Rows = append(table.Rows, []string{
- color.Render(mode, color.Parse("75"), def.Name),
- color.Render(mode, color.Parse("230"), val),
- color.Render(mode, color.Parse("243"), valid),
- color.Render(mode, color.Parse("252"), def.Description),
+ color.Render(mode, color.Parse("4B"), def.Name),
+ color.Render(mode, color.Parse("E6"), val),
+ color.Render(mode, color.Parse("F3"), valid),
+ color.Render(mode, color.Parse("FC"), def.Description),
})
}
for _, line := range table.Render(p.OptionBool("unicode")) {
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index 20c61d9..0554f32 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -92,13 +92,13 @@ func (g *Game) doScore(sess *net.Session) {
sep()
nameStr := g.colorize(sess, "player_name", p.Name)
- clStr := color.Render(mode, color.Parse("230"), fmt.Sprintf("Combat Lvl %d", p.CombatLevel()))
+ clStr := color.Render(mode, color.Parse("E6"), fmt.Sprintf("Combat Lvl %d", p.CombatLevel()))
roomStr := g.colorize(sess, "room_number", fmt.Sprintf("Room #%d", p.RoomID))
row1 := padded(nameStr, 20) + padded(clStr, 24) + roomStr
content(row1)
creditsStr := g.colorize(sess, "credits_pickup", formatInt(p.Credits))
- styleStr := color.Render(mode, color.Parse("75"), fmt.Sprintf("Style: %s", p.AttackStyle))
+ styleStr := color.Render(mode, color.Parse("4B"), fmt.Sprintf("Style: %s", p.AttackStyle))
invStr := fmt.Sprintf("Inv: %d/28", 28-p.FreeSlots())
row2 := padded("Credits: "+creditsStr, 20) + padded(styleStr, 24) + invStr
content(row2)
@@ -111,7 +111,7 @@ func (g *Game) doScore(sess *net.Session) {
hpLine := fmt.Sprintf("HP [%s] %s / %s",
hpBarStr,
color.Render(mode, color.ColorSpec{Fg: hpFg}, fmt.Sprint(p.HP)),
- color.Render(mode, color.Parse("230"), fmt.Sprint(p.MaxHP())))
+ color.Render(mode, color.Parse("E6"), fmt.Sprint(p.MaxHP())))
content(hpLine)
batBarRaw := ui.RenderBarF(p.Battery, p.MaxBattery(), 26, unicode)
@@ -119,7 +119,7 @@ func (g *Game) doScore(sess *net.Session) {
batLine := fmt.Sprintf("BAT [%s] %s / %s",
batBarStr,
color.Render(mode, color.ColorSpec{Fg: 45}, fmt.Sprintf("%.1f", p.Battery)),
- color.Render(mode, color.Parse("230"), fmt.Sprintf("%.0f", p.MaxBattery())))
+ color.Render(mode, color.Parse("E6"), fmt.Sprintf("%.0f", p.MaxBattery())))
content(batLine)
gap()
diff --git a/internal/game/cmd_skills.go b/internal/game/cmd_skills.go
index 9dba5b7..9321b96 100644
--- a/internal/game/cmd_skills.go
+++ b/internal/game/cmd_skills.go
@@ -17,22 +17,22 @@ func (g *Game) doSkills(sess *net.Session) {
p := sess.Player
mode := g.colorMode(sess)
t := &ui.Table{Title: "Skills", Columns: []string{
- color.Render(mode, color.Parse("75"), "Skill"),
- color.Render(mode, color.Parse("245"), "Abbr"),
- color.Render(mode, color.Parse("230"), "Level"),
- color.Render(mode, color.Parse("222"), "XP"),
- color.Render(mode, color.Parse("179"), "XP to Next"),
+ color.Render(mode, color.Parse("4B"), "Skill"),
+ color.Render(mode, color.Parse("F5"), "Abbr"),
+ color.Render(mode, color.Parse("E6"), "Level"),
+ color.Render(mode, color.Parse("DE"), "XP"),
+ color.Render(mode, color.Parse("B3"), "XP to Next"),
}}
for _, s := range player.AllSkills {
level := p.Level(s)
xp := p.Skills[s]
next := player.XPForNextLevel(xp)
t.Rows = append(t.Rows, []string{
- color.Render(mode, color.Parse("75"), string(s)),
- color.Render(mode, color.Parse("245"), player.SkillAbbr[s]),
- color.Render(mode, color.Parse("230"), strconv.Itoa(level)),
- color.Render(mode, color.Parse("222"), strconv.Itoa(xp)),
- color.Render(mode, color.Parse("179"), strconv.Itoa(next)),
+ color.Render(mode, color.Parse("4B"), string(s)),
+ color.Render(mode, color.Parse("F5"), player.SkillAbbr[s]),
+ color.Render(mode, color.Parse("E6"), strconv.Itoa(level)),
+ color.Render(mode, color.Parse("DE"), strconv.Itoa(xp)),
+ color.Render(mode, color.Parse("B3"), strconv.Itoa(next)),
})
}
for _, line := range t.Render(p.OptionBool("unicode")) {
diff --git a/internal/game/cmd_symbol.go b/internal/game/cmd_symbol.go
index bb97010..0c3014b 100644
--- a/internal/game/cmd_symbol.go
+++ b/internal/game/cmd_symbol.go
@@ -58,7 +58,7 @@ func (g *Game) executeSymbol(sess *net.Session, args []string, rawInput string)
colorSpec := strings.Join(args, " ")
spec := color.Parse(colorSpec)
if spec.Empty() {
- sess.WriteLine("Invalid color spec. Use a color number like 232 and/or bold/dim/underline.")
+ sess.WriteLine("Invalid color spec. Use a hex color code like FF and/or bold/dim/underline.")
return
}
existing, ok := p.MapSymbols[p.RoomID]
@@ -82,7 +82,7 @@ func (g *Game) executeSymbol(sess *net.Session, args []string, rawInput string)
if colorSpec != "" {
spec := color.Parse(colorSpec)
if spec.Empty() {
- sess.WriteLine("Invalid color spec. Use a color number like 232 and/or bold/dim/underline.")
+ sess.WriteLine("Invalid color spec. Use a hex color code like FF and/or bold/dim/underline.")
return
}
}
diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go
index 19b5425..8c6f14f 100644
--- a/internal/game/cmd_tech.go
+++ b/internal/game/cmd_tech.go
@@ -118,7 +118,7 @@ func (g *Game) doTechList(sess *net.Session) {
"",
fmt.Sprintf("Battery: %s/%s",
g.colorize(sess, "battery", fmt.Sprintf("%.1f", p.Battery)),
- color.Render(mode, color.Parse("230"), fmt.Sprintf("%.0f", p.MaxBattery())),
+ color.Render(mode, color.Parse("E6"), fmt.Sprintf("%.0f", p.MaxBattery())),
),
)
@@ -129,14 +129,14 @@ func (g *Game) doTechList(sess *net.Session) {
nameStr := tech.Name
if tech.Level <= techLevel {
if p.HasActiveTech(tech.ID) {
- status = color.Render(mode, color.Parse("82"), "ON")
+ status = color.Render(mode, color.Parse("52"), "ON")
} else {
- status = color.Render(mode, color.Parse("240"), "off")
+ status = color.Render(mode, color.Parse("F0"), "off")
}
- nameStr = color.Render(mode, color.Parse("75"), tech.Name)
+ nameStr = color.Render(mode, color.Parse("4B"), tech.Name)
} else {
- status = color.Render(mode, color.Parse("160"), "locked")
- nameStr = color.Render(mode, color.Parse("240"), tech.Name)
+ status = color.Render(mode, color.Parse("A0"), "locked")
+ nameStr = color.Render(mode, color.Parse("F0"), tech.Name)
}
effect := techEffectString(tech)
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index 093ae71..fc4a764 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -194,18 +194,18 @@ func (g *Game) showMenu(sess *net.Session) {
}
if len(sess.Account.Characters) > 0 {
lines = append(lines,
- color.ExpandTags(mode, " {3}(C){/}{4}onnect character to THOI{/}"),
+ color.ExpandTags(mode, " {03}(C){/}{04}onnect character to THOI{/}"),
"",
- color.ExpandTags(mode, " {3}(L){/}{4}ist characters{/}"),
- color.ExpandTags(mode, " {3}(R){/}{4}ename character{/}"),
- color.ExpandTags(mode, " {3}(D){/}{4}elete character{/}"),
+ color.ExpandTags(mode, " {03}(L){/}{04}ist characters{/}"),
+ color.ExpandTags(mode, " {03}(R){/}{04}ename character{/}"),
+ color.ExpandTags(mode, " {03}(D){/}{04}elete character{/}"),
)
}
lines = append(lines,
- color.ExpandTags(mode, " {3}(N){/}{4}ew character{/}"),
- color.ExpandTags(mode, " {3}(P){/}{4}urge account{/}"),
- color.ExpandTags(mode, " {3}(A){/}{4}ccount rename{/}"),
- color.ExpandTags(mode, " {3}(Q){/}{4}uit{/}"),
+ color.ExpandTags(mode, " {03}(N){/}{04}ew character{/}"),
+ color.ExpandTags(mode, " {03}(P){/}{04}urge account{/}"),
+ color.ExpandTags(mode, " {03}(A){/}{04}ccount rename{/}"),
+ color.ExpandTags(mode, " {03}(Q){/}{04}uit{/}"),
"",
)
sess.WriteLines(lines...)
diff --git a/internal/game/hacking/mastermind.go b/internal/game/hacking/mastermind.go
index 8d4eb1c..9a20907 100644
--- a/internal/game/hacking/mastermind.go
+++ b/internal/game/hacking/mastermind.go
@@ -45,8 +45,8 @@ func (m *MastermindGame) Init(level int) string {
sb.WriteString("No symbol repeats. You have 10 attempts to crack the code.\n")
sb.WriteString("\n")
sb.WriteString("After each guess, you'll see:\n")
- sb.WriteString(" {40}[X]{/} = correct symbol in correct position\n")
- sb.WriteString(" {226}[O]{/} = correct symbol in wrong position\n")
+ sb.WriteString(" {28}[X]{/} = correct symbol in correct position\n")
+ sb.WriteString(" {E2}[O]{/} = correct symbol in wrong position\n")
sb.WriteString(" [ ] = symbol not in code\n")
sb.WriteString("\n")
sb.WriteString("Commands:\n")
@@ -108,11 +108,11 @@ func (m *MastermindGame) HandleInput(input string) (string, bool, bool) {
for i, d := range guess {
match := "[ ]"
if guess[i] == m.code[i] {
- match = "{40}[X]{/}"
+ match = "{28}[X]{/}"
} else {
for j := 0; j < 4; j++ {
if guess[i] == m.code[j] {
- match = "{226}[O]{/}"
+ match = "{E2}[O]{/}"
break
}
}
@@ -150,7 +150,7 @@ func (m *MastermindGame) doStatus() string {
for j, d := range g.Digits {
match := "[ ]"
if g.Digits[j] == m.code[j] {
- match = "{40}[X]{/}"
+ match = "{28}[X]{/}"
} else {
found := false
for k := 0; k < 4; k++ {
@@ -160,7 +160,7 @@ func (m *MastermindGame) doStatus() string {
}
}
if found {
- match = "{226}[O]{/}"
+ match = "{E2}[O]{/}"
}
}
_ = d
diff --git a/internal/game/hacking/wumpus.go b/internal/game/hacking/wumpus.go
index 91b9243..1f0a434 100644
--- a/internal/game/hacking/wumpus.go
+++ b/internal/game/hacking/wumpus.go
@@ -218,16 +218,16 @@ func (w *WumpusGame) roomDesc() string {
for _, a := range dodecahedron[cur] {
if a == w.wumpus {
- sb.WriteString("{196}You detect corrupted data nearby...{/}\n")
+ sb.WriteString("{C4}You detect corrupted data nearby...{/}\n")
}
for _, p := range w.pits {
if a == p {
- sb.WriteString("{208}You sense a void in the network...{/}\n")
+ sb.WriteString("{D0}You sense a void in the network...{/}\n")
}
}
for _, i := range w.ice {
if a == i {
- sb.WriteString("{226}You hear static crackling...{/}\n")
+ sb.WriteString("{E2}You hear static crackling...{/}\n")
}
}
}
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index 36cff07..f6b58a1 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -37,7 +37,7 @@ func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Roo
}
hp = fmt.Sprintf(" [%d%% complete]", pct)
} else {
- hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP)
+ hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("A7"), fmt.Sprint(m.HP)), m.MaxHP)
}
}
var desc string
diff --git a/internal/game/look_room.go b/internal/game/look_room.go
index 8a1da04..585efb0 100644
--- a/internal/game/look_room.go
+++ b/internal/game/look_room.go
@@ -11,10 +11,28 @@ import (
)
func (g *Game) showRoomName(sess *net.Session, p *player.Player, room *world.Room) {
- sess.WriteLines(
- "",
- fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID))),
- )
+ title := fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID)))
+ if bracket := g.roomSymbolBracket(sess, p, room); bracket != "" {
+ title += " " + bracket
+ }
+ sess.WriteLines("", title)
+}
+
+// roomSymbolBracket renders a dim-bracketed colored symbol (e.g. "[D]") when the
+// player has set a custom symbol for this room, else "".
+func (g *Game) roomSymbolBracket(sess *net.Session, p *player.Player, room *world.Room) string {
+ if p == nil {
+ return ""
+ }
+ if _, ok := p.MapSymbols[room.ID]; !ok {
+ return ""
+ }
+ ch, spec := roomMapSymbol(g, sess, room.ID, false)
+ mode := g.colorMode(sess)
+ dimSpec := g.resolveColor(sess, "dim")
+ return color.Render(mode, dimSpec, "[") +
+ color.Render(mode, spec, string(ch)) +
+ color.Render(mode, dimSpec, "]")
}
func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *world.Room) []string {
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 96dc174..c5fb072 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -59,7 +59,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
var levelStr string
if best.Protected {
- levelStr = color.Render(g.colorMode(sess), color.Parse("223"), "(protected)")
+ levelStr = color.Render(g.colorMode(sess), color.Parse("DF"), "(protected)")
} else {
mobLevel := mobCombatLevel(best)
levelStr = g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel))
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index 9a85806..05e70d0 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -1,13 +1,112 @@
package game
import (
+ "os"
+ "path/filepath"
+ "strconv"
"strings"
"testing"
"thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
)
+func writeTempRoom(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(roomsDir, strconv.Itoa(id)+".yaml")
+ if err := os.WriteFile(path, []byte(body), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+
+// TestMapConnectorGlyphs verifies the directional link rendering: bidirectional
+// links draw a bar, one-way (or one-side-blocked) links draw a directional
+// arrow, and links with no traversable direction draw a blocked 'X'.
+func TestMapConnectorGlyphs(t *testing.T) {
+ const condEast = "name: One\nexits:\n east:\n room: 2\n condition:\n flag: gate_open\n"
+
+ cases := []struct {
+ name string
+ room1 string
+ room2 string
+ flagOpen bool
+ wantPresent string
+ wantAbsent []string
+ }{
+ {
+ name: "bidirectional bar",
+ room1: "name: One\nexits:\n east: 2\n",
+ room2: "name: Two\nexits:\n west: 1\n",
+ wantPresent: "-",
+ wantAbsent: []string{"X", "<", ">"},
+ },
+ {
+ name: "one-way east arrow",
+ room1: "name: One\nexits:\n east: 2\n",
+ room2: "name: Two\n",
+ wantPresent: ">",
+ wantAbsent: []string{"X", "-", "<"},
+ },
+ {
+ name: "forward blocked, reverse open -> arrow not X",
+ room1: condEast,
+ room2: "name: Two\nexits:\n west: 1\n",
+ flagOpen: false,
+ wantPresent: "<",
+ wantAbsent: []string{"X", "-", ">"},
+ },
+ {
+ name: "forward blocked, reverse absent -> X",
+ room1: condEast,
+ room2: "name: Two\n",
+ flagOpen: false,
+ wantPresent: "X",
+ wantAbsent: []string{"-", "<", ">"},
+ },
+ {
+ name: "conditional unblocked -> bar",
+ room1: condEast,
+ room2: "name: Two\nexits:\n west: 1\n",
+ flagOpen: true,
+ wantPresent: "-",
+ wantAbsent: []string{"X", "<", ">"},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ writeTempRoom(t, dir, 1, tc.room1)
+ writeTempRoom(t, dir, 2, tc.room2)
+
+ g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()}
+ if tc.flagOpen {
+ g.Flags.Set("gate_open", true)
+ }
+ sess := &net.Session{Player: &player.Player{Flags: map[string]any{}}}
+
+ out := strings.Join(buildTinyMap(g, sess, 1, mapGlyphsForPlayer(false)), "\n")
+ if !strings.Contains(out, tc.wantPresent) {
+ t.Errorf("want %q present:\n%s", tc.wantPresent, out)
+ }
+ for _, a := range tc.wantAbsent {
+ if strings.Contains(out, a) {
+ t.Errorf("want %q absent:\n%s", a, out)
+ }
+ }
+ })
+ }
+}
+
+
+
func TestBuildTinyMap(t *testing.T) {
g := &Game{
Deps: Deps{World: world.New("../../data")},
diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go
index c1d9a96..f625d54 100644
--- a/internal/game/production_menu.go
+++ b/internal/game/production_menu.go
@@ -127,7 +127,7 @@ func (g *Game) showProductionTable(sess *net.Session, p *player.Player, items []
mode := g.colorMode(sess)
skillLevel := p.Level(player.SkillName(skill))
- dimSpec := color.Parse("240")
+ dimSpec := color.Parse("F0")
tbl := &ui.Table{
Title: title,
diff --git a/internal/game/render_color.go b/internal/game/render_color.go
index a5ab8fa..0e31538 100644
--- a/internal/game/render_color.go
+++ b/internal/game/render_color.go
@@ -46,15 +46,15 @@ func levelColorSpec(myLevel, theirLevel int) color.ColorSpec {
diff := theirLevel - myLevel
switch {
case diff == 0:
- return color.Parse("231")
+ return color.Parse("E7")
case diff > 0 && diff < 5:
- return color.Parse("208")
+ return color.Parse("D0")
case diff >= 5:
- return color.Parse("196")
+ return color.Parse("C4")
case diff < 0 && diff > -5:
- return color.Parse("190")
+ return color.Parse("BE")
default:
- return color.Parse("34")
+ return color.Parse("22")
}
}
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index 2bac749..049db06 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -16,6 +16,7 @@ type mapGlyphs struct {
topFill rune
connectorH, connectorV rune
upArrow, downArrow rune
+ leftArrow, rightArrow rune
}
func mapGlyphsForPlayer(unicode bool) mapGlyphs {
@@ -23,13 +24,13 @@ func mapGlyphsForPlayer(unicode bool) mapGlyphs {
return mapGlyphs{
topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝',
side: '║', topFill: '═', connectorH: '-', connectorV: '│',
- upArrow: '↑', downArrow: '↓',
+ upArrow: '↑', downArrow: '↓', leftArrow: '←', rightArrow: '→',
}
}
return mapGlyphs{
topLeft: '.', topRight: '.', bottomLeft: ':', bottomRight: ':',
side: ':', topFill: '.', connectorH: '-', connectorV: '|',
- upArrow: '^', downArrow: 'v',
+ upArrow: '^', downArrow: 'v', leftArrow: '<', rightArrow: '>',
}
}
@@ -129,6 +130,10 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
colorMode := colorModeFor(sess)
atSpec := resolveMapAt(g, sess)
dimSpec := resolveDim(g, sess)
+ ctx := &mapRenderCtx{
+ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID,
+ atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg,
+ }
grid := make([][]mapCell, 5)
for i := range grid {
@@ -170,11 +175,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
continue
}
if exitsConnect(g, leftRoom, rightRoom, world.East, world.West) {
- connSpec := color.NoColor()
- if visited != nil && (!visited[leftRoom] || !visited[rightRoom]) {
- connSpec = dimSpec
- }
- grid[(y+1)*2][(x+1)*2+1] = mapCell{char: mg.connectorH, spec: connSpec}
+ grid[(y+1)*2][(x+1)*2+1] = ctx.connectorCell(leftRoom, rightRoom, world.East, world.West)
}
}
}
@@ -189,11 +190,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
continue
}
if exitsConnect(g, topRoom, bottomRoom, world.South, world.North) {
- connSpec := color.NoColor()
- if visited != nil && (!visited[topRoom] || !visited[bottomRoom]) {
- connSpec = dimSpec
- }
- grid[(y+1)*2+1][(x+1)*2] = mapCell{char: mg.connectorV, spec: connSpec}
+ grid[(y+1)*2+1][(x+1)*2] = ctx.connectorCell(topRoom, bottomRoom, world.South, world.North)
}
}
}
@@ -226,6 +223,10 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
colorMode := colorModeFor(sess)
atSpec := resolveMapAt(g, sess)
dimSpec := resolveDim(g, sess)
+ ctx := &mapRenderCtx{
+ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID,
+ atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg,
+ }
grid := make([][]mapCell, mapHeight)
for i := range grid {
@@ -264,11 +265,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
gr := cy + y*2
gc := cx + x*2 + 1
if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- connSpec := color.NoColor()
- if visited != nil && (!visited[rid] || !visited[rightID]) {
- connSpec = dimSpec
- }
- grid[gr][gc] = mapCell{char: mg.connectorH, spec: connSpec}
+ grid[gr][gc] = ctx.connectorCell(rid, rightID, world.East, world.West)
}
}
}
@@ -278,11 +275,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m
gr := cy + y*2 + 1
gc := cx + x*2
if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- connSpec := color.NoColor()
- if visited != nil && (!visited[rid] || !visited[bottomID]) {
- connSpec = dimSpec
- }
- grid[gr][gc] = mapCell{char: mg.connectorV, spec: connSpec}
+ grid[gr][gc] = ctx.connectorCell(rid, bottomID, world.South, world.North)
}
}
}
@@ -316,15 +309,145 @@ func resolveDim(g *Game, sess *net.Session) color.ColorSpec {
if sess != nil {
return g.resolveColor(sess, "dim")
}
- return color.Parse("243 dim")
+ return color.Parse("F3 dim")
+}
+
+func resolveMapBlocked(g *Game, sess *net.Session) color.ColorSpec {
+ if sess != nil {
+ return g.resolveColor(sess, "map_blocked")
+ }
+ return color.Parse("C4")
+}
+
+// mapRenderCtx bundles the per-render state shared by node and connector drawing
+// so the tiny and full maps build cells the same way.
+type mapRenderCtx struct {
+ g *Game
+ sess *net.Session
+ bg *mapGraph
+ visited map[int]bool
+ currentRoom int
+ atSpec color.ColorSpec
+ dimSpec color.ColorSpec
+ blockedSpec color.ColorSpec
+ mg mapGlyphs
+}
+
+// nodeSpec returns the effective color a room's node is drawn with, mirroring
+// the logic used when placing room glyphs.
+func (c *mapRenderCtx) nodeSpec(roomID int) color.ColorSpec {
+ if roomID == c.currentRoom {
+ return c.atSpec
+ }
+ if c.visited != nil && !c.visited[roomID] {
+ return c.dimSpec
+ }
+ _, spec := roomMapSymbol(c.g, c.sess, roomID, false)
+ return spec
+}
+
+// connectorCell builds the link cell between two grid-adjacent rooms based on
+// the per-direction traversability of the two exits joining them:
+// - both directions open -> bidirectional bar (- / |)
+// - exactly one open -> arrow pointing along the open direction
+// - neither open (>=1 blocked) -> blocked 'X'
+//
+// Bars and arrows use the normal link coloring (dim if an endpoint is unvisited,
+// otherwise the gradient average); only 'X' uses the blocked color.
+func (c *mapRenderCtx) connectorCell(roomA, roomB int, dirAB, dirBA world.ExitDir) mapCell {
+ fwd := exitStateTo(c.g, c.sess, roomA, dirAB, roomB) // A -> B
+ bwd := exitStateTo(c.g, c.sess, roomB, dirBA, roomA) // B -> A
+
+ var glyph rune
+ switch {
+ case fwd == exitOpen && bwd == exitOpen:
+ glyph = barGlyph(c.mg, dirAB)
+ case fwd == exitOpen:
+ glyph = arrowGlyph(c.mg, dirAB)
+ case bwd == exitOpen:
+ glyph = arrowGlyph(c.mg, dirBA)
+ default:
+ // The caller only draws a connector when at least one exit exists, so
+ // reaching here means every existing direction is blocked.
+ return mapCell{char: 'X', spec: c.blockedSpec}
+ }
+
+ if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) {
+ return mapCell{char: glyph, spec: c.dimSpec}
+ }
+ return mapCell{char: glyph, spec: color.Average(c.nodeSpec(roomA), c.nodeSpec(roomB))}
+}
+
+type exitState int
+
+const (
+ exitAbsent exitState = iota
+ exitOpen
+ exitBlocked
+)
+
+// exitStateTo reports whether the exit from `from` in `dir` leads to `neighbor`
+// and, if so, whether it is currently traversable for this player. A missing
+// session/player (e.g. in tests or background renders) treats conditional exits
+// as open so rendering never depends on player evaluation.
+func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighbor int) exitState {
+ room, ok := loadRoom(g, from)
+ if !ok {
+ return exitAbsent
+ }
+ exit, ok := room.Exits[dir]
+ if !ok || exit.Room != neighbor {
+ return exitAbsent
+ }
+ if exit.Condition == nil || sess == nil || sess.Player == nil {
+ return exitOpen
+ }
+ if g.checkCondition(sess, exit.Condition) {
+ return exitOpen
+ }
+ return exitBlocked
+}
+
+// barGlyph returns the bidirectional connector glyph for a link's orientation.
+func barGlyph(mg mapGlyphs, dir world.ExitDir) rune {
+ if dir == world.East || dir == world.West {
+ return mg.connectorH
+ }
+ return mg.connectorV
+}
+
+// arrowGlyph returns the one-way arrow pointing along the direction of travel.
+func arrowGlyph(mg mapGlyphs, dir world.ExitDir) rune {
+ switch dir {
+ case world.East:
+ return mg.rightArrow
+ case world.West:
+ return mg.leftArrow
+ case world.South:
+ return mg.downArrow
+ case world.North:
+ return mg.upArrow
+ }
+ return mg.connectorH
+}
+
+// roomDefaultColorSpec resolves the room's own default map color (feature 3).
+// Empty/unset returns NoColor.
+func roomDefaultColorSpec(g *Game, roomID int) color.ColorSpec {
+ if room, ok := loadRoom(g, roomID); ok && room.Color != "" {
+ return color.Parse(room.Color)
+ }
+ return color.NoColor()
}
func roomMapSymbol(g *Game, sess *net.Session, roomID int, unvisited bool) (rune, color.ColorSpec) {
+ roomSpec := roomDefaultColorSpec(g, roomID)
if sess != nil && sess.Player != nil {
if data, ok := sess.Player.MapSymbols[roomID]; ok {
r, size := utf8.DecodeRuneInString(data.Char)
if size > 0 && r != utf8.RuneError {
- spec := color.NoColor()
+ // precedence: player symbol color > room default color > none
+ spec := roomSpec
if data.Color != "" {
spec = color.Parse(data.Color)
}
@@ -338,13 +461,13 @@ func roomMapSymbol(g *Game, sess *net.Session, roomID int, unvisited bool) (rune
}
if sess.Player.OptionBool("unicode") {
if unvisited {
- return '□', color.NoColor()
+ return '□', roomSpec
}
- return '■', color.NoColor()
+ return '■', roomSpec
}
- return 'o', color.NoColor()
+ return 'o', roomSpec
}
- return '■', color.NoColor()
+ return '■', roomSpec
}
func exitsConnect(g *Game, room1, room2 int, dir12, dir21 world.ExitDir) bool {
diff --git a/internal/game/validate_source.go b/internal/game/validate_source.go
index b93c45a..4a2e5b9 100644
--- a/internal/game/validate_source.go
+++ b/internal/game/validate_source.go
@@ -47,7 +47,7 @@ func (g *Game) validationSource() validate.Source {
Courses: courseViews,
Techs: techViews,
TechIDs: techIDs,
- CheckSources: g.ValidationConfig.CheckSources,
+ RootRooms: g.ValidationConfig.RootRooms,
IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable,
}
}
diff --git a/internal/player/player.go b/internal/player/player.go
index 1b59ab1..ea2487c 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -139,7 +139,7 @@ var OptionDefs = []OptionDef{
{"mix_all", OptBool, false, nil, "Auto-start mixing when only one product is possible"},
{"construct_all", OptBool, false, nil, "Auto-start constructing when only one product is possible"},
{"craft_all", OptBool, false, nil, "Auto-start crafting when only one product is possible"},
- {"safespot_alert", OptString, "{196 bold}** Your safespot has been compromised! **{/}", nil, "Message shown when forced out of a safespot"},
+ {"safespot_alert", OptString, "{C4 bold}** Your safespot has been compromised! **{/}", nil, "Message shown when forced out of a safespot"},
{"danger_warning", OptBool, true, nil, "Confirm before entering a dangerous (hazardous) area"},
{"prompt_break", OptString, "on", []string{"on", "off"}, "Line break after prompt before output"},
}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index f1d6b5e..b2e5449 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -6,6 +6,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/world"
)
@@ -34,6 +35,13 @@ func validateRooms(s Source) []Issue {
Message: fmt.Sprintf("Room %d: has no name", id),
})
}
+ if room.Color != "" && color.Parse(room.Color).Empty() {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: invalid color %q", id, room.Color),
+ })
+ }
for dir, exit := range room.Exits {
switch dir {
@@ -654,7 +662,7 @@ func validateRoomWiring(s Source) []Issue {
roomIndex := s.World.RoomIndex()
sourceSet := make(map[int]bool)
- for _, src := range s.CheckSources {
+ for _, src := range s.RootRooms {
sourceSet[src] = true
}
@@ -705,6 +713,109 @@ func validateRoomWiring(s Source) []Issue {
return issues
}
+// gridDeltas maps the horizontal exits to their grid movement. Up/Down are
+// intentionally excluded: they connect separate horizontal planes rather than
+// moving within one.
+var gridDeltas = map[world.ExitDir][2]int{
+ world.North: {0, -1},
+ world.South: {0, 1},
+ world.East: {1, 0},
+ world.West: {-1, 0},
+}
+
+// validateRoomGrid lays each reachable horizontal plane on a 2D grid starting
+// from the configured root rooms and reports when the exit layout cannot be
+// embedded without conflict:
+// - overlap: two distinct rooms land on the same grid cell.
+// - twist: one room is forced onto two different grid cells.
+//
+// Up/Down exits don't move on the grid; each leads to a new plane that is laid
+// out independently (fresh origin), so one root validates every reachable
+// floor. Exit conditions are ignored (geometry is independent of gating), and
+// exits to nonexistent rooms are skipped (covered by referential checks).
+func validateRoomGrid(s Source) []Issue {
+ var issues []Issue
+ roomIndex := s.World.RoomIndex()
+
+ placed := make(map[int]bool)
+ var seeds []int
+ for _, r := range s.RootRooms {
+ if roomIndex[r] {
+ seeds = append(seeds, r)
+ }
+ }
+
+ for len(seeds) > 0 {
+ origin := seeds[0]
+ seeds = seeds[1:]
+ if placed[origin] {
+ continue
+ }
+
+ coordOf := map[int][2]int{origin: {0, 0}}
+ roomAt := map[[2]int]int{{0, 0}: origin}
+ placed[origin] = true
+ queue := []int{origin}
+
+ for len(queue) > 0 {
+ rid := queue[0]
+ queue = queue[1:]
+ room, err := s.World.LoadRoom(rid)
+ if err != nil {
+ continue
+ }
+ c := coordOf[rid]
+ for _, dir := range world.ExitOrder {
+ exit, ok := room.Exits[dir]
+ if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
+ continue
+ }
+ target := exit.Room
+
+ if dir == world.Up || dir == world.Down {
+ if !placed[target] {
+ seeds = append(seeds, target)
+ }
+ continue
+ }
+
+ d := gridDeltas[dir]
+ want := [2]int{c[0] + d[0], c[1] + d[1]}
+
+ if existing, ok := coordOf[target]; ok {
+ if existing != want {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid twist: room %d (via %d %s) maps to grid (%d,%d) but was already placed at (%d,%d) [plane origin %d]",
+ target, rid, dir, want[0], want[1], existing[0], existing[1], origin),
+ })
+ }
+ continue
+ }
+ if occupier, ok := roomAt[want]; ok && occupier != target {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid overlap: room %d (via %d %s) wants grid (%d,%d), already used by room %d [plane origin %d]",
+ target, rid, dir, want[0], want[1], occupier, origin),
+ })
+ continue
+ }
+
+ coordOf[target] = want
+ roomAt[want] = target
+ placed[target] = true
+ queue = append(queue, target)
+ }
+ }
+ }
+
+ return issues
+}
+
func validateTechs(s Source) []Issue {
var issues []Issue
seen := make(map[string]bool)
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
new file mode 100644
index 0000000..9a8ef02
--- /dev/null
+++ b/internal/validate/grid_test.go
@@ -0,0 +1,105 @@
+package validate
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+func writeGridRoom(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ rooms := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(rooms, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(rooms, strconv.Itoa(id)+".yaml"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func runGridCheck(t *testing.T, rooms map[int]string, roots ...int) []Issue {
+ t.Helper()
+ dir := t.TempDir()
+ for id, body := range rooms {
+ writeGridRoom(t, dir, id, body)
+ }
+ return validateRoomGrid(Source{World: world.New(dir), RootRooms: roots})
+}
+
+func containsMsg(issues []Issue, substr string) bool {
+ for _, iss := range issues {
+ if strings.Contains(iss.Message, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+func TestGridCleanLayout(t *testing.T) {
+ // A consistent 2x2 block: 4 is reached the same way from 2 and from 3.
+ rooms := map[int]string{
+ 1: "exits:\n south: 3\n east: 2\n",
+ 2: "exits:\n south: 4\n",
+ 3: "exits:\n east: 4\n",
+ 4: "name: corner\n",
+ }
+ if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
+ t.Errorf("expected no grid issues, got: %+v", issues)
+ }
+}
+
+func TestGridOverlap(t *testing.T) {
+ // rooms 4 and 5 both resolve to grid (1,1).
+ rooms := map[int]string{
+ 1: "exits:\n south: 3\n east: 2\n",
+ 2: "exits:\n south: 5\n",
+ 3: "exits:\n east: 4\n",
+ 4: "name: four\n",
+ 5: "name: five\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid overlap") {
+ t.Errorf("expected a grid overlap, got: %+v", issues)
+ }
+ for _, iss := range issues {
+ if iss.Level != "ERROR" {
+ t.Errorf("grid issues should be ERROR, got %q", iss.Level)
+ }
+ }
+}
+
+func TestGridTwist(t *testing.T) {
+ // room 3 is forced onto two different cells.
+ rooms := map[int]string{
+ 1: "exits:\n south: 4\n east: 2\n",
+ 2: "exits:\n east: 3\n",
+ 4: "exits:\n east: 3\n",
+ 3: "name: three\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid twist") {
+ t.Errorf("expected a grid twist, got: %+v", issues)
+ }
+}
+
+func TestGridMultiPlaneViaUpDown(t *testing.T) {
+ // Plane A is just room 1. Going up seeds plane B (origin 10), which has an
+ // overlap. This proves up/down crosses planes and frames are independent
+ // (room 1 and room 10 both sit at (0,0) without conflicting).
+ rooms := map[int]string{
+ 1: "exits:\n up: 10\n",
+ 10: "exits:\n south: 12\n east: 11\n",
+ 11: "exits:\n south: 14\n",
+ 12: "exits:\n east: 13\n",
+ 13: "name: thirteen\n",
+ 14: "name: fourteen\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "plane origin 10") {
+ t.Errorf("expected an overlap in plane origin 10, got: %+v", issues)
+ }
+}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 36969ed..56de6f1 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -51,7 +51,7 @@ type Source struct {
Courses []CourseView
Techs []TechView
TechIDs map[string]bool
- CheckSources []int
+ RootRooms []int
IgnoreUnreachable []int
}
@@ -80,6 +80,7 @@ func Run(s Source) []Issue {
issues = append(issues, validateRoomEnterSteps(s)...)
issues = append(issues, validateTechs(s)...)
issues = append(issues, validateRoomWiring(s)...)
+ issues = append(issues, validateRoomGrid(s)...)
return issues
}
diff --git a/internal/world/room.go b/internal/world/room.go
index e0e2d16..d542bf4 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -68,6 +68,7 @@ func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
type Room struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
+ Color string `yaml:"color"`
Description behavior.DescList `yaml:"description"`
Exits map[ExitDir]ExitDef `yaml:"exits"`
Objects []RoomObject `yaml:"objects"`