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 += '
No characters found
'; + } else { + filtered.forEach(function(c) { + var active = currentChar === c.name ? ' active' : ''; + html += '
'; + html += '
' + esc(c.name) + '
'; + html += '
Total Lvl ' + esc(c.total_level) + ' · Room ' + esc(c.room) + '
'; + if (c.account) { + html += '
' + esc(c.account) + '
'; + } + html += '
'; + }); + } + + el.innerHTML = html; +} + +function loadCharacter(name) { + currentChar = name; + currentView = 'character'; + currentAcc = null; + renderCharList(''); + window.location.hash = name; + + API.get('/api/players/' + encodeURIComponent(name)).then(function(data) { + renderCharacter(name, data); + }).catch(function(e) { + $('#editorMain').innerHTML = '

Failed to load: ' + esc(e.message) + '

'; + }); +} + +function renderCharacter(name, data) { + var html = '

Character: ' + esc(name) + '

'; + + html += '
'; + html += '
'; + + html += '
'; + + html += sectionHeader('Identity'); + html += '
'; + html += fieldDisplay('Account', buildAccountLink(data.account || '-')); + html += fieldDisplay('Current Room', esc(data.room_id)); + html += fieldDisplay('HP / Max HP', esc(data.hp) + ' / ' + esc(data.max_hp)); + html += fieldDisplay('Credits', esc(data.credits)); + html += fieldDisplay('Banked Credits', esc(data.banked_credits)); + html += fieldDisplay('Combat Level', esc(data.combat_level)); + html += fieldDisplay('Total Level', esc(data.total_level)); + html += fieldDisplay('Attack Style', esc(data.attack_style)); + html += fieldDisplay('Battery', esc(data.battery)); + html += fieldDisplay('Prompt', esc(data.prompt)); + html += '
'; + + if (data.description) { + html += sectionHeader('Description'); + html += '
' + esc(data.description) + '
'; + } + + if (data.skill_levels && Object.keys(data.skill_levels).length > 0) { + html += sectionHeader('Skills'); + html += '
'; + var skillNames = Object.keys(data.skill_levels).sort(); + skillNames.forEach(function(sk) { + html += fieldDisplay(capitalize(sk), esc(data.skill_levels[sk])); + }); + html += '
'; + } + + if (data.equipment && Object.keys(data.equipment).length > 0) { + html += sectionHeader('Equipment'); + html += '
'; + var eqSlots = Object.keys(data.equipment).sort(); + eqSlots.forEach(function(slot) { + html += fieldDisplay(formatSlot(slot), esc(data.equipment[slot])); + }); + html += '
'; + } + + if (data.inventory && Object.keys(data.inventory).length > 0) { + html += sectionHeader('Inventory'); + html += '
'; + var invSlots = Object.keys(data.inventory).sort(byNum); + invSlots.forEach(function(sk) { + var slot = data.inventory[sk]; + var item = slot ? (slot.item_id || JSON.stringify(slot)) : '-'; + var qty = slot && slot.quantity ? ' x' + slot.quantity : ''; + html += fieldDisplay('Slot ' + esc(sk), esc(item) + qty); + }); + html += '
'; + } + + if (data.bank && Object.keys(data.bank).length > 0) { + html += sectionHeader('Bank'); + html += '
'; + var bankSlots = Object.keys(data.bank).sort(byNum); + bankSlots.forEach(function(sk) { + var slot = data.bank[sk]; + var item = slot ? (slot.item_id || JSON.stringify(slot)) : '-'; + var qty = slot && slot.quantity ? ' x' + slot.quantity : ''; + html += fieldDisplay('Slot ' + esc(sk), esc(item) + qty); + }); + html += '
'; + } + + if (data.stats) { + html += sectionHeader('Stats'); + html += '
'; + html += fieldDisplay('Total Kills', esc(data.stats.total_kills)); + html += fieldDisplay('Deaths', esc(data.stats.deaths)); + if (data.stats.rooms_visited) { + html += fieldDisplay('Unique Rooms', esc(roomVisitedCount(data.stats.rooms_visited))); + } + html += '
'; + + if (data.stats.mob_kills && Object.keys(data.stats.mob_kills).length > 0) { + html += sectionHeader('Mob Kills'); + html += '
'; + var mobs = Object.keys(data.stats.mob_kills).sort(); + mobs.forEach(function(mob) { + html += fieldDisplay(capitalize(mob), esc(data.stats.mob_kills[mob])); + }); + html += '
'; + } + } + + if (data.flags && Object.keys(data.flags).length > 0) { + html += sectionHeader('Flags'); + html += '
'; + var flagKeys = Object.keys(data.flags).sort(); + flagKeys.forEach(function(k) { + html += fieldDisplay(esc(k), esc(data.flags[k])); + }); + html += '
'; + } + + html += '
'; + + html += ''; + + $('#editorMain').innerHTML = html; +} + +function loadAccount(name) { + currentAcc = name; + currentView = 'account'; + currentChar = null; + renderCharList(''); + window.location.hash = 'account:' + name; + + API.get('/api/players/account/' + encodeURIComponent(name)).then(function(data) { + renderAccount(name, data); + }).catch(function(e) { + $('#editorMain').innerHTML = '

