package admin import ( "net/http" "os" "sort" "strings" "gopkg.in/yaml.v3" "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" ) // 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"` } // 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: "damage", Label: "Damage (alt key)", 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: "credits_pickup", Label: "Credits Pickup", Group: "Pickups / Economy"}, {Name: "chips_pickup", Label: "Chips 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_course", Label: "Map Course", Group: "Assassin / Map / Safespot"}, {Name: "safespot_alert", Label: "Safespot Alert", Group: "Assassin / Map / Safespot"}, // System {Name: "sequence", Label: "Sequence (trigger)", Group: "System"}, } // knownColor returns true if name is one of the editable categories. func knownColor(name string) bool { for _, c := range colorCategories { if c.Name == name { return true } } return false } // 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(""), ln(seg("It's a test room", "room_desc")), txt(""), ln(seg("A tree is here.", "room_desc")), txt(""), ln(seg("A sentry ", "protected_mob"), seg("(level 4)", ""), seg(" watches the gate.", "")), ln(seg("A man ", "mob"), seg("(level 5)", ""), seg(" scribbles something in a small notebook.", "")), txt(""), ln(seg("Icarus", "player_name"), seg(" is here.", "")), txt(""), txt("On the ground:"), ln(seg(" bones ", "item"), seg("(reserved for admin 65t)", "dim")), ln(seg(" bronze axe", "item")), ln(seg(" bucket of water", "item")), ln(seg(" 10 x chips ", "item"), seg("(reserved for admin 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(">", "dim")), txt(""), ln(seg("> s", "dim")), 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", "dim")), 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 6 \U0001FB34 damage. [█░░░░░░░░░] [ 1/7]", "damage_dealt"), 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. [░░░░░░░░░░] [ 0/7]", "damage_dealt"), seg(" (+18xp acc, +6xp hp)", "xp")), ln(seg("You have defeated ", ""), seg("the man", "mob"), seg("!", "victory")), ln(seg("*** You are now level 12 melee! ***", "level_up")), txt(""), ln(seg("A scout", "mob"), seg(" hits you for ", ""), seg("\U0001FB38 5 \U0001FB34 damage. [██████░░░░] [22/40]", "damage_taken")), ln(seg("A scout's attack is extra effective! Equip ", ""), seg("plated vest", "warning"), seg(" for protection.", "")), ln(seg("You attack ", ""), seg("a scout", "mob"), seg(" with ", ""), seg("laser cutter", "science_mod"), seg("!", "")), ln(seg("You miss a scout.", "miss")), ln(seg("Oh dear, you are dead!", "death")), txt(""), ln(seg("> help!", "dim")), ln(seg("Unknown command.", "error")), txt(""), ln(seg("(", ""), seg("damage", "damage"), seg(" is a config-only alt key, not emitted by the engine)", "")), }, }) // --- Pickups / economy ------------------------------------------------ sections = append(sections, colorSection{ Title: "Pickups & Economy", Lines: []colorLine{ ln(seg("You pick up ", ""), seg("50 credits. (total: 1234)", "credits_pickup")), ln(seg("You steal ", ""), seg("25 credits", "credits_pickup"), seg(".", "")), ln(seg("Score \u2502 ", ""), seg("12 chips", "chips_pickup")), }, }) // --- Chat / dialog ---------------------------------------------------- sections = append(sections, colorSection{ Title: "Chat & Dialog", Lines: []colorLine{ ln(seg("You say: ", ""), seg("hello there", "say")), ln(seg("[Global] ", "global"), seg("Icarus: ", "player_name"), seg("anyone up for a raid?", "global")), ln(seg("Icarus", "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("[enter to continue]", "dialog_options")), ln(seg(" 1. ", "dialog_options"), seg("I'm looking for work.", "dialog")), ln(seg(" 2. ", "dialog_options"), seg("Just passing through.", "dialog")), }, }) // --- Drops / consume / fire ------------------------------------------ sections = append(sections, colorSection{ Title: "Drops, Consume & Fire", Lines: []colorLine{ ln(seg("The man drops: ", "drop_message"), seg("bones", "item")), ln(seg("The man drops: ", "drop_message"), seg("10", "credits_pickup"), 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{ 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")), ln(seg("Your divine power prevents death.", "miss")), }, }) // --- Map (mini map side panel) --------------------------------------- sections = append(sections, colorSection{ Title: "Mini Map", Lines: []colorLine{ ln(seg("╔═════╗", "dim")), ln(seg("║", "dim"), seg(" ", ""), seg("░", "map_blocked"), seg(" ", ""), seg("║", "dim")), ln(seg("║", "dim"), seg(" ", ""), seg("║", "dim")), ln(seg("║", "dim"), seg("■", ""), seg("-", ""), seg("@", "map_at"), seg("-", ""), seg("■", ""), seg("║", "dim")), ln(seg("║", "dim"), seg(" ", ""), seg("│", "map_course"), seg(" ", ""), seg("║", "dim")), ln(seg("║", "dim"), seg(" ", ""), seg("■", ""), seg(" ", ""), seg("║", "dim")), ln(seg("╚═════╝", "dim")), }, }) // --- Trigger sequence / system --------------------------------------- sections = append(sections, colorSection{ Title: "Triggers & System", Lines: []colorLine{ ln(seg("The ancient console hums to life as you approach.", "sequence")), ln(seg(" A holographic display flickers. \u201cWelcome, traveler.\u201d", "sequence")), }, }) 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] = "" } } writeJSON(w, map[string]any{ "colors": live, "default_colors": def, "categories": colorCategories, "preview": buildPreview(), }) } func (s *AdminServer) saveColors(w http.ResponseWriter, r *http.Request) { var body struct { Colors map[string]string `json:"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. for name, val := range body.Colors { if !knownColor(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 } } 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 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() 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] = "" } } writeJSON(w, map[string]any{"ok": true, "colors": live}) } // 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 }