aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_colors.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-15 05:10:40 -0400
committerhistoria <[not public]>2026-07-15 05:10:40 -0400
commit84dd8c7557d8b9d92cd8d339afc28a0fd9a3a896 (patch)
tree60c47059d6776e7b91fb516ebdb04a0932c9aa59 /internal/admin/api_colors.go
parentbeb188ce050fb76cdddc0bbeedee7ec608e47995 (diff)
downloadthehouseoficarus-84dd8c7557d8b9d92cd8d339afc28a0fd9a3a896.tar.gz
feat(admin): color scheme test page added
Diffstat (limited to 'internal/admin/api_colors.go')
-rw-r--r--internal/admin/api_colors.go430
1 files changed, 430 insertions, 0 deletions
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go
new file mode 100644
index 0000000..78e5f98
--- /dev/null
+++ b/internal/admin/api_colors.go
@@ -0,0 +1,430 @@
+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("[", "dim"), seg("*", "map_at"), seg("] ", "dim"),
+ seg("Test Room", "room_name"),
+ seg(" (", ""), seg("intro", "room_desc"), seg(", ", ""),
+ seg("#2001", "room_number"), seg(")", "")),
+ 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) watches the gate.", "")),
+ ln(seg("A man ", "mob"), seg("(level 5) 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("Room ", "exit_name"), seg("#1005", "room_number")),
+ ln(seg(" ", ""), seg("west", "exit_direction"), seg(" - ", ""), seg("Inside Transport Shuttle", "exit_name")),
+ txt(""),
+ ln(seg(">", "dim")),
+ },
+ })
+
+ // --- Movement ---------------------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Movement",
+ Lines: []colorLine{
+ ln(seg("You walk ", ""), seg("south", "direction"), seg(" to ", ""),
+ seg("Test Room", "room_name"), seg(" (", ""), seg("#2001", "room_number"), seg(")", "")),
+ ln(seg("WARNING: that area is exposed to ", ""), seg("radiation", "warning"), 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("\u25B8 6 \u25C2 damage. [\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [ 1/7]", "damage_dealt"),
+ seg(" (+18xp acc, +6xp hp)", "xp")),
+ ln(seg("The man", "mob"), seg(" misses you.", "miss")),
+ ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""),
+ seg("\u25B8 6 \u25C2 damage. [\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [ 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("\u25B8 5 \u25C2 damage. [\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [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 ", ""), seg("a scout", "mob"), seg(".", "miss")),
+ ln(seg("Oh dear, you are dead!", "death")),
+ ln(seg("Error: command not recognized.", "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("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591", "map_blocked"), seg("@", "map_at"), seg("\u2591\u2591", "map_blocked"), seg("\u2502", "map_course"), seg("\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2502", "map_course"), seg("\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d", "dim")),
+ ln(seg(" ", ""), seg("Room #2002", "room_number")),
+ },
+ })
+
+ // --- 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
+}