Failed to load account: ' + esc(e.message) + '

'; + }); +} + +function renderAccount(name, data) { + var html = '

Account: ' + esc(name) + '

'; + + html += '
'; + html += '
'; + + html += '
'; + + html += sectionHeader('Identity'); + html += '
'; + html += fieldDisplay('Name', esc(data.name)); + html += fieldDisplay('Admin', data.admin ? 'Yes' : 'No'); + html += '
'; + + if (data.characters && data.characters.length > 0) { + html += sectionHeader('Characters'); + html += '
'; + data.characters.forEach(function(charName) { + html += fieldDisplay('', buildCharacterLink(charName)); + }); + html += '
'; + } + + if (data.aliases && Object.keys(data.aliases).length > 0) { + html += sectionHeader('Aliases'); + html += '
'; + var aliasKeys = Object.keys(data.aliases).sort(); + aliasKeys.forEach(function(k) { + html += fieldDisplay(esc(k), esc(data.aliases[k])); + }); + html += '
'; + } + + if (data.colors && Object.keys(data.colors).length > 0) { + html += sectionHeader('Colors'); + html += '
'; + var colorKeys = Object.keys(data.colors).sort(); + colorKeys.forEach(function(k) { + html += fieldDisplay(esc(k), esc(data.colors[k])); + }); + html += '
'; + } + + if (data.options && Object.keys(data.options).length > 0) { + html += sectionHeader('Options'); + html += '
'; + var optKeys = Object.keys(data.options).sort(); + optKeys.forEach(function(k) { + html += fieldDisplay(esc(k), esc(data.options[k])); + }); + html += '
'; + } + + html += '
'; + + html += ''; + + $('#editorMain').innerHTML = html; +} + +function switchTab(e, tab) { + e.target.parentElement.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); }); + e.target.classList.add('active'); + var tabs = $('#editorMain').querySelectorAll('.tab-content'); + tabs.forEach(function(t) { t.style.display = 'none'; }); + var target = document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)); + if (target) target.style.display = 'block'; +} + +function buildAccountLink(accountName) { + if (!accountName || accountName === '-') return esc(accountName); + return '' + esc(accountName) + ''; +} + +function buildCharacterLink(charName) { + return '' + esc(charName) + ''; +} + +function sectionHeader(label) { + return '

' + esc(label) + '

'; +} + +function fieldDisplay(label, value) { + if (label) { + return '
' + value + '
'; + } + return '
' + value + '
'; +} + +function capitalize(s) { + var str = String(s); + return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' '); +} + +function formatSlot(slot) { + return capitalize(String(slot)); +} + +function byNum(a, b) { + return parseInt(a) - parseInt(b); +} + +function roomVisitedCount(base64Str) { + if (typeof base64Str !== 'string' || base64Str.length === 0) return '0'; + try { + var binary = atob(base64Str); + var count = 0; + for (var i = 0; i < binary.length; i++) { + var byte = binary.charCodeAt(i); + for (var bit = 0; bit < 8; bit++) { + if (byte & (1 << bit)) count++; + } + } + return count; + } catch (e) { + return base64Str.length > 40 ? base64Str.substring(0, 40) + '...' : base64Str; + } +} diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html index 2a1ef36..02f8a01 100644 --- a/internal/admin/templates/layout.html +++ b/internal/admin/templates/layout.html @@ -20,7 +20,7 @@ Techs Modules Triggers - Players + Characters Dashboard Files
@@ -40,7 +40,7 @@ {{else if eq .Page "techs"}}{{template "body-techs" .}} {{else if eq .Page "modules"}}{{template "body-modules" .}} {{else if eq .Page "triggers"}}{{template "body-triggers" .}} -{{else if eq .Page "players"}}{{template "body-players" .}} +{{else if eq .Page "characters"}}{{template "body-characters" .}} {{else if eq .Page "dashboard"}}{{template "body-dashboard" .}} {{else if eq .Page "files"}}{{template "body-files" .}} {{else if eq .Page "duplicate-rooms"}}{{template "body-duplicate-rooms" .}} diff --git a/internal/admin/templates/players.html b/internal/admin/templates/players.html index 22ffc47..2cef9d7 100644 --- a/internal/admin/templates/players.html +++ b/internal/admin/templates/players.html @@ -1,33 +1,15 @@ -{{define "body-players"}} -
-

Players

- - - - -
NameLevelRoomAccount
+{{define "body-characters"}} +
+
+ +
+
+
+

Select a character to view

+
+ {{end}} -- cgit v1.2.3