aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-18 04:44:50 -0400
committerhistoria <[not public]>2026-07-18 04:44:50 -0400
commit2b5d8f595f96a2864899f4c7e28bc06fa52d2603 (patch)
treef1539db865d9968414e59d9b250861c1b356d627 /internal
parentdb8ca1fe26a36b78074a424ae657b2cbb9b3af19 (diff)
downloadthehouseoficarus-2b5d8f595f96a2864899f4c7e28bc06fa52d2603.tar.gz
feat: expose all colors in config.yaml for easier theming
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_colors.go373
-rw-r--r--internal/admin/api_colors_test.go70
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/coloreditor.js173
-rw-r--r--internal/config/config.go73
-rw-r--r--internal/game/cmd_aps.go15
-rw-r--r--internal/game/cmd_color.go63
-rw-r--r--internal/game/cmd_mods.go16
-rw-r--r--internal/game/cmd_option.go20
-rw-r--r--internal/game/cmd_score.go85
-rw-r--r--internal/game/cmd_skills.go24
-rw-r--r--internal/game/cmd_tech.go12
-rw-r--r--internal/game/game.go30
-rw-r--r--internal/game/look_entities.go11
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/production_menu.go2
-rw-r--r--internal/game/render_color.go43
-rw-r--r--internal/game/render_combat.go44
-rw-r--r--internal/game/sys_labor.go2
19 files changed, 776 insertions, 284 deletions
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,