package admin import ( "net/http" "os" "sort" "strings" "gopkg.in/yaml.v3" "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/game" ) // colorCategory describes one editable color category: its config key, a // human-friendly label, and a UI grouping. type colorCategory struct { Name string `json:"name"` Label string `json:"label"` Group string `json:"group"` } // colorSegment is one piece of preview text tagged with the category whose // color spec should paint it. A nil/empty category means plain (uncolored). type colorSegment struct { T string `json:"t"` // text C string `json:"c"` // category name, "" = plain } // colorLine groups segments into one rendered line. type colorLine struct { Segments []colorSegment `json:"segments"` } // colorSection is a titled block of example output. type colorSection struct { Title string `json:"title"` Lines []colorLine `json:"lines"` } // 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", "command": "Command Text", "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)", } // 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 } } 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} } // ln builds a line from a sequence of segments. func ln(segs ...colorSegment) colorLine { return colorLine{Segments: segs} } // txt builds a plain (uncolored) line. func txt(s string) colorLine { return colorLine{Segments: []colorSegment{{T: s}}} } // buildPreview constructs the example output covering every color category. // Text patterns match real in-game output (room looks, combat, pickups, chat, // etc.) so admins see exactly what each color key paints. func buildPreview() []colorSection { var sections []colorSection // --- A full room "look" ---------------------------------------------- sections = append(sections, colorSection{ Title: "Room Look", Lines: []colorLine{ ln(seg("Test Room", "room_name"), seg(" (intro, #2001)", "room_number")), txt(""), // Description + minimap, right-aligned at column 80. // Text padded to 71 chars visible + " " + 7-char map = 80. ln(seg("Golstaff, you have entered the door to the north. You are now by ", "room_desc"), seg("╔═════╗", "dim")), ln(seg("yourself, standing in a dark room. The pungent stench of mildew ", "room_desc"), seg("║", "dim"), seg(" ", ""), seg("║", "dim")), ln(seg("emanates from the wet dungeon walls. ", "room_desc"), seg("║", "dim"), seg(" ", ""), seg("║", "dim")), ln(seg(" ", ""), 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)", "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)", "level_diff_equal"), seg(" is here.", "")), txt(""), txt("On the ground:"), ln(seg(" bones ", "item"), seg("(reserved for Dalinar 65t)", "dim")), ln(seg(" bronze axe", "item")), ln(seg(" bucket of water", "item")), ln(seg(" 10 x ", ""), seg("chips ", "item"), seg("(reserved for Dalinar 65t)", "dim")), ln(seg(" pot of flour", "item")), txt(""), txt("Exits:"), ln(seg(" ", ""), seg("south", "exit_direction"), seg(" - ", ""), seg("Test Room", "exit_name")), ln(seg(" ", ""), seg("east", "exit_direction"), seg(" - ", ""), seg("Processing Station", "exit_name")), ln(seg(" ", ""), seg("west", "exit_direction"), seg(" - ", ""), seg("Inside Transport Shuttle", "exit_name")), txt(""), ln(seg("> s", "")), ln(seg("You walk ", ""), seg("south", "direction"), seg(".", "")), }, }) // --- Combat rounds (matches real output) ------------------------------ sections = append(sections, colorSection{ Title: "Combat (two encounters)", Lines: []colorLine{ txt(""), ln(seg("> attack man", "")), txt(""), 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("[", ""), 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("[", ""), 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("[", ""), seg("██████", "hp_bar_med"), seg("░░░░", "hp_bar_empty"), seg("]", ""), seg(" [22/40]", "")), ln(seg("Oh dear, you are dead!", "death")), txt(""), txt(""), }, }) // --- Chat / dialog ---------------------------------------------------- sections = append(sections, colorSection{ Title: "Chat & Dialog", Lines: []colorLine{ ln(seg("You say: ", ""), seg("hello there", "say")), ln(seg("[Global] ", "global"), seg("Dalinar: ", "player_name"), seg("I just won big in baccarat!", "global")), ln(seg("Dalinar", "player_name"), seg(" has slain ", ""), seg("a scout", "broadcast"), seg(" (level 5)!", "broadcast")), txt(""), ln(seg("Guard says: ", ""), seg("\u201cHalt. State your business.\u201d", "dialog")), ln(seg(" 1. ", "dialog_options"), seg("I'm looking for work.", "dialog")), ln(seg(" 2. ", "dialog_options"), seg("Just passing through.", "dialog")), ln(seg("[enter to continue]", "dialog_options")), txt(""), ln(seg(" ", ""), seg("bet ", "command"), seg(" Set your wager", "")), ln(seg(" ", ""), seg("spin", "command"), seg(" Spin the reels", "")), }, }) // --- Drops / consume / fire ------------------------------------------ sections = append(sections, colorSection{ Title: "Drops, Consume & Fire", Lines: []colorLine{ txt(""), ln(seg("The man drops: ", "drop_message"), seg("bones", "item")), ln(seg("The man drops: ", "drop_message"), seg("10", ""), seg(" x ", ""), seg("chips", "item")), ln(seg("You eat the bread. It restores some energy.", "eat_food")), ln(seg("You manage to get a fire going!", "fire")), }, }) // --- Tick / tech / farm ---------------------------------------------- sections = append(sections, colorSection{ Title: "Tick, Tech & Farm", Lines: []colorLine{ ln(seg("tick 1", "visual_first_tick")), ln(seg("tick 2", "visual_tick")), ln(seg("tick 3", "visual_tick")), ln(seg("Your battery is depleted! All tech has been disabled.", "tech_depleted")), ln(seg("You connect to the charging station. Your battery is fully recharged.", "battery")), ln(seg("Your marigold has grown to stage 2/4.", "farm_grow")), ln(seg("Your marigold has become diseased!", "farm_disease")), }, }) // --- Assassin / safespot --------------------------------------------- sections = append(sections, colorSection{ Title: "Assassin & Safespot", Lines: []colorLine{ txt(""), ln(seg("Your target: 15 slugs. Get to work.", "assassin_task")), ln(seg("*** Assassin task complete! ***", "assassin_task"), seg(" (+50xp asm)", "xp")), ln(seg(" Assassin task: 12 of 15 slugs remaining.", "assassin_task")), ln(seg("Your safespot has been compromised!", "safespot_alert")), txt(""), }, }) // --- 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("Battery ", ""), seg("[", ""), seg("██████", "battery_bar"), seg("░░░░░░░░░░░░░░░░░░░░", "hp_bar_empty"), seg("]", "")), ln(seg("Run En. ", ""), 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(" ", ""), seg("(level 13)", "level_diff_up_small"), seg(" ", ""), seg("(level 20)", "level_diff_up_big"), seg(" ", ""), seg("(level 7)", "level_diff_down_small"), seg(" ", ""), seg("(level 3)", "level_diff_down_big")), 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("Score panel: ", ""), seg("Combat Lvl 99", "score_max_value"), seg(" / HP ", ""), seg("40", "score_max_value"), seg(" / Battery ", ""), seg("100", "score_max_value"), seg(" / Run En. ", ""), seg("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 } func (s *AdminServer) handleColors(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.getColors(w, r) case http.MethodPost: s.saveColors(w, r) default: writeJSONError(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *AdminServer) getColors(w http.ResponseWriter, _ *http.Request) { live := make(map[string]string, len(colorCategories)) for _, c := range colorCategories { if v, ok := s.cfg.DefaultColors[c.Name]; ok { live[c.Name] = v } else { live[c.Name] = "" } } defaults := config.DefaultColors() def := make(map[string]string, len(colorCategories)) for _, c := range colorCategories { if v, ok := defaults[c.Name]; ok { def[c.Name] = v } else { 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, "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"` 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 player-color key/spec. for name, val := range body.Colors { if !knownPlayerColor(name) { writeJSONError(w, "unknown 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) 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) return } } cur := s.cfg.DefaultColors next := make(map[string]string, len(cur)+len(body.Colors)) for k, v := range cur { next[k] = v } for k, v := range body.Colors { next[k] = v } 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 } writeJSON(w, map[string]any{"ok": true}) } func (s *AdminServer) handleColorsReset(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSONError(w, "method not allowed", http.StatusMethodNotAllowed) 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 } // Return the freshly reset live values for the client to reload. live := make(map[string]string, len(colorCategories)) for _, c := range colorCategories { if v, ok := s.cfg.DefaultColors[c.Name]; ok { live[c.Name] = v } else { live[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] = "" } } writeJSON(w, map[string]any{"ok": true, "colors": live, "constant_colors": liveConst}) } // writeConfigFile marshals the entire live *config.Config back to config.yaml, // overwriting whatever was on disk. A full re-marshal drops the file's manual // comments but produces a valid, reloadable file with the edited colors. func (s *AdminServer) writeConfigFile() error { _ = backupFile(s.configPath) // best-effort snapshot for safety out, err := yaml.Marshal(s.cfg) if err != nil { return err } return os.WriteFile(s.configPath, out, 0644) } // sortedKeys is a small helper retained in case future code wants a stable // iteration order over the category names. func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys }