From 5a6986a8c539364b7a166abdfb608b3cc00ecb3b Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 13 Jul 2026 23:01:45 -0400 Subject: feat(admin): convert players page into characters page with basic stats/yaml --- internal/admin/api_helpers.go | 34 +++- internal/admin/api_players.go | 153 +++++++++++---- internal/admin/server.go | 1 + internal/admin/static/admin.css | 2 + internal/admin/static/charactereditor.js | 323 +++++++++++++++++++++++++++++++ internal/admin/templates/layout.html | 4 +- internal/admin/templates/players.html | 40 ++-- 7 files changed, 490 insertions(+), 67 deletions(-) create mode 100644 internal/admin/static/charactereditor.js (limited to 'internal/admin') diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go index 78e59d4..109bca6 100644 --- a/internal/admin/api_helpers.go +++ b/internal/admin/api_helpers.go @@ -2,19 +2,49 @@ package admin import ( "bytes" + "fmt" "os" "gopkg.in/yaml.v3" ) func yamlToMap(data []byte) (map[string]any, error) { - var m map[string]any - if err := yaml.Unmarshal(data, &m); err != nil { + var v any + if err := yaml.Unmarshal(data, &v); err != nil { return nil, err } + m, ok := stringifyMap(v).(map[string]any) + if !ok { + return nil, fmt.Errorf("yaml root is not a map") + } return m, nil } +func stringifyMap(v any) any { + switch val := v.(type) { + case map[interface{}]interface{}: + out := make(map[string]any, len(val)) + for k, v := range val { + out[fmt.Sprint(k)] = stringifyMap(v) + } + return out + case map[string]interface{}: + out := make(map[string]any, len(val)) + for k, v := range val { + out[k] = stringifyMap(v) + } + return out + case []interface{}: + out := make([]any, len(val)) + for i, e := range val { + out[i] = stringifyMap(e) + } + return out + default: + return v + } +} + func writeMapAsYAML(path string, m map[string]any) ([]byte, error) { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) diff --git a/internal/admin/api_players.go b/internal/admin/api_players.go index 2d0c09b..f7a0b87 100644 --- a/internal/admin/api_players.go +++ b/internal/admin/api_players.go @@ -17,29 +17,6 @@ func (s *AdminServer) handlePlayers(w http.ResponseWriter, r *http.Request) { return } - nameFilter := r.URL.Query().Get("name") - - if nameFilter != "" { - name, err := s.accountStore.FindCharacter(nameFilter) - if err != nil { - writeJSON(w, map[string]any{"error": "character not found: " + nameFilter}) - return - } - charPath := s.accountStore.CharPath(name) - data, err := os.ReadFile(charPath) - if err != nil { - writeJSON(w, map[string]any{"error": "read error: " + err.Error()}) - return - } - var p player.Player - if err := yaml.Unmarshal(data, &p); err != nil { - writeJSON(w, map[string]any{"error": "parse error: " + err.Error()}) - return - } - writeJSON(w, p) - return - } - charsDir := filepath.Join(s.dataDir, "players", "characters") entries, err := os.ReadDir(charsDir) if err != nil { @@ -47,10 +24,14 @@ func (s *AdminServer) handlePlayers(w http.ResponseWriter, r *http.Request) { return } + charToAccount := s.buildCharToAccountMap() + type playerSummary struct { - Name string `json:"name"` - Level int `json:"level"` - Room int `json:"room"` + Name string `json:"name"` + Level int `json:"level"` + TotalLevel int `json:"total_level"` + Room int `json:"room"` + Account string `json:"account"` } var players []playerSummary @@ -59,19 +40,123 @@ func (s *AdminServer) handlePlayers(w http.ResponseWriter, r *http.Request) { continue } rawName := strings.TrimSuffix(e.Name(), ".yaml") - data, err := os.ReadFile(filepath.Join(charsDir, e.Name())) + p, err := s.accountStore.LoadCharacter(rawName) if err != nil { continue } - var p player.Player - if err := yaml.Unmarshal(data, &p); err != nil { - continue - } players = append(players, playerSummary{ - Name: rawName, - Level: p.CombatLevel(), - Room: p.RoomID, + Name: rawName, + Level: p.CombatLevel(), + TotalLevel: p.TotalLevel(), + Room: p.RoomID, + Account: charToAccount[rawName], }) } writeJSON(w, players) } + +func (s *AdminServer) buildCharToAccountMap() map[string]string { + m := make(map[string]string) + names, err := s.accountStore.ListAccountNames() + if err != nil { + return m + } + for _, name := range names { + acc, err := s.accountStore.LoadAccount(name) + if err != nil { + continue + } + for _, charName := range acc.Characters { + m[charName] = acc.Name + } + } + return m +} + +func (s *AdminServer) handlePlayerByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/players/") + if path == "" { + http.Error(w, `{"error":"missing player id"}`, http.StatusBadRequest) + return + } + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if strings.HasPrefix(path, "account/") { + s.getAccount(w, r, strings.TrimPrefix(path, "account/")) + return + } + + s.getCharacter(w, r, path) +} + +func (s *AdminServer) getCharacter(w http.ResponseWriter, r *http.Request, name string) { + canonical, err := s.accountStore.FindCharacter(name) + if err != nil { + writeJSONError(w, "character not found: "+name, http.StatusNotFound) + return + } + + charPath := s.accountStore.CharPath(canonical) + data, err := os.ReadFile(charPath) + if err != nil { + writeJSONError(w, "read error: "+err.Error(), http.StatusInternalServerError) + return + } + + m, err := yamlToMap(data) + if err != nil { + writeJSONError(w, "parse error: "+err.Error(), http.StatusInternalServerError) + return + } + + var p player.Player + if err := yaml.Unmarshal(data, &p); err == nil { + m["combat_level"] = p.CombatLevel() + m["total_level"] = p.TotalLevel() + m["max_hp"] = p.MaxHP() + skillLevels := make(map[string]int) + for _, skill := range player.AllSkills { + skillLevels[string(skill)] = p.Level(skill) + } + m["skill_levels"] = skillLevels + + charToAccount := s.buildCharToAccountMap() + if acc, ok := charToAccount[canonical]; ok { + m["account"] = acc + } + } + + m["_path"] = charPath + m["_raw"] = string(data) + writeJSON(w, m) +} + +func (s *AdminServer) getAccount(w http.ResponseWriter, r *http.Request, name string) { + canonical, err := s.accountStore.FindAccount(name) + if err != nil { + writeJSONError(w, "account not found: "+name, http.StatusNotFound) + return + } + + accPath := s.accountStore.AccountPath(canonical) + data, err := os.ReadFile(accPath) + if err != nil { + writeJSONError(w, "read error: "+err.Error(), http.StatusInternalServerError) + return + } + + m, err := yamlToMap(data) + if err != nil { + writeJSONError(w, "parse error: "+err.Error(), http.StatusInternalServerError) + return + } + delete(m, "password_hash") + + m["_path"] = accPath + m["_raw"] = string(data) + writeJSON(w, m) +} diff --git a/internal/admin/server.go b/internal/admin/server.go index cc63cee..61fcb09 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -168,6 +168,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor apiMux.HandleFunc("/api/courses", s.handleCourses) apiMux.HandleFunc("/api/courses/", s.handleCourseByID) apiMux.HandleFunc("/api/players", s.handlePlayers) + apiMux.HandleFunc("/api/players/", s.handlePlayerByID) apiMux.HandleFunc("/api/dashboard", s.handleDashboard) apiMux.HandleFunc("/api/global_flags", s.handleGlobalFlags) apiMux.HandleFunc("/api/tools", s.handleTools) diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index f6072ad..63a12e5 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -172,6 +172,8 @@ details.dir-group summary.dir-header::-webkit-details-marker{display:none} .dir-group[open]>.dir-header .dir-arrow::before{content:'\25BC'} .dir-drop-target{background:rgba(15,52,96,.3)!important;outline:1px dashed var(--accent);outline-offset:-2px} .editor-main pre{background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;border:1px solid var(--border)} +.entity-link{color:var(--accent);text-decoration:none;cursor:pointer} +.entity-link:hover{text-decoration:underline;color:#4a90d9} .form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 10px} .form-grid .form-group{margin-bottom:4px} .form-grid .form-group input,.form-grid .form-group select{font-size:11px;padding:4px 6px} diff --git a/internal/admin/static/charactereditor.js b/internal/admin/static/charactereditor.js new file mode 100644 index 0000000..bf40f09 --- /dev/null +++ b/internal/admin/static/charactereditor.js @@ -0,0 +1,323 @@ +function esc(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } +function escAttr(s) { return String(s || '').replace(/&/g,'&').replace(/"/g,'"'); } + +var charList = []; +var currentView = null; +var currentChar = null; +var currentAcc = null; + +function initCharacterEditor() { + loadCharList(); + if (window.location.hash) { + var hash = window.location.hash.substring(1); + if (hash.indexOf('account:') === 0) { + loadAccount(hash.substring(8)); + } else { + loadCharacter(hash); + } + } +} + +function loadCharList() { + API.get('/api/players').then(function(data) { + charList = data || []; + renderCharList(''); + }).catch(function(e) { + notify('Failed to load characters: ' + e.message, 'error'); + }); +} + +function filterCharList(val) { + renderCharList(val); +} + +function renderCharList(filter) { + var el = $('#listEntries'); + var f = (filter || '').toLowerCase(); + var html = ''; + + var filtered = charList.filter(function(c) { + var name = (c.name || '').toLowerCase(); + var account = (c.account || '').toLowerCase(); + return !f || name.indexOf(f) >= 0 || account.indexOf(f) >= 0; + }); + + if (filtered.length === 0) { + html += '
Failed to load: ' + esc(e.message) + '
'; + }); +} + +function renderCharacter(name, data) { + var html = '' + esc(data.description) + ''; + } + + if (data.skill_levels && Object.keys(data.skill_levels).length > 0) { + html += sectionHeader('Skills'); + html += '
Failed to load account: ' + esc(e.message) + '
'; + }); +} + +function renderAccount(name, data) { + var html = '