From 988b006a5bb9edb6465653566e7bdf8175347b8c Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 27 Jun 2026 16:50:41 -0400 Subject: feat: one-way map links, blocked paths on map, map grid startup validation, user colors for maps --- internal/color/color.go | 37 +++++++- internal/color/color_test.go | 64 +++++++++++++ internal/config/config.go | 77 ++++++++-------- internal/game/cmd_aps.go | 12 +-- internal/game/cmd_color.go | 15 +-- internal/game/cmd_colortable.go | 79 +--------------- internal/game/cmd_mods.go | 16 ++-- internal/game/cmd_option.go | 16 ++-- internal/game/cmd_score.go | 8 +- internal/game/cmd_skills.go | 20 ++-- internal/game/cmd_symbol.go | 4 +- internal/game/cmd_tech.go | 12 +-- internal/game/core_login_account.go | 16 ++-- internal/game/hacking/mastermind.go | 12 +-- internal/game/hacking/wumpus.go | 6 +- internal/game/look_entities.go | 2 +- internal/game/look_room.go | 26 +++++- internal/game/look_target.go | 2 +- internal/game/map_test.go | 99 ++++++++++++++++++++ internal/game/production_menu.go | 2 +- internal/game/render_color.go | 10 +- internal/game/render_map.go | 179 ++++++++++++++++++++++++++++++------ internal/game/validate_source.go | 2 +- internal/player/player.go | 2 +- internal/validate/checks.go | 113 ++++++++++++++++++++++- internal/validate/grid_test.go | 105 +++++++++++++++++++++ internal/validate/validate.go | 3 +- internal/world/room.go | 1 + 28 files changed, 712 insertions(+), 228 deletions(-) create mode 100644 internal/validate/grid_test.go (limited to 'internal') 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"` -- cgit v1.2.3