aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
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/admin
parentdb8ca1fe26a36b78074a424ae657b2cbb9b3af19 (diff)
downloadthehouseoficarus-2b5d8f595f96a2864899f4c7e28bc06fa52d2603.tar.gz
feat: expose all colors in config.yaml for easier theming
Diffstat (limited to 'internal/admin')
-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
4 files changed, 479 insertions, 139 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) {