diff options
Diffstat (limited to 'internal/admin')
| -rw-r--r-- | internal/admin/api_colors.go | 373 | ||||
| -rw-r--r-- | internal/admin/api_colors_test.go | 70 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 2 | ||||
| -rw-r--r-- | internal/admin/static/coloreditor.js | 173 |
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) { |
