From 2b5d8f595f96a2864899f4c7e28bc06fa52d2603 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 18 Jul 2026 04:44:50 -0400 Subject: feat: expose all colors in config.yaml for easier theming --- internal/admin/api_colors.go | 373 +++++++++++++++++++++++++++-------- internal/admin/api_colors_test.go | 70 ++++++- internal/admin/static/admin.css | 2 + internal/admin/static/coloreditor.js | 173 +++++++++++----- internal/config/config.go | 73 ++++++- internal/game/cmd_aps.go | 15 +- internal/game/cmd_color.go | 63 ++++-- internal/game/cmd_mods.go | 16 +- internal/game/cmd_option.go | 20 +- internal/game/cmd_score.go | 85 +++----- internal/game/cmd_skills.go | 24 ++- internal/game/cmd_tech.go | 12 +- internal/game/game.go | 30 +-- internal/game/look_entities.go | 11 +- internal/game/look_target.go | 2 +- internal/game/production_menu.go | 2 +- internal/game/render_color.go | 43 +++- internal/game/render_combat.go | 44 ++++- internal/game/sys_labor.go | 2 +- 19 files changed, 776 insertions(+), 284 deletions(-) (limited to 'internal') diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go index 882a096..00caa98 100644 --- a/internal/admin/api_colors.go +++ b/internal/admin/api_colors.go @@ -10,6 +10,7 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" + "thehouseoficarus/internal/game" ) // colorCategory describes one editable color category: its config key, a @@ -38,77 +39,132 @@ type colorSection struct { Lines []colorLine `json:"lines"` } -// colorCategories is the curated, grouped, ordered master list mirroring the -// keys produced by config.DefaultColors(). Editing here is the single source -// of truth for the admin color themer's left-side list. -var colorCategories = []colorCategory{ - // Room / look - {Name: "room_name", Label: "Room Name", Group: "Room"}, - {Name: "room_number", Label: "Room Number", Group: "Room"}, - {Name: "room_desc", Label: "Room Description", Group: "Room"}, - {Name: "exit_direction", Label: "Exit Direction", Group: "Room"}, - {Name: "exit_name", Label: "Exit Name", Group: "Room"}, - {Name: "mob", Label: "Mob", Group: "Room"}, - {Name: "protected_mob", Label: "Protected Mob", Group: "Room"}, - {Name: "player_name", Label: "Player Name", Group: "Room"}, - {Name: "item", Label: "Item", Group: "Room"}, - {Name: "dim", Label: "Dim / Brackets", Group: "Room"}, - - // Movement - {Name: "direction", Label: "Move Direction", Group: "Movement"}, - - // Combat - {Name: "damage_dealt", Label: "Damage Dealt", Group: "Combat"}, - {Name: "damage_taken", Label: "Damage Taken", Group: "Combat"}, - {Name: "miss", Label: "Miss", Group: "Combat"}, - {Name: "death", Label: "Death", Group: "Combat"}, - {Name: "victory", Label: "Victory", Group: "Combat"}, - {Name: "warning", Label: "Warning", Group: "Combat"}, - {Name: "error", Label: "Error", Group: "Combat"}, - {Name: "science_mod", Label: "Science Mod", Group: "Combat"}, - - // XP / level - {Name: "xp", Label: "XP Drop", Group: "XP / Level"}, - {Name: "level_up", Label: "Level Up", Group: "XP / Level"}, - - // Pickups / economy - {Name: "currency_pickup", Label: "Currency Pickup", Group: "Pickups / Economy"}, - - // Chat / dialog - {Name: "say", Label: "Say", Group: "Chat / Dialog"}, - {Name: "global", Label: "Global", Group: "Chat / Dialog"}, - {Name: "broadcast", Label: "Broadcast", Group: "Chat / Dialog"}, - {Name: "dialog", Label: "Dialog", Group: "Chat / Dialog"}, - {Name: "dialog_options", Label: "Dialog Options", Group: "Chat / Dialog"}, - - // Drops / consume / fire - {Name: "drop_message", Label: "Drop Message", Group: "Drops / Consume / Fire"}, - {Name: "eat_food", Label: "Eat Food", Group: "Drops / Consume / Fire"}, - {Name: "fire", Label: "Fire", Group: "Drops / Consume / Fire"}, - - // Tick / status - {Name: "visual_tick", Label: "Visual Tick", Group: "Tick / Status"}, - {Name: "visual_first_tick", Label: "Visual First Tick", Group: "Tick / Status"}, - - // Tech / farm - {Name: "battery", Label: "Battery", Group: "Tech / Farm"}, - {Name: "tech_depleted", Label: "Tech Depleted", Group: "Tech / Farm"}, - {Name: "farm_grow", Label: "Farm Grow", Group: "Tech / Farm"}, - {Name: "farm_disease", Label: "Farm Disease", Group: "Tech / Farm"}, - - // Assassin / map / safespot - {Name: "assassin_task", Label: "Assassin Task", Group: "Assassin / Map / Safespot"}, - {Name: "map_at", Label: "Map At (you)", Group: "Assassin / Map / Safespot"}, - {Name: "map_blocked", Label: "Map Blocked", Group: "Assassin / Map / Safespot"}, - {Name: "map_agility", Label: "Map Agility", Group: "Assassin / Map / Safespot"}, - {Name: "safespot_alert", Label: "Safespot Alert", Group: "Assassin / Map / Safespot"}, - - // System - {Name: "sequence", Label: "Sequence (trigger)", Group: "System"}, +// playerColorLabels maps each player-overridable color category name to the +// human-friendly label shown in the admin GUI. Order comes from the shared +// game.PlayerColorCategoryOrder() so the admin editor always mirrors the +// in-game `colors` command output exactly. +var playerColorLabels = map[string]string{ + "room_name": "Room Name", + "room_number": "Room Number", + "room_desc": "Room Description", + "exit_direction": "Exit Direction", + "exit_name": "Exit Name", + "dim": "Dim / Brackets", + "mob": "Mob", + "protected_mob": "Protected Mob", + "player_name": "Player Name", + "item": "Item", + "direction": "Move Direction", + "damage_dealt": "Damage Dealt", + "damage_taken": "Damage Taken", + "miss": "Miss", + "death": "Death", + "victory": "Victory", + "warning": "Warning", + "error": "Error", + "science_mod": "Science Mod", + "xp": "XP Drop", + "level_up": "Level Up", + "currency_pickup": "Currency Pickup", + "say": "Say", + "global": "Global", + "broadcast": "Broadcast", + "dialog": "Dialog", + "dialog_options": "Dialog Options", + "drop_message": "Drop Message", + "eat_food": "Eat Food", + "fire": "Fire", + "visual_tick": "Visual Tick", + "visual_first_tick": "Visual First Tick", + "battery": "Battery", + "tech_depleted": "Tech Depleted", + "farm_grow": "Farm Grow", + "farm_disease": "Farm Disease", + "assassin_task": "Assassin Task", + "map_at": "Map At (you)", + "map_blocked": "Map Blocked", + "map_agility": "Map Agility", + "safespot_alert": "Safespot Alert", + "sequence": "Sequence (trigger)", } -// knownColor returns true if name is one of the editable categories. -func knownColor(name string) bool { +// colorCategories is the ordered list of player-overridable color categories +// shown in the admin GUI. Built from game.PlayerColorCategoryOrder() so it +// stays in lock-step with the in-game `colors` command output. Group is left +// empty: the editor renders one flat list under the "Player Colors" heading. +var colorCategories = buildPlayerCategories() + +func buildPlayerCategories() []colorCategory { + order := game.PlayerColorCategoryOrder() + out := make([]colorCategory, 0, len(order)) + for _, name := range order { + label := playerColorLabels[name] + if label == "" { + label = name + } + out = append(out, colorCategory{Name: name, Label: label}) + } + return out +} + +// constantCategories is the ordered list of constant (player-invisible) color +// categories exposed in the admin GUI under the "Constants" heading. Order is +// family-grouped for skimmability; Group is currently unused by the client. +var constantCategories = []colorCategory{ + // HP / status bars + {Name: "hp_bar_high", Label: "HP Bar — High (>=70%)", Group: "Bars"}, + {Name: "hp_bar_med", Label: "HP Bar — Medium (>=40%)", Group: "Bars"}, + {Name: "hp_bar_low", Label: "HP Bar — Low (<40%)", Group: "Bars"}, + {Name: "hp_bar_empty", Label: "HP Bar — Empty Cells", Group: "Bars"}, + {Name: "task_bar_high", Label: "Task Bar — High (>=70%)", Group: "Bars"}, + {Name: "task_bar_med", Label: "Task Bar — Medium (>=40%)", Group: "Bars"}, + {Name: "task_bar_low", Label: "Task Bar — Low (<40%)", Group: "Bars"}, + {Name: "battery_bar", Label: "Battery Bar Fill", Group: "Bars"}, + {Name: "run_energy_bar", Label: "Run Energy Bar Fill", Group: "Bars"}, + + // Skill-level tier color on the score panel + {Name: "skill_lvl_max", Label: "Skill Level Color — 99", Group: "Skills"}, + {Name: "skill_lvl_high", Label: "Skill Level Color — 80+", Group: "Skills"}, + {Name: "skill_lvl_mid", Label: "Skill Level Color — 60+", Group: "Skills"}, + {Name: "skill_lvl_low", Label: "Skill Level Color — 40+", Group: "Skills"}, + {Name: "skill_lvl_base", Label: "Skill Level Color — base", Group: "Skills"}, + + // Level-difference bands + {Name: "level_diff_equal", Label: "Level Diff — Equal", Group: "Level Diff"}, + {Name: "level_diff_up_small", Label: "Level Diff — Slightly Higher (+1..+4)", Group: "Level Diff"}, + {Name: "level_diff_up_big", Label: "Level Diff — Much Higher (>=+5)", Group: "Level Diff"}, + {Name: "level_diff_down_small", Label: "Level Diff — Slightly Lower (-1..-4)", Group: "Level Diff"}, + {Name: "level_diff_down_big", Label: "Level Diff — Much Lower (<=-5)", Group: "Level Diff"}, + + // Tech list status / labels + {Name: "tech_status_on", Label: "Tech Status — ON", Group: "Tech"}, + {Name: "tech_status_off", Label: "Tech Status — off", Group: "Tech"}, + {Name: "tech_status_locked", Label: "Tech Status — locked", Group: "Tech"}, + {Name: "tech_name_unlocked", Label: "Tech Name — unlocked", Group: "Tech"}, + {Name: "tech_name_locked", Label: "Tech Name — locked", Group: "Tech"}, + {Name: "active_tech_on", Label: "Active Tech [ON] tag", Group: "Tech"}, + {Name: "active_tech_status", Label: "Active Buff [BUFF] tag", Group: "Tech"}, + {Name: "active_tech_stat", Label: "Tech / Buff Stat Name", Group: "Tech"}, + {Name: "score_max_value", Label: "Score Panel Max Value", Group: "Tech"}, + + // Shared table-heading chrome + {Name: "table_header_label", Label: "Table Header — Label", Group: "Tables"}, + {Name: "table_header_value", Label: "Table Header — Value", Group: "Tables"}, + {Name: "table_header_detail", Label: "Table Header — Detail", Group: "Tables"}, + {Name: "table_header_xp", Label: "Table Header — XP / Cost", Group: "Tables"}, + {Name: "table_header_desc", Label: "Table Header — Description", Group: "Tables"}, + {Name: "production_locked", Label: "Production Menu — Locked Row", Group: "Tables"}, + + // APS / look inline accents + {Name: "aps_here", Label: "APS Datapad — \"here\"", Group: "Accents"}, + {Name: "mob_hp_inline", Label: "Mob Inline HP", Group: "Accents"}, + {Name: "protected_label", Label: "Mob \"(protected)\" tag", Group: "Accents"}, + {Name: "god_tag_label", Label: "God-mode [LOCAL]/[GLOBAL]/(HIDDEN)", Group: "Accents"}, +} + +// knownPlayerColor returns true if name is one of the player-overridable +// categories. +func knownPlayerColor(name string) bool { for _, c := range colorCategories { if c.Name == name { return true @@ -117,6 +173,22 @@ func knownColor(name string) bool { return false } +// knownConstantColor returns true if name is one of the constant +// (admin-only) color categories. +func knownConstantColor(name string) bool { + for _, c := range constantCategories { + if c.Name == name { + return true + } + } + return false +} + +// knownColor returns true if name is editable in either section. +func knownColor(name string) bool { + return knownPlayerColor(name) || knownConstantColor(name) +} + // seg is a small constructor helper for the inline preview builder. func seg(t, cat string) colorSegment { return colorSegment{T: t, C: cat} @@ -157,12 +229,12 @@ func buildPreview() []colorSection { seg("║", "dim"), seg("■", ""), seg("-", ""), seg("@", "map_at"), seg("-", ""), seg("■", ""), seg("║", "dim")), ln(seg("Crassius Rex", "protected_mob"), seg(" watches the gate. ", ""), seg("║", "dim"), seg(" ", ""), seg("│", "map_agility"), seg(" ", ""), seg("║", "dim")), - ln(seg("A man ", "mob"), seg("(level 5)", "victory"), seg(" scribbles something in a small notebook. ", ""), + ln(seg("A man ", "mob"), seg("(level 5)", "level_diff_down_big"), seg(" scribbles something in a small notebook. ", ""), seg("║", "dim"), seg(" ", ""), seg("■", ""), seg(" ", ""), seg("║", "dim")), ln(seg(" ", ""), seg("╚═════╝", "dim")), txt(""), - ln(seg("Dalinar", "player_name"), seg(" (level 123)", "error"), seg(" is here.", "")), + ln(seg("Dalinar", "player_name"), seg(" (level 123)", "level_diff_equal"), seg(" is here.", "")), txt(""), txt("On the ground:"), ln(seg(" bones ", "item"), seg("(reserved for Dalinar 65t)", "dim")), @@ -191,19 +263,19 @@ func buildPreview() []colorSection { ln(seg("You attack ", ""), seg("the man", "mob"), seg("! Style: accurate (+3 atk)", "")), ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""), seg("\U0001FB38 1 \U0001FB34 damage. ", "damage_dealt"), - seg("[█████████░]", "victory"), seg(" [ 6/7]", ""), + seg("[", ""), seg("█████████", "hp_bar_high"), seg("░", "hp_bar_empty"), seg("]", ""), seg(" [ 6/7]", ""), seg(" (+18xp acc, +6xp hp)", "xp")), ln(seg("The man misses you.", "miss")), ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""), seg("\U0001FB38 6 \U0001FB34 damage. ", "damage_dealt"), - seg("[░░░░░░░░░░]", "error"), seg(" [ 0/7]", ""), + seg("[", ""), seg("░░░░░░░░░░", "hp_bar_empty"), seg("]", ""), seg(" [ 0/7]", ""), seg(" (+18xp acc, +6xp hp)", "xp")), ln(seg("You have defeated ", ""), seg("the man", "mob"), seg("!", "victory")), ln(seg("*** You are now level 12 accuracy! ***", "level_up")), txt(""), ln(seg("The man", "mob"), seg(" hits you for ", ""), seg("\U0001FB38 5 \U0001FB34 damage. ", "damage_taken"), - seg("[██████░░░░]", "warning"), seg(" [22/40]", "")), + seg("[", ""), seg("██████", "hp_bar_med"), seg("░░░░", "hp_bar_empty"), seg("]", ""), seg(" [22/40]", "")), ln(seg("Oh dear, you are dead!", "death")), txt(""), txt(""), @@ -268,6 +340,87 @@ func buildPreview() []colorSection { }, }) + // --- Constants (player-invisible colors) ---------------------------- + sections = append(sections, colorSection{ + Title: "Constants (admin-only)", + Lines: []colorLine{ + txt(""), + ln(seg("HP bar high ", ""), seg("[", ""), seg("██████", "hp_bar_high"), seg("░░░░", "hp_bar_empty"), seg("]", "")), + ln(seg("HP bar med ", ""), seg("[", ""), seg("████", "hp_bar_med"), seg("░░░░░░", "hp_bar_empty"), seg("]", "")), + ln(seg("HP bar low ", ""), seg("[", ""), seg("██", "hp_bar_low"), seg("░░░░░░░░", "hp_bar_empty"), seg("]", "")), + ln(seg("HP bar empty", ""), seg("[", ""), seg("░░░░░░░░░░", "hp_bar_empty"), seg("]", "")), + ln(seg("Task bar high", ""), seg("[", ""), seg("██████", "task_bar_high"), seg("░░░░", "hp_bar_empty"), seg("]", "")), + ln(seg("Task bar med ", ""), seg("[", ""), seg("████", "task_bar_med"), seg("░░░░░░", "hp_bar_empty"), seg("]", "")), + ln(seg("Task bar low ", ""), seg("[", ""), seg("██", "task_bar_low"), seg("░░░░░░░░", "hp_bar_empty"), seg("]", "")), + ln(seg("BAT ", ""), seg("[", ""), seg("██████", "battery_bar"), seg("░░░░░░░░░░░░░░░░░░░░", ""), seg("] corbat", "")), + ln(seg("ENG ", ""), seg("[", ""), seg("█████████████", "run_energy_bar"), seg("░░░░░░░░░░░░", "hp_bar_empty"), seg("]", "")), + txt(""), + // Constants composed with player colors, as they appear in real + // combat output. Lets the admin see a hp_bar_med / hp_bar_low + // change reflected immediately in a realistic damage line. + ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""), + seg("\U0001FB38 5 \U0001FB34 damage. ", "damage_dealt"), + seg("[", ""), seg("█████", "hp_bar_med"), seg("░░░░░", "hp_bar_empty"), seg("]", ""), seg(" [ 5/7]", "")), + ln(seg("The man", "mob"), seg(" hits you for ", ""), + seg("\U0001FB38 8 \U0001FB34 damage. ", "damage_taken"), + seg("[", ""), seg("██", "hp_bar_low"), seg("░░░░░░░░", "hp_bar_empty"), seg("]", ""), seg(" [ 3/40]", "")), + ln(seg("Task advance: ", ""), + seg("You advance the work on ", ""), + seg("the ore seam", "mob"), seg(". ", ""), + seg("[", ""), seg("██████", "task_bar_high"), seg("░░░░", "hp_bar_empty"), seg("]", ""), seg(" 60%", "")), + txt(""), + ln(seg("Skill levels: ", ""), + seg("99", "skill_lvl_max"), seg(" ", ""), + seg("85", "skill_lvl_high"), seg(" ", ""), + seg("63", "skill_lvl_mid"), seg(" ", ""), + seg("42", "skill_lvl_low"), seg(" ", ""), + seg("12", "skill_lvl_base")), + txt(""), + ln(seg("Level diff: ", ""), + seg("(level 10)", "level_diff_equal"), seg(" vs you 10", ""), + seg(" (level 13)", "level_diff_up_small"), seg(" vs you 10", ""), + seg(" (level 20)", "level_diff_up_big")), + ln(seg(" ", ""), + seg("(level 7)", "level_diff_down_small"), seg(" vs you 10", ""), + seg(" (level 3)", "level_diff_down_big"), seg(" vs you 10", "")), + txt(""), + ln(seg("Technology: ", ""), + seg("Buff Helper ", "tech_name_unlocked"), + seg("ON", "tech_status_on"), seg(" ", ""), + seg("Empty Tech ", "tech_name_locked"), + seg("locked", "tech_status_locked")), + ln(seg(" ", ""), + seg("Off Tech ", "tech_name_unlocked"), + seg("off", "tech_status_off")), + ln(seg("Active: ", ""), + seg("[ON] ", "active_tech_on"), + seg("BullsEye ", "active_tech_stat"), seg("[BUFF] ", "active_tech_status"), seg("Strength", "active_tech_stat")), + ln(seg("Max values: ", ""), + seg("Combat Lvl 99 / HP 40 / BAT 100 / ENG 100", "score_max_value")), + txt(""), + ln(seg("Skill table", ""), seg(" (", "table_header_label"), seg("table_header", "table_header_label"), seg(" ", ""), seg("header", "table_header_label"), seg(") ", ""), + seg("Skill ", "table_header_label"), seg("Abbr ", "table_header_detail"), seg("Level ", "table_header_value"), seg("XP ", "table_header_xp"), seg("XP to Next", "table_header_xp")), + ln(seg("Buff table ", ""), + seg("Option ", "table_header_label"), seg("Value ", "table_header_value"), seg("Valid ", "table_header_detail"), seg("Description", "table_header_desc")), + ln(seg("Production: ", ""), + seg("bronze axe ", "production_locked"), seg("Lv 3 ", "production_locked"), seg("(missing materials)", "production_locked")), + txt(""), + ln(seg("Mob inline HP: ", ""), + seg("a scout", "mob"), seg(" [", ""), seg("3", "mob_hp_inline"), seg("/7hp]", "")), + ln(seg("Protected mob: ", ""), + seg("Dragon", "protected_mob"), seg(" ", ""), seg("(protected)", "protected_label")), + ln(seg("God-mode tags: ", ""), + seg("[LOCAL]", "god_tag_label"), seg(" ", ""), + seg("[GLOBAL]", "god_tag_label"), seg(" ", ""), + seg("(HIDDEN)", "god_tag_label")), + ln(seg("APS Datapad: ", ""), + seg("#2001 ", "table_header_detail"), + seg("Admin Tower ", "table_header_label"), + seg("here", "aps_here")), + txt(""), + }, + }) + return sections } @@ -300,31 +453,64 @@ func (s *AdminServer) getColors(w http.ResponseWriter, _ *http.Request) { def[c.Name] = "" } } + + liveConst := make(map[string]string, len(constantCategories)) + for _, c := range constantCategories { + if v, ok := s.cfg.ConstantColors[c.Name]; ok { + liveConst[c.Name] = v + } else { + liveConst[c.Name] = "" + } + } + constDefaults := config.DefaultConstantColors() + defConst := make(map[string]string, len(constantCategories)) + for _, c := range constantCategories { + if v, ok := constDefaults[c.Name]; ok { + defConst[c.Name] = v + } else { + defConst[c.Name] = "" + } + } + writeJSON(w, map[string]any{ - "colors": live, - "default_colors": def, - "categories": colorCategories, - "preview": buildPreview(), + "colors": live, + "default_colors": def, + "categories": colorCategories, + "constant_colors": liveConst, + "default_constant_colors": defConst, + "constant_categories": constantCategories, + "preview": buildPreview(), }) } func (s *AdminServer) saveColors(w http.ResponseWriter, r *http.Request) { var body struct { - Colors map[string]string `json:"colors"` + Colors map[string]string `json:"colors"` + ConstantColors map[string]string `json:"constant_colors"` } if err := readJSON(r, &body); err != nil { writeJSONError(w, "invalid request body", http.StatusBadRequest) return } - // Validate every supplied key/spec against the known config grammar. + // Validate every supplied player-color key/spec. for name, val := range body.Colors { - if !knownColor(name) { + if !knownPlayerColor(name) { writeJSONError(w, "unknown color category: "+name, http.StatusBadRequest) return } - // "off" or empty is explicitly allowed (NoColor). Anything else must - // parse to a non-empty ColorSpec whose only unknown we tolerate is none. + spec := color.Parse(val) + if strings.TrimSpace(val) != "" && val != "off" && spec.Empty() { + writeJSONError(w, "invalid color spec for "+name+": "+val, http.StatusBadRequest) + return + } + } + // Validate every supplied constant-color key/spec. + for name, val := range body.ConstantColors { + if !knownConstantColor(name) { + writeJSONError(w, "unknown constant color category: "+name, http.StatusBadRequest) + return + } spec := color.Parse(val) if strings.TrimSpace(val) != "" && val != "off" && spec.Empty() { writeJSONError(w, "invalid color spec for "+name+": "+val, http.StatusBadRequest) @@ -342,6 +528,16 @@ func (s *AdminServer) saveColors(w http.ResponseWriter, r *http.Request) { } s.cfg.DefaultColors = next + curConst := s.cfg.ConstantColors + nextConst := make(map[string]string, len(curConst)+len(body.ConstantColors)) + for k, v := range curConst { + nextConst[k] = v + } + for k, v := range body.ConstantColors { + nextConst[k] = v + } + s.cfg.ConstantColors = nextConst + if err := s.writeConfigFile(); err != nil { writeJSONError(w, "save failed: "+err.Error(), http.StatusInternalServerError) return @@ -355,6 +551,7 @@ func (s *AdminServer) handleColorsReset(w http.ResponseWriter, r *http.Request) return } s.cfg.DefaultColors = config.DefaultColors() + s.cfg.ConstantColors = config.DefaultConstantColors() if err := s.writeConfigFile(); err != nil { writeJSONError(w, "save failed: "+err.Error(), http.StatusInternalServerError) return @@ -368,7 +565,15 @@ func (s *AdminServer) handleColorsReset(w http.ResponseWriter, r *http.Request) live[c.Name] = "" } } - writeJSON(w, map[string]any{"ok": true, "colors": live}) + liveConst := make(map[string]string, len(constantCategories)) + for _, c := range constantCategories { + if v, ok := s.cfg.ConstantColors[c.Name]; ok { + liveConst[c.Name] = v + } else { + liveConst[c.Name] = "" + } + } + writeJSON(w, map[string]any{"ok": true, "colors": live, "constant_colors": liveConst}) } // writeConfigFile marshals the entire live *config.Config back to config.yaml, diff --git a/internal/admin/api_colors_test.go b/internal/admin/api_colors_test.go index cb63714..1f5d3eb 100644 --- a/internal/admin/api_colors_test.go +++ b/internal/admin/api_colors_test.go @@ -17,7 +17,7 @@ func newColorsTestServer(t *testing.T) (*AdminServer, string) { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.yaml") - if err := os.WriteFile(path, []byte("tick_length: 600\ndefault_colors:\n room_name: \"74 bold\"\n"), 0644); err != nil { + if err := os.WriteFile(path, []byte("tick_length: 600\ndefault_colors:\n room_name: \"74 bold\"\nconstant_colors:\n hp_bar_low: \"A7\"\n"), 0644); err != nil { t.Fatal(err) } loaded, err := config.Load(path) @@ -43,8 +43,12 @@ func TestColorsGet(t *testing.T) { if len(cats) != len(colorCategories) { t.Fatalf("categories count: got %d want %d", len(cats), len(colorCategories)) } + constCats, _ := out["constant_categories"].([]any) + if len(constCats) != len(constantCategories) { + t.Fatalf("constant_categories count: got %d want %d", len(constCats), len(constantCategories)) + } preview, _ := out["preview"].([]any) - if len(preview) < 6 { + if len(preview) < 7 { t.Fatalf("preview sections too few: %d", len(preview)) } } @@ -103,6 +107,53 @@ func TestColorsSaveRejectsUnknownKey(t *testing.T) { } } +func TestConstantsSaveAndPersist(t *testing.T) { + s, path := newColorsTestServer(t) + before, ok := s.cfg.ConstantColors["hp_bar_low"] + if !ok || before == "" { + t.Fatalf("constant not pre-populated: got %q", before) + } + body := map[string]any{"constant_colors": map[string]string{"hp_bar_low": "FF bold"}} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.saveColors(w, req) + if w.Code != http.StatusOK { + t.Fatalf("save status %d: %s", w.Code, w.Body.String()) + } + if s.cfg.ConstantColors["hp_bar_low"] == before { + t.Fatalf("constant live map not updated") + } + if s.cfg.ConstantColors["hp_bar_low"] != "FF bold" { + t.Fatalf("constant live value = %q", s.cfg.ConstantColors["hp_bar_low"]) + } + if s.cfg.DefaultColors["room_name"] != "74 bold" { + t.Fatalf("player color leaked from constant save: room_name=%q", s.cfg.DefaultColors["room_name"]) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte("FF bold")) { + t.Fatalf("config.yaml not persisted with new constant value; got:\n%s", string(raw)) + } +} + +func TestConstantsSaveRejectsPlayerKey(t *testing.T) { + s, _ := newColorsTestServer(t) + // Player categories like "room_name" are not legal constant keys. + body := map[string]any{"constant_colors": map[string]string{"room_name": "FF"}} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.saveColors(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + func TestColorsOffAllowed(t *testing.T) { s, _ := newColorsTestServer(t) body := map[string]any{"colors": map[string]string{"mob": "off"}} @@ -119,6 +170,7 @@ func TestColorsOffAllowed(t *testing.T) { func TestColorsReset(t *testing.T) { s, _ := newColorsTestServer(t) s.cfg.DefaultColors["room_name"] = "FF" + s.cfg.ConstantColors["hp_bar_low"] = "FF" req := httptest.NewRequest(http.MethodPost, "/api/colors/reset", nil) w := httptest.NewRecorder() s.handleColorsReset(w, req) @@ -128,6 +180,9 @@ func TestColorsReset(t *testing.T) { if s.cfg.DefaultColors["room_name"] != "74 bold" { t.Fatalf("reset did not restore builtin; got %q", s.cfg.DefaultColors["room_name"]) } + if s.cfg.ConstantColors["hp_bar_low"] != "A7" { + t.Fatalf("constant reset did not restore builtin; got %q", s.cfg.ConstantColors["hp_bar_low"]) + } } func TestColorsPreviewExhaustive(t *testing.T) { @@ -153,12 +208,21 @@ func TestColorsPreviewExhaustive(t *testing.T) { for _, cat := range colorCategories { if cat.Name == "currency_pickup" || cat.Name == "map_blocked" || cat.Name == "sequence" || - cat.Name == "science_mod" { + cat.Name == "science_mod" || + // `error` and `warning` used to be previewed as HP-bar proxies; + // the bars now paint with the real hp_bar_* constants, so these + // two combat-message categories have no natural example slot. + cat.Name == "error" || cat.Name == "warning" { continue } if !seen[cat.Name] { t.Errorf("preview missing category: %s", cat.Name) } } + for _, cat := range constantCategories { + if !seen[cat.Name] { + t.Errorf("preview missing constant category: %s", cat.Name) + } + } _ = io.Discard } \ No newline at end of file diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 9f62897..0d2e8c6 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -504,6 +504,8 @@ input:focus-visible,select:focus-visible,textarea:focus-visible,button:focus-vis .color-list .search-bar{flex-shrink:0} #colorRows{flex:1;overflow-y:auto;padding-right:4px} .color-group{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#777;margin:10px 0 4px 0;padding-bottom:2px;border-bottom:1px solid var(--border)} +.color-section-header{font-size:13px;font-weight:bold;letter-spacing:.5px;color:var(--cond-light);margin:14px 0 6px 0;padding:4px 6px;border-top:1px solid var(--border);border-bottom:1px solid var(--border);background:var(--cond-bg);border-radius:3px} +.color-section-header:first-child{margin-top:4px;border-top:none} .color-row{display:flex;align-items:center;gap:6px;padding:4px 4px;border-radius:3px;margin-bottom:1px} .color-row:hover{background:rgba(15,52,96,.3)} .color-row.dirty{background:rgba(158,99,7,.12)} diff --git a/internal/admin/static/coloreditor.js b/internal/admin/static/coloreditor.js index fe88945..7ae305e 100644 --- a/internal/admin/static/coloreditor.js +++ b/internal/admin/static/coloreditor.js @@ -6,13 +6,23 @@ 'use strict'; var STATE = { - categories: [], // [{name,label,group}] - categoriesById: {}, // name -> cat - saved: {}, // name -> spec string (last persisted) - staged: {}, // name -> spec string (unsaved edits) - defaults: {}, // name -> spec string (builtins, for reset) - preview: [], // [{title, lines:[{segments:[{t,c}]}]}] - dirty: {} // name -> true + categories: [], // [{name,label,group}] — player-overridable + categoriesById: {}, // name -> cat (player) + saved: {}, // name -> spec (last persisted) — player + staged: {}, // name -> spec (unsaved) — player + defaults: {}, // name -> spec (builtins) — player + dirty: {}, // name -> true — player + + // Constant (admin-only) colors. Kept in parallel maps so the page can + // render two top-level sections but share one save/reset pipeline. + constantCategories: [], + constantCategoriesById: {}, + constantSaved: {}, + constantStaged: {}, + constantDefaults: {}, + constantDirty: {}, + + preview: [], }; function $i(id) { return document.getElementById(id); } @@ -73,10 +83,39 @@ return parts.join(' '); } + // Returns 'constant' if the category name is a constant (admin-only) + // color, otherwise 'player'. + function sectionOf(name) { + if (STATE.constantCategoriesById[name]) return 'constant'; + return 'player'; + } + + // Current effective spec for a category (staged overrides saved), works for + // both player and constant sections. + function effSaved(name) { return sectionOf(name) === 'constant' ? (STATE.constantSaved[name] || STATE.constantDefaults[name] || '') : (STATE.saved[name] || STATE.defaults[name] || ''); } + function effStaged(name) { return sectionOf(name) === 'constant' ? STATE.constantStaged[name] : STATE.staged[name]; } + function effDirty(name) { return sectionOf(name) === 'constant' ? STATE.constantDirty[name] : STATE.dirty[name]; } + function setStaged(name, val) { + if (sectionOf(name) === 'constant') STATE.constantStaged[name] = val; + else STATE.staged[name] = val; + } + function setDirty(name, val) { + if (sectionOf(name) === 'constant') STATE.constantDirty[name] = val; + else STATE.dirty[name] = val; + } + function clearDirty(name) { + if (sectionOf(name) === 'constant') delete STATE.constantDirty[name]; + else delete STATE.dirty[name]; + } + function clearStaged(name) { + if (sectionOf(name) === 'constant') delete STATE.constantStaged[name]; + else delete STATE.staged[name]; + } + // Current effective spec for a category (staged overrides saved). function effectiveSpec(name) { - if (STATE.dirty[name]) return STATE.staged[name]; - return STATE.saved[name] || STATE.defaults[name] || ''; + if (effDirty(name)) return effStaged(name); + return effSaved(name); } // ── 16-color ANSI projection (mirrors nearestANSI in color.go) ────────── @@ -114,16 +153,12 @@ function buildRows() { var host = $i('colorRows'); host.innerHTML = ''; - var byGroup = {}; - var order = []; - STATE.categories.forEach(function (c) { - if (!byGroup[c.group]) { byGroup[c.group] = []; order.push(c.group); } - byGroup[c.group].push(c); - }); - order.forEach(function (g) { - host.appendChild(el('div', 'color-group', g)); - byGroup[g].forEach(function (c) { host.appendChild(buildRow(c)); }); - }); + // Section header, then flat ordered rows. No per-group dividers: the + // order mirrors the in-game `colors` command exactly. + host.appendChild(el('div', 'color-section-header', 'Player Colors')); + STATE.categories.forEach(function (c) { host.appendChild(buildRow(c)); }); + host.appendChild(el('div', 'color-section-header', 'Constants')); + STATE.constantCategories.forEach(function (c) { host.appendChild(buildRow(c)); }); } function rowSwatchColor(spec) { @@ -220,8 +255,8 @@ } } var specStr = serializeSpec(s); - STATE.staged[cat.name] = specStr; - STATE.dirty[cat.name] = (specStr !== (STATE.saved[cat.name] || '')); + setStaged(cat.name, specStr); + setDirty(cat.name, (specStr !== effSaved(cat.name))); refreshRow(row); refreshRevertButton(row, cat); renderColorPreview(); @@ -238,7 +273,7 @@ } function refreshRow(row) { - row.classList.toggle('dirty', !!STATE.dirty[row.dataset.name]); + row.classList.toggle('dirty', !!effDirty(row.dataset.name)); var spec = parseSpec(effectiveSpec(row.dataset.name)); var sw = row.querySelector('.color-swatch'); var sc = rowSwatchColor(spec); @@ -248,13 +283,13 @@ function refreshRevertButton(row, cat) { var btn = row.querySelector('.color-revert'); - btn.disabled = !STATE.dirty[cat.name]; + btn.disabled = !effDirty(cat.name); } function revertRow(row, cat) { - delete STATE.staged[cat.name]; - delete STATE.dirty[cat.name]; - var spec = parseSpec(STATE.saved[cat.name] || STATE.defaults[cat.name] || ''); + clearStaged(cat.name); + clearDirty(cat.name); + var spec = parseSpec(effSaved(cat.name)); row.dataset.fg = spec.fg >= 0 ? spec.fg : '-1'; row.querySelectorAll('.color-flags input').forEach(function (cb) { var f = cb.dataset.flag; cb.checked = !!spec[f]; @@ -468,18 +503,23 @@ function colorSave() { var status = $i('colorSaveStatus'); status.textContent = 'Saving...'; status.className = 'color-save-status'; - var payload = {}; + var playerPayload = {}, constPayload = {}; Object.keys(STATE.dirty).forEach(function (k) { - if (STATE.dirty[k]) payload[k] = effectiveSpec(k); + if (STATE.dirty[k]) playerPayload[k] = effectiveSpec(k); + }); + Object.keys(STATE.constantDirty).forEach(function (k) { + if (STATE.constantDirty[k]) constPayload[k] = effectiveSpec(k); }); fetch('/api/colors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ colors: payload }) + body: JSON.stringify({ colors: playerPayload, constant_colors: constPayload }) }).then(function (r) { return r.json(); }).then(function (j) { if (j.ok) { // committed values become the new saved baseline Object.keys(STATE.dirty).forEach(function (k) { if (STATE.dirty[k]) STATE.saved[k] = effectiveSpec(k); }); + Object.keys(STATE.constantDirty).forEach(function (k) { if (STATE.constantDirty[k]) STATE.constantSaved[k] = effectiveSpec(k); }); STATE.dirty = {}; + STATE.constantDirty = {}; document.querySelectorAll('.color-row.dirty').forEach(function (row) { row.classList.remove('dirty'); var btn = row.querySelector('.color-revert'); if (btn) btn.disabled = true; @@ -494,7 +534,7 @@ } function colorResetAll() { - if (!confirm('Reset ALL colors to the built-in defaults? This saves immediately.')) return; + if (!confirm('Reset ALL colors (player + constants) to the built-in defaults? This saves immediately.')) return; fetch('/api/colors/reset', { method: 'POST' }).then(function (r) { return r.json(); }).then(function (j) { if (!j.ok) { alert('Reset failed: ' + (j.error || 'unknown')); return; } Object.keys(j.colors || {}).forEach(function (k) { @@ -502,14 +542,20 @@ STATE.staged[k] = j.colors[k]; delete STATE.dirty[k]; }); + Object.keys(j.constant_colors || {}).forEach(function (k) { + STATE.constantSaved[k] = j.constant_colors[k]; + STATE.constantStaged[k] = j.constant_colors[k]; + delete STATE.constantDirty[k]; + }); buildRows(); renderColorPreview(); }).catch(function (e) { alert('Reset failed: ' + e); }); } function colorExport() { - var out = {}; - STATE.categories.forEach(function (c) { out[c.name] = effectiveSpec(c.name); }); + var out = { colors: {}, constant_colors: {} }; + STATE.categories.forEach(function (c) { out.colors[c.name] = effectiveSpec(c.name); }); + STATE.constantCategories.forEach(function (c) { out.constant_colors[c.name] = effectiveSpec(c.name); }); var json = JSON.stringify(out, null, 2); var ta = el('textarea'); ta.value = json; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); @@ -521,18 +567,28 @@ } function colorImportPrompt() { - var txt = prompt('Paste exported theme JSON (a {category: spec} object):'); + var txt = prompt('Paste exported theme JSON ({colors:{...},constant_colors:{...}}):'); if (!txt) return; var obj; try { obj = JSON.parse(txt); } catch (e) { alert('Invalid JSON: ' + e); return; } + // Accept either the layered {colors, constant_colors} shape or a flat + // {category: spec} legacy object. + var layerColors = obj.colors || obj; + var layerConst = obj.constant_colors || {}; var applied = 0; - Object.keys(obj).forEach(function (k) { - if (!STATE.categoriesById[k]) return; - var spec = parseSpec(obj[k]); - STATE.staged[k] = serializeSpec(spec); - STATE.dirty[k] = (STATE.staged[k] !== (STATE.saved[k] || '')); - applied++; - }); + function applyInto(map, idMap, stagedMap, dirtyMap) { + Object.keys(map).forEach(function (k) { + var cat = idMap[k]; + if (!cat) return; + var spec = parseSpec(map[k]); + stagedMap[k] = serializeSpec(spec); + var saved = sectionOf(k) === 'constant' ? STATE.constantSaved[k] : STATE.saved[k]; + dirtyMap[k] = (stagedMap[k] !== (saved || '')); + applied++; + }); + } + applyInto(layerColors, STATE.categoriesById, STATE.staged, STATE.dirty); + applyInto(layerConst, STATE.constantCategoriesById, STATE.constantStaged, STATE.constantDirty); if (applied === 0) { alert('No known categories found in the JSON.'); return; } buildRows(); renderColorPreview(); @@ -543,19 +599,23 @@ function filterColorRows(q) { q = (q || '').toLowerCase(); - document.querySelectorAll('#colorRows .color-row').forEach(function (row) { - var name = row.dataset.name; - var label = (STATE.categoriesById[name] || {}).label || ''; - row.style.display = (!q || name.indexOf(q) >= 0 || label.toLowerCase().indexOf(q) >= 0) ? '' : 'none'; - }); - document.querySelectorAll('#colorRows .color-group').forEach(function (g) { - // hide group header if all its rows are hidden — naive: check sibling visibility - var any = false, n = g.nextElementSibling; - while (n && !n.classList.contains('color-group')) { - if (n.classList.contains('color-row') && n.style.display !== 'none') { any = true; break; } + // Group rows under their preceding section header so the header can be + // hidden when its section has zero visible rows under a query. + var headers = document.querySelectorAll('#colorRows .color-section-header'); + headers.forEach(function (h) { + var any = false, n = h.nextElementSibling; + while (n && !n.classList.contains('color-section-header')) { + if (n.classList.contains('color-row')) { + var name = n.dataset.name; + var idMap = sectionOf(name) === 'constant' ? STATE.constantCategoriesById : STATE.categoriesById; + var label = (idMap[name] || {}).label || ''; + var match = (!q || name.indexOf(q) >= 0 || label.toLowerCase().indexOf(q) >= 0); + n.style.display = match ? '' : 'none'; + if (match) any = true; + } n = n.nextElementSibling; } - g.style.display = any || !q ? '' : 'none'; + h.style.display = (!q || any) ? '' : 'none'; }); } @@ -567,9 +627,18 @@ STATE.categories.forEach(function (c) { STATE.categoriesById[c.name] = c; }); STATE.saved = data.colors || {}; STATE.defaults = data.default_colors || {}; - STATE.preview = data.preview || []; STATE.staged = {}; STATE.dirty = {}; Object.keys(STATE.saved).forEach(function (k) { STATE.staged[k] = STATE.saved[k]; }); + + STATE.constantCategories = data.constant_categories || []; + STATE.constantCategoriesById = {}; + STATE.constantCategories.forEach(function (c) { STATE.constantCategoriesById[c.name] = c; }); + STATE.constantSaved = data.constant_colors || {}; + STATE.constantDefaults = data.default_constant_colors || {}; + STATE.constantStaged = {}; STATE.constantDirty = {}; + Object.keys(STATE.constantSaved).forEach(function (k) { STATE.constantStaged[k] = STATE.constantSaved[k]; }); + + STATE.preview = data.preview || []; buildRows(); renderColorPreview(); }).catch(function (e) { diff --git a/internal/config/config.go b/internal/config/config.go index 755d43b..9510cb2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { StartupValidation ValidationConfig `yaml:"startup_validation"` GameConstants GameConstants `yaml:"game_constants"` DefaultColors ColorsConfig `yaml:"default_colors"` + ConstantColors ColorsConfig `yaml:"constant_colors"` TLS TLSConfig `yaml:"tls"` Telnet TelnetConfig `yaml:"telnet"` TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"` @@ -79,6 +80,75 @@ func DefaultColors() ColorsConfig { } } +// DefaultConstantColors returns the built-in specs for the colors that the +// engine renders conditionally and that players can never override (HP-bar +// tiers, level-difference bands, table-header chrome, status indicators, etc). +// Admins may override any of these via the constant_colors section of +// config.yaml; players have no access to them. +func DefaultConstantColors() ColorsConfig { + return ColorsConfig{ + // HP / status bars (cmd_score.go, render_combat.go). + "hp_bar_high": "6C", + "hp_bar_med": "B3", + "hp_bar_low": "A7", + "hp_bar_empty": "ED dim", + + // Task worksite progress bar (sys_labor.go) — same tiering rules + // as the HP bar but a different low-color so labor tasks read + // green/yellow/orange rather than green/yellow/red. It reuses + // hp_bar_empty for the empty/unfilled cells. + "task_bar_high": "6C", + "task_bar_med": "B3", + "task_bar_low": "43", + + // Score-panel other bars and tech/buff indicators (cmd_score.go). + "battery_bar": "6E", + "run_energy_bar": "6C", + "active_tech_on": "6C", + "active_tech_status": "6C", + "active_tech_stat": "6E", + "score_max_value": "FA", + + // Skill-level tier color on the score panel (cmd_score.go). + "skill_lvl_max": "B4", + "skill_lvl_high": "B3", + "skill_lvl_mid": "74", + "skill_lvl_low": "49", + "skill_lvl_base": "42", + + // Level-difference band on mobs/players (render_color.go). + "level_diff_equal": "FA", + "level_diff_up_small": "B3", + "level_diff_up_big": "A7", + "level_diff_down_small": "6E", + "level_diff_down_big": "41", + + // Tech list status / name styling (cmd_tech.go). + "tech_status_on": "6C", + "tech_status_off": "F0", + "tech_status_locked": "A7", + "tech_name_unlocked": "49", + "tech_name_locked": "F0", + + // Shared table-heading chrome (cmd_skills.go, cmd_mods.go, + // cmd_option.go, cmd_aps.go). + "table_header_label": "49", + "table_header_value": "FA", + "table_header_detail": "F5", + "table_header_xp": "B3", + "table_header_desc": "FF", + + // APS / look inline accents. + "aps_here": "6C", + "mob_hp_inline": "A7", + "protected_label": "FA", + "god_tag_label": "bold", + + // Production menu degraded/locked rows (production_menu.go). + "production_locked": "F0", + } +} + type ValidationConfig struct { RootRooms []int `yaml:"root_rooms"` IgnoreUnreachable []int `yaml:"ignore_unreachable"` @@ -130,7 +200,8 @@ func Default() *Config { GameConstants: GameConstants{ ShopDefaultRestock: 1000, }, - DefaultColors: DefaultColors(), + DefaultColors: DefaultColors(), + ConstantColors: DefaultConstantColors(), TLS: TLSConfig{ CertFile: "", KeyFile: "", diff --git a/internal/game/cmd_aps.go b/internal/game/cmd_aps.go index 727bf8b..56820ad 100644 --- a/internal/game/cmd_aps.go +++ b/internal/game/cmd_aps.go @@ -125,15 +125,18 @@ func displayApsNodes(g *Game, sess *net.Session, p *player.Player, known []int) }) mode := g.colorMode(sess) + hLabel := g.resolveConstant(sess, "table_header_label") + hDetail := g.resolveConstant(sess, "table_header_detail") + hValue := g.resolveConstant(sess, "table_header_value") rows := make([][]string, len(infos)) for i, info := range infos { distStr := strconv.Itoa(info.Distance) if info.Distance == 0 { - distStr = color.Render(mode, color.Parse("6C"), "here") + distStr = color.Render(mode, g.resolveConstant(sess, "aps_here"), "here") } rows[i] = []string{ - color.Render(mode, color.Parse("F5"), fmt.Sprintf("#%d", info.ID)), - color.Render(mode, color.Parse("49"), info.Name), + color.Render(mode, hDetail, fmt.Sprintf("#%d", info.ID)), + color.Render(mode, hLabel, info.Name), distStr, } } @@ -141,9 +144,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("F5"), "Room"), - color.Render(mode, color.Parse("49"), "Name"), - color.Render(mode, color.Parse("FA"), "Distance"), + color.Render(mode, hDetail, "Room"), + color.Render(mode, hLabel, "Name"), + color.Render(mode, hValue, "Distance"), }, Rows: rows, } diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index 01e2b9a..01912a7 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -134,39 +134,78 @@ func (g *Game) executeColor(sess *net.Session, args []string, rawInput string) { } var colorCategoryOrder = []string{ + // Room / look "room_name", "room_number", "room_desc", - "direction", "exit_direction", "exit_name", - "protected_mob", + "dim", "mob", + "protected_mob", + "player_name", + "item", + + // Movement + "direction", + + // Combat "damage_dealt", "damage_taken", - - "xp", - "level_up", - "item", - "player_name", + "miss", "death", "victory", - "miss", + "warning", "error", + "science_mod", + + // XP / level + "xp", + "level_up", + + // Pickups / economy + "currency_pickup", + + // Chat / dialog "say", "global", + "broadcast", "dialog", "dialog_options", - "broadcast", - "fire", - "eat_food", + + // Drops / consume / fire "drop_message", - "currency_pickup", + "eat_food", + "fire", + + // Tick / status + "visual_tick", + "visual_first_tick", + // Tech / farm + "battery", + "tech_depleted", + "farm_grow", + "farm_disease", + + // Assassin / map / safespot + "assassin_task", "map_at", "map_blocked", "map_agility", "safespot_alert", + + // System + "sequence", +} + +// PlayerColorCategoryOrder is the canonical ordered list of player-overridable +// color categories, shared with the admin GUI so its left-side editor list +// mirrors the in-game `colors` command output exactly. +func PlayerColorCategoryOrder() []string { + out := make([]string, len(colorCategoryOrder)) + copy(out, colorCategoryOrder) + return out } func showColorTable(g *Game, sess *net.Session) { diff --git a/internal/game/cmd_mods.go b/internal/game/cmd_mods.go index 26b0e95..0126b5f 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("49"), "Module"), - color.Render(mode, color.Parse("F5"), "Level"), - color.Render(mode, color.Parse("B3"), "Cost"), - color.Render(mode, color.Parse("B3"), "XP"), + color.Render(mode, g.resolveConstant(sess, "table_header_label"), "Module"), + color.Render(mode, g.resolveConstant(sess, "table_header_detail"), "Level"), + color.Render(mode, g.resolveConstant(sess, "table_header_xp"), "Cost"), + color.Render(mode, g.resolveConstant(sess, "table_header_xp"), "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("49"), m.Name), - color.Render(mode, color.Parse("F5"), levelStr), - color.Render(mode, color.Parse("B3"), costStr), - color.Render(mode, color.Parse("B3"), xpStr), + color.Render(mode, g.resolveConstant(sess, "table_header_label"), m.Name), + color.Render(mode, g.resolveConstant(sess, "table_header_detail"), levelStr), + color.Render(mode, g.resolveConstant(sess, "table_header_xp"), costStr), + color.Render(mode, g.resolveConstant(sess, "table_header_xp"), xpStr), }) } else { t.Rows = append(t.Rows, []string{ diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go index 2d6f3f0..55a04de 100644 --- a/internal/game/cmd_option.go +++ b/internal/game/cmd_option.go @@ -16,12 +16,16 @@ func (g *Game) doOption(sess *net.Session, input string) { mode := g.colorMode(sess) if input == "" { + hLabel := g.resolveConstant(sess, "table_header_label") + hValue := g.resolveConstant(sess, "table_header_value") + hDetail := g.resolveConstant(sess, "table_header_detail") + hDesc := g.resolveConstant(sess, "table_header_desc") table := &ui.Table{ Columns: []string{ - color.Render(mode, color.Parse("49"), "Option"), - color.Render(mode, color.Parse("FA"), "Value"), - color.Render(mode, color.Parse("F5"), "Valid"), - color.Render(mode, color.Parse("FF"), "Description"), + color.Render(mode, hLabel, "Option"), + color.Render(mode, hValue, "Value"), + color.Render(mode, hDetail, "Valid"), + color.Render(mode, hDesc, "Description"), }, } for _, def := range player.OptionDefs { @@ -29,10 +33,10 @@ func (g *Game) doOption(sess *net.Session, input string) { val = truncateForTable(val, p.OptionBool("unicode")) valid := formatValidValues(&def) table.Rows = append(table.Rows, []string{ - color.Render(mode, color.Parse("49"), def.Name), - color.Render(mode, color.Parse("FA"), val), - color.Render(mode, color.Parse("F5"), valid), - color.Render(mode, color.Parse("FF"), def.Description), + color.Render(mode, hLabel, def.Name), + color.Render(mode, hValue, val), + color.Render(mode, hDetail, valid), + color.Render(mode, hDesc, def.Description), }) } for _, line := range table.Render(p.OptionBool("unicode"), p.OptionInt("wrap_width")) { diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index 31caf1e..3949486 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -92,43 +92,46 @@ func (g *Game) doScore(sess *net.Session) { sep() nameStr := g.colorize(sess, "player_name", p.Name) - clStr := color.Render(mode, color.Parse("FA"), fmt.Sprintf("Combat Lvl %d", p.CombatLevel())) + clStr := g.colorizeConstant(sess, "score_max_value", 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, "currency_pickup", formatInt(p.Credits)) chipsStr := g.colorize(sess, "currency_pickup", formatInt(countChips(p))) - styleStr := color.Render(mode, color.Parse("49"), fmt.Sprintf("Style: %s", p.AttackStyle)) + styleStr := color.Render(mode, g.resolveConstant(sess, "table_header_label"), fmt.Sprintf("Style: %s", p.AttackStyle)) invStr := fmt.Sprintf("Inv: %d/28", 28-p.FreeSlots()) row2 := padded("Credits: "+creditsStr, 19) + padded("Chips: "+chipsStr, 16) + padded(styleStr, 22) + invStr content(row2) gap() - hpStyle := hpBarStyle(p.HP, p.MaxHP()) + hpStyle := g.hpBarStyle(sess, p.HP, p.MaxHP()) hpBarStr := ui.RenderColoredBar(p.HP, p.MaxHP(), 26, unicode, hpStyle, mode) - hpFg := hpBarFg(p.HP, p.MaxHP()) + hpFg := g.hpBarStyle(sess, p.HP, p.MaxHP()).FilledColor hpLine := fmt.Sprintf("HP [%s] %s / %s", hpBarStr, color.Render(mode, color.ColorSpec{Fg: hpFg}, fmt.Sprint(p.HP)), - color.Render(mode, color.Parse("FA"), fmt.Sprint(p.MaxHP()))) + g.colorizeConstant(sess, "score_max_value", fmt.Sprint(p.MaxHP()))) content(hpLine) batBarRaw := ui.RenderBarF(p.Battery, p.MaxBattery(), 26, unicode) - batBarStr := color.Render(mode, color.ColorSpec{Fg: 110}, batBarRaw) + batBarStr := color.Render(mode, g.resolveConstant(sess, "battery_bar"), batBarRaw) + batFg := g.resolveConstant(sess, "battery_bar").Fg batLine := fmt.Sprintf("BAT [%s] %s / %s", batBarStr, - color.Render(mode, color.ColorSpec{Fg: 110}, fmt.Sprintf("%.1f", p.Battery)), - color.Render(mode, color.Parse("FA"), fmt.Sprintf("%.0f", p.MaxBattery()))) + color.Render(mode, color.ColorSpec{Fg: batFg}, fmt.Sprintf("%.1f", p.Battery)), + g.colorizeConstant(sess, "score_max_value", fmt.Sprintf("%.0f", p.MaxBattery()))) content(batLine) - engStyle := &ui.BarStyle{FilledColor: 108, EmptyColor: 237, EmptyDim: true} + engSpec := g.resolveConstant(sess, "run_energy_bar") + engEmpty := g.resolveConstant(sess, "hp_bar_empty") + engStyle := &ui.BarStyle{FilledColor: engSpec.Fg, EmptyColor: engEmpty.Fg, EmptyDim: engEmpty.Dim} engBarStr := ui.RenderColoredBar(p.RunEnergy, p.MaxRunEnergy(), 26, unicode, engStyle, mode) engLine := fmt.Sprintf("ENG [%s] %s / %s", engBarStr, - color.Render(mode, color.ColorSpec{Fg: 108}, fmt.Sprint(p.RunEnergy)), - color.Render(mode, color.Parse("FA"), fmt.Sprint(p.MaxRunEnergy()))) + color.Render(mode, color.ColorSpec{Fg: engSpec.Fg}, fmt.Sprint(p.RunEnergy)), + g.colorizeConstant(sess, "score_max_value", fmt.Sprint(p.MaxRunEnergy()))) content(engLine) gap() @@ -155,8 +158,8 @@ func (g *Game) doScore(sess *net.Session) { buildSkillCell := func(s player.SkillName) string { level := p.Level(s) - lvlColor := skillLevelColor(level) - name := padded(color.Render(mode, color.ColorSpec{Fg: 110}, skillDisplay[s]), 12) + lvlColor := g.skillLevelColor(sess, level) + name := padded(color.Render(mode, g.resolveConstant(sess, "active_tech_stat"), skillDisplay[s]), 12) lvl := color.Render(mode, color.ColorSpec{Fg: lvlColor}, fmt.Sprintf("%2d", level)) xpBar := xpProgressBar(p.Skills[s], 12, unicode) return fmt.Sprintf("%s %s [%s]", name, lvl, xpBar) @@ -195,8 +198,8 @@ func (g *Game) doScore(sess *net.Session) { for _, id := range p.ActiveTechList() { def := GetTechDef(id) if def != nil { - on := color.Render(mode, color.ColorSpec{Fg: 108}, "[ON]") - name := color.Render(mode, color.ColorSpec{Fg: 110}, def.Name) + on := color.Render(mode, g.resolveConstant(sess, "active_tech_on"), "[ON]") + name := color.Render(mode, g.resolveConstant(sess, "active_tech_stat"), def.Name) techParts = append(techParts, on+" "+name) } } @@ -205,9 +208,9 @@ func (g *Game) doScore(sess *net.Session) { if len(p.ActiveBuffs) > 0 { for _, buff := range p.ActiveBuffs { buffLine := fmt.Sprintf("%s +%d%% %s (%d ticks remaining)", - color.Render(mode, color.ColorSpec{Fg: 108}, "[BUFF]"), + color.Render(mode, g.resolveConstant(sess, "active_tech_status"), "[BUFF]"), buff.BonusPercent, - color.Render(mode, color.ColorSpec{Fg: 110}, buff.Stat), + color.Render(mode, g.resolveConstant(sess, "active_tech_stat"), buff.Stat), buff.TicksLeft) content(buffLine) } @@ -252,51 +255,21 @@ func formatInt(n int) string { return strings.Join(parts, ",") } -func hpBarStyle(current, max int) *ui.BarStyle { - if max <= 0 { - max = 1 - } - pct := float64(current) / float64(max) - var fg int - switch { - case pct >= 0.7: - fg = 108 - case pct >= 0.4: - fg = 179 - default: - fg = 167 - } - return &ui.BarStyle{FilledColor: fg, EmptyColor: 237, EmptyDim: true} -} - -func hpBarFg(current, max int) int { - if max <= 0 { - max = 1 - } - pct := float64(current) / float64(max) - switch { - case pct >= 0.7: - return 108 - case pct >= 0.4: - return 179 - default: - return 167 - } -} - -func skillLevelColor(level int) int { +// skillLevelColor returns the xterm256 palette index for a skill level cell +// on the datapad score panel, sourcing it from the constant_colors section. +func (g *Game) skillLevelColor(sess *net.Session, level int) int { + spec := g.resolveConstant(sess, "skill_lvl_base") switch { case level >= 99: - return 180 + spec = g.resolveConstant(sess, "skill_lvl_max") case level >= 80: - return 179 + spec = g.resolveConstant(sess, "skill_lvl_high") case level >= 60: - return 116 + spec = g.resolveConstant(sess, "skill_lvl_mid") case level >= 40: - return 73 - default: - return 66 + spec = g.resolveConstant(sess, "skill_lvl_low") } + return spec.Fg } func xpProgressBar(xp int, width int, unicode bool) string { diff --git a/internal/game/cmd_skills.go b/internal/game/cmd_skills.go index 2eeb149..2f9f52e 100644 --- a/internal/game/cmd_skills.go +++ b/internal/game/cmd_skills.go @@ -16,23 +16,27 @@ func (g *Game) executeSkills(sess *net.Session, args []string, rawInput string) func (g *Game) doSkills(sess *net.Session) { p := sess.Player mode := g.colorMode(sess) + hLabel := g.resolveConstant(sess, "table_header_label") + hDetail := g.resolveConstant(sess, "table_header_detail") + hValue := g.resolveConstant(sess, "table_header_value") + hXP := g.resolveConstant(sess, "table_header_xp") t := &ui.Table{Title: "Skills", Columns: []string{ - color.Render(mode, color.Parse("49"), "Skill"), - color.Render(mode, color.Parse("F5"), "Abbr"), - color.Render(mode, color.Parse("FA"), "Level"), - color.Render(mode, color.Parse("B3"), "XP"), - color.Render(mode, color.Parse("B3"), "XP to Next"), + color.Render(mode, hLabel, "Skill"), + color.Render(mode, hDetail, "Abbr"), + color.Render(mode, hValue, "Level"), + color.Render(mode, hXP, "XP"), + color.Render(mode, hXP, "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("49"), string(s)), - color.Render(mode, color.Parse("F5"), player.SkillAbbr[s]), - color.Render(mode, color.Parse("FA"), strconv.Itoa(level)), - color.Render(mode, color.Parse("B3"), strconv.Itoa(xp)), - color.Render(mode, color.Parse("B3"), strconv.Itoa(next)), + color.Render(mode, hLabel, string(s)), + color.Render(mode, hDetail, player.SkillAbbr[s]), + color.Render(mode, hValue, strconv.Itoa(level)), + color.Render(mode, hXP, strconv.Itoa(xp)), + color.Render(mode, hXP, strconv.Itoa(next)), }) } for _, line := range t.Render(p.OptionBool("unicode"), p.OptionInt("wrap_width")) { diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go index 4ca1382..0a5580c 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("FA"), fmt.Sprintf("%.0f", p.MaxBattery())), + g.colorizeConstant(sess, "score_max_value", 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("6C"), "ON") + status = color.Render(mode, g.resolveConstant(sess, "tech_status_on"), "ON") } else { - status = color.Render(mode, color.Parse("F0"), "off") + status = color.Render(mode, g.resolveConstant(sess, "tech_status_off"), "off") } - nameStr = color.Render(mode, color.Parse("49"), tech.Name) + nameStr = color.Render(mode, g.resolveConstant(sess, "tech_name_unlocked"), tech.Name) } else { - status = color.Render(mode, color.Parse("A7"), "locked") - nameStr = color.Render(mode, color.Parse("F0"), tech.Name) + status = color.Render(mode, g.resolveConstant(sess, "tech_status_locked"), "locked") + nameStr = color.Render(mode, g.resolveConstant(sess, "tech_name_locked"), tech.Name) } effect := techEffectString(tech) diff --git a/internal/game/game.go b/internal/game/game.go index eed61f5..700877b 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -38,9 +38,10 @@ type Deps struct { MobStore *world.MobStore CraftIndex *CraftIndex CourseStore *CourseStore - Ticks *engine.Engine - ColorConfig *config.ColorsConfig - DataDir string + Ticks *engine.Engine + ColorConfig *config.ColorsConfig + ConstantColorConfig *config.ColorsConfig + DataDir string } // Game is the central orchestrator: it owns the data stores (via the embedded @@ -75,19 +76,20 @@ type Game struct { shutdownMu sync.Mutex } -func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig, startingRoom int) *Game { +func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig *config.ColorsConfig, valConfig config.ValidationConfig, startingRoom int) *Game { g := &Game{ Deps: Deps{ - World: world.New(dataDir), - ObjectStore: object.NewObjectStore(dataDir), - ItemStore: item.NewItemStore(dataDir), - AccountStore: player.NewAccountStore(dataDir), - MobStore: world.NewMobStore(dataDir), - CraftIndex: NewCraftIndex(), - CourseStore: NewCourseStore(dataDir), - Ticks: engine.New(), - ColorConfig: colorConfig, - DataDir: dataDir, + World: world.New(dataDir), + ObjectStore: object.NewObjectStore(dataDir), + ItemStore: item.NewItemStore(dataDir), + AccountStore: player.NewAccountStore(dataDir), + MobStore: world.NewMobStore(dataDir), + CraftIndex: NewCraftIndex(), + CourseStore: NewCourseStore(dataDir), + Ticks: engine.New(), + ColorConfig: colorConfig, + ConstantColorConfig: constantColorConfig, + DataDir: dataDir, }, GlobalFlags: NewGlobalFlagStore(), Combat: combat.NewTracker(), diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index c1befe3..dd2cdf0 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("A7"), fmt.Sprint(m.HP)), m.MaxHP) + hp = fmt.Sprintf(" [%s/%dhp]", g.colorizeConstant(sess, "mob_hp_inline", fmt.Sprint(m.HP)), m.MaxHP) } } var desc string @@ -103,13 +103,16 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. var godPrefix string if p.GodMode { var tags []string + tagLabel := func(t string) string { + return color.Render(g.colorMode(sess), g.resolveConstant(sess, "god_tag_label"), t) + } if isLocal { - tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Fg: -1, Bold: true}, "[LOCAL]")) + tags = append(tags, tagLabel("[LOCAL]")) } else { - tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Fg: -1, Bold: true}, "[GLOBAL]")) + tags = append(tags, tagLabel("[GLOBAL]")) } if def.Hidden { - tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Fg: -1, Bold: true}, "(HIDDEN)")) + tags = append(tags, tagLabel("(HIDDEN)")) } godPrefix = strings.Join(tags, " ") + " " } diff --git a/internal/game/look_target.go b/internal/game/look_target.go index bb9b5da..9257940 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -64,7 +64,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } var levelStr string if best.Protected { - levelStr = color.Render(g.colorMode(sess), color.Parse("FA"), "(protected)") + levelStr = g.colorizeConstant(sess, "protected_label", "(protected)") } else { mobLevel := mobCombatLevel(best) levelStr = g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel)) diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go index ff9fc51..10b7eb0 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("F0") + dimSpec := g.resolveConstant(sess, "production_locked") tbl := &ui.Table{ Title: title, diff --git a/internal/game/render_color.go b/internal/game/render_color.go index 3464f26..958b671 100644 --- a/internal/game/render_color.go +++ b/internal/game/render_color.go @@ -42,24 +42,53 @@ func (g *Game) colorize(sess *net.Session, category, text string) string { return color.Render(g.colorMode(sess), spec, text) } -func levelColorSpec(myLevel, theirLevel int) color.ColorSpec { +// resolveConstant returns the live spec for a constant (player-invisible) +// color category. Unlike resolveColor, it never consults the account's +// per-player override map — constants are admin-tunable only — so players +// have no way to change them. Falls back through the file's constant_colors +// map, then to the built-in DefaultConstantColors. +func (g *Game) resolveConstant(sess *net.Session, category string) color.ColorSpec { + if g.ConstantColorConfig != nil { + if val, ok := (*g.ConstantColorConfig)[category]; ok && val != "" { + return color.Parse(val) + } + } + if val, ok := config.DefaultConstantColors()[category]; ok && val != "" { + return color.Parse(val) + } + return color.NoColor() +} + +// colorizeConstant paints text with a constant color spec, bypassing the +// player override tier entirely. +func (g *Game) colorizeConstant(sess *net.Session, category, text string) string { + spec := g.resolveConstant(sess, category) + return color.Render(g.colorMode(sess), spec, text) +} + +// levelColorSpec returns the color spec for the level-difference band shown +// next to mob/player levels (green/red/white by how far the target is from +// the viewer). Uses admin-tunable constant_colors; falls back to built-ins. +func (g *Game) levelColorSpec(sess *net.Session, myLevel, theirLevel int) color.ColorSpec { diff := theirLevel - myLevel + var name string switch { case diff == 0: - return color.Parse("FA") + name = "level_diff_equal" case diff > 0 && diff < 5: - return color.Parse("B3") + name = "level_diff_up_small" case diff >= 5: - return color.Parse("A7") + name = "level_diff_up_big" case diff < 0 && diff > -5: - return color.Parse("6E") + name = "level_diff_down_small" default: - return color.Parse("41") + name = "level_diff_down_big" } + return g.resolveConstant(sess, name) } func (g *Game) levelColorize(sess *net.Session, myLevel, theirLevel int, text string) string { - spec := levelColorSpec(myLevel, theirLevel) + spec := g.levelColorSpec(sess, myLevel, theirLevel) return color.Render(g.colorMode(sess), spec, text) } diff --git a/internal/game/render_combat.go b/internal/game/render_combat.go index e4a0d55..4055b4d 100644 --- a/internal/game/render_combat.go +++ b/internal/game/render_combat.go @@ -3,6 +3,7 @@ package game import ( "fmt" + "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/ui" ) @@ -14,7 +15,10 @@ func (g *Game) dmgDisplay(sess *net.Session, dmg int) string { return fmt.Sprintf("> %d <", dmg) } -func (g *Game) renderBar(sess *net.Session, current, max, lowColor int) string { +// renderBarGeneric renders a high/med/low tiered bar at width 10 using the +// named constant-color family (prefix: "hp_bar" or "task_bar"). The empty +// cells are painted with hp_bar_empty. +func (g *Game) renderBarGeneric(sess *net.Session, current, max int, prefix string) string { if max <= 0 { max = 1 } @@ -25,23 +29,43 @@ func (g *Game) renderBar(sess *net.Session, current, max, lowColor int) string { current = max } - pct := float64(current) / float64(max) * 100.0 - var fgColor int + pct := float64(current) / float64(max) + var spec color.ColorSpec switch { - case pct >= 70: - fgColor = 108 - case pct >= 40: - fgColor = 179 + case pct >= 0.7: + spec = g.resolveConstant(sess, prefix+"_high") + case pct >= 0.4: + spec = g.resolveConstant(sess, prefix+"_med") default: - fgColor = lowColor + spec = g.resolveConstant(sess, prefix+"_low") } + empty := g.resolveConstant(sess, "hp_bar_empty") unicode := sess.Player != nil && sess.Player.OptionBool("unicode") - style := &ui.BarStyle{FilledColor: fgColor, EmptyColor: 237, EmptyDim: true} + style := &ui.BarStyle{FilledColor: spec.Fg, EmptyColor: empty.Fg, EmptyDim: empty.Dim} bar := ui.RenderColoredBar(current, max, 10, unicode, style, g.colorMode(sess)) return "[" + bar + "]" } +// hpBarStyle returns the per-tier fill/empty color spec for an HP bar at the +// given ratio (high>=70%, med>=40%, low<40%). Colors come from the +// constant_colors map (hp_bar_high/med/low/empty). +func (g *Game) hpBarStyle(sess *net.Session, current, max int) *ui.BarStyle { + if max <= 0 { + max = 1 + } + pct := float64(current) / float64(max) + spec := g.resolveConstant(sess, "hp_bar_low") + switch { + case pct >= 0.7: + spec = g.resolveConstant(sess, "hp_bar_high") + case pct >= 0.4: + spec = g.resolveConstant(sess, "hp_bar_med") + } + empty := g.resolveConstant(sess, "hp_bar_empty") + return &ui.BarStyle{FilledColor: spec.Fg, EmptyColor: empty.Fg, EmptyDim: empty.Dim} +} + func (g *Game) hpBar(sess *net.Session, current, max int) string { - return g.renderBar(sess, current, max, 167) + return g.renderBarGeneric(sess, current, max, "hp_bar") } diff --git a/internal/game/sys_labor.go b/internal/game/sys_labor.go index 6004b75..f0e1d01 100644 --- a/internal/game/sys_labor.go +++ b/internal/game/sys_labor.go @@ -22,7 +22,7 @@ func taskWorkVerb(mob *world.MobInstance) string { // inverse of hpBar. It is used for task worksites whose internal HP drains to 0 // to complete the work (progress = MaxHP - HP). func (g *Game) progressBar(sess *net.Session, progress, max int) string { - return g.renderBar(sess, progress, max, 67) + return g.renderBarGeneric(sess, progress, max, "task_bar") } // writeTaskProgress prints the per-tick progress line for a task worksite, -- cgit v1.2.3