diff options
| author | historia <[not public]> | 2026-06-27 16:50:41 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-27 16:50:41 -0400 |
| commit | 988b006a5bb9edb6465653566e7bdf8175347b8c (patch) | |
| tree | 3a1fceff2ee80c11b5dafcc49c13ababe021e880 /internal/game | |
| parent | 1cab9ca20e24742f770a676f84e4ed9f1636f297 (diff) | |
| download | thehouseoficarus-988b006a5bb9edb6465653566e7bdf8175347b8c.tar.gz | |
feat: one-way map links, blocked paths on map, map grid startup validation, user colors for maps
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/cmd_aps.go | 12 | ||||
| -rw-r--r-- | internal/game/cmd_color.go | 15 | ||||
| -rw-r--r-- | internal/game/cmd_colortable.go | 79 | ||||
| -rw-r--r-- | internal/game/cmd_mods.go | 16 | ||||
| -rw-r--r-- | internal/game/cmd_option.go | 16 | ||||
| -rw-r--r-- | internal/game/cmd_score.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_skills.go | 20 | ||||
| -rw-r--r-- | internal/game/cmd_symbol.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_tech.go | 12 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 16 | ||||
| -rw-r--r-- | internal/game/hacking/mastermind.go | 12 | ||||
| -rw-r--r-- | internal/game/hacking/wumpus.go | 6 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 2 | ||||
| -rw-r--r-- | internal/game/look_room.go | 26 | ||||
| -rw-r--r-- | internal/game/look_target.go | 2 | ||||
| -rw-r--r-- | internal/game/map_test.go | 99 | ||||
| -rw-r--r-- | internal/game/production_menu.go | 2 | ||||
| -rw-r--r-- | internal/game/render_color.go | 10 | ||||
| -rw-r--r-- | internal/game/render_map.go | 179 | ||||
| -rw-r--r-- | internal/game/validate_source.go | 2 |
20 files changed, 354 insertions, 184 deletions
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, } } |
