diff options
Diffstat (limited to 'internal/admin/api_colors.go')
| -rw-r--r-- | internal/admin/api_colors.go | 430 |
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 +} |
