aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-13 23:01:45 -0400
committerhistoria <[not public]>2026-07-13 23:01:45 -0400
commit5a6986a8c539364b7a166abdfb608b3cc00ecb3b (patch)
tree0a8a199fa28173df7f97493a209b5642ef7b4817 /internal
parent5ea48c1f91298b42924f6865ffc30810cceec2d3 (diff)
downloadthehouseoficarus-5a6986a8c539364b7a166abdfb608b3cc00ecb3b.tar.gz
feat(admin): convert players page into characters page with basic stats/yaml
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_helpers.go34
-rw-r--r--internal/admin/api_players.go153
-rw-r--r--internal/admin/server.go1
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/charactereditor.js323
-rw-r--r--internal/admin/templates/layout.html4
-rw-r--r--internal/admin/templates/players.html40
-rw-r--r--internal/player/player.go8
-rw-r--r--internal/player/store.go20
9 files changed, 518 insertions, 67 deletions
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+function escAttr(s) { return String(s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;'); }
+
+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 += '<div style="padding:12px;color:#888;font-size:12px">No characters found</div>';
+ } else {
+ filtered.forEach(function(c) {
+ var active = currentChar === c.name ? ' active' : '';
+ html += '<div class="item' + active + '" onclick="loadCharacter(\'' + escAttr(c.name) + '\')">';
+ html += '<div style="font-weight:600">' + esc(c.name) + '</div>';
+ html += '<div style="font-size:10px;color:#888;margin-top:2px">Total Lvl ' + esc(c.total_level) + ' &middot; Room ' + esc(c.room) + '</div>';
+ if (c.account) {
+ html += '<div style="font-size:10px;color:#aaa">' + esc(c.account) + '</div>';
+ }
+ html += '</div>';
+ });
+ }
+
+ 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 = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function renderCharacter(name, data) {
+ var html = '<h2 style="margin-top:0">Character: ' + esc(name) + '</h2>';
+
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+
+ html += sectionHeader('Identity');
+ html += '<div class="form-grid">';
+ 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 += '</div>';
+
+ if (data.description) {
+ html += sectionHeader('Description');
+ html += '<pre style="background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;border:1px solid var(--border);white-space:pre-wrap">' + esc(data.description) + '</pre>';
+ }
+
+ if (data.skill_levels && Object.keys(data.skill_levels).length > 0) {
+ html += sectionHeader('Skills');
+ html += '<div class="form-grid">';
+ var skillNames = Object.keys(data.skill_levels).sort();
+ skillNames.forEach(function(sk) {
+ html += fieldDisplay(capitalize(sk), esc(data.skill_levels[sk]));
+ });
+ html += '</div>';
+ }
+
+ if (data.equipment && Object.keys(data.equipment).length > 0) {
+ html += sectionHeader('Equipment');
+ html += '<div class="form-grid">';
+ var eqSlots = Object.keys(data.equipment).sort();
+ eqSlots.forEach(function(slot) {
+ html += fieldDisplay(formatSlot(slot), esc(data.equipment[slot]));
+ });
+ html += '</div>';
+ }
+
+ if (data.inventory && Object.keys(data.inventory).length > 0) {
+ html += sectionHeader('Inventory');
+ html += '<div class="form-grid">';
+ 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 += '</div>';
+ }
+
+ if (data.bank && Object.keys(data.bank).length > 0) {
+ html += sectionHeader('Bank');
+ html += '<div class="form-grid">';
+ 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 += '</div>';
+ }
+
+ if (data.stats) {
+ html += sectionHeader('Stats');
+ html += '<div class="form-grid">';
+ 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 += '</div>';
+
+ if (data.stats.mob_kills && Object.keys(data.stats.mob_kills).length > 0) {
+ html += sectionHeader('Mob Kills');
+ html += '<div class="form-grid">';
+ var mobs = Object.keys(data.stats.mob_kills).sort();
+ mobs.forEach(function(mob) {
+ html += fieldDisplay(capitalize(mob), esc(data.stats.mob_kills[mob]));
+ });
+ html += '</div>';
+ }
+ }
+
+ if (data.flags && Object.keys(data.flags).length > 0) {
+ html += sectionHeader('Flags');
+ html += '<div class="form-grid">';
+ var flagKeys = Object.keys(data.flags).sort();
+ flagKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.flags[k]));
+ });
+ html += '</div>';
+ }
+
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ $('#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 = '<p style="color:var(--danger)">Failed to load account: ' + esc(e.message) + '</p>';
+ });
+}
+
+function renderAccount(name, data) {
+ var html = '<h2 style="margin-top:0">Account: ' + esc(name) + '</h2>';
+
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+
+ html += sectionHeader('Identity');
+ html += '<div class="form-grid">';
+ html += fieldDisplay('Name', esc(data.name));
+ html += fieldDisplay('Admin', data.admin ? '<span style="color:var(--accent)">Yes</span>' : 'No');
+ html += '</div>';
+
+ if (data.characters && data.characters.length > 0) {
+ html += sectionHeader('Characters');
+ html += '<div class="form-grid">';
+ data.characters.forEach(function(charName) {
+ html += fieldDisplay('', buildCharacterLink(charName));
+ });
+ html += '</div>';
+ }
+
+ if (data.aliases && Object.keys(data.aliases).length > 0) {
+ html += sectionHeader('Aliases');
+ html += '<div class="form-grid">';
+ var aliasKeys = Object.keys(data.aliases).sort();
+ aliasKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.aliases[k]));
+ });
+ html += '</div>';
+ }
+
+ if (data.colors && Object.keys(data.colors).length > 0) {
+ html += sectionHeader('Colors');
+ html += '<div class="form-grid">';
+ var colorKeys = Object.keys(data.colors).sort();
+ colorKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.colors[k]));
+ });
+ html += '</div>';
+ }
+
+ if (data.options && Object.keys(data.options).length > 0) {
+ html += sectionHeader('Options');
+ html += '<div class="form-grid">';
+ var optKeys = Object.keys(data.options).sort();
+ optKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.options[k]));
+ });
+ html += '</div>';
+ }
+
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ $('#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 '<a href="javascript:void(0)" onclick="loadAccount(\'' + escAttr(accountName) + '\')" class="entity-link">' + esc(accountName) + '</a>';
+}
+
+function buildCharacterLink(charName) {
+ return '<a href="javascript:void(0)" onclick="loadCharacter(\'' + escAttr(charName) + '\')" class="entity-link">' + esc(charName) + '</a>';
+}
+
+function sectionHeader(label) {
+ return '<h3 style="margin:16px 0 8px 0;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:4px;font-size:13px;text-transform:uppercase;letter-spacing:0.5px">' + esc(label) + '</h3>';
+}
+
+function fieldDisplay(label, value) {
+ if (label) {
+ return '<div class="form-group"><label>' + label + '</label><span style="font-size:12px;word-break:break-all">' + value + '</span></div>';
+ }
+ return '<div class="form-group"><span style="font-size:12px;word-break:break-all">' + value + '</span></div>';
+}
+
+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 @@
<a href="/editor/techs" class="{{if eq .Page "techs"}}active{{end}}">Techs</a>
<a href="/editor/modules" class="{{if eq .Page "modules"}}active{{end}}">Modules</a>
<a href="/editor/triggers" class="{{if eq .Page "triggers"}}active{{end}}">Triggers</a>
- <a href="/editor/players" class="{{if eq .Page "players"}}active{{end}}">Players</a>
+ <a href="/editor/characters" class="{{if eq .Page "characters"}}active{{end}}">Characters</a>
<a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
<a href="/editor/files" class="{{if eq .Page "files"}}active{{end}}">Files</a>
<div class="undo-bar">
@@ -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"}}
-<div style="padding:20px">
- <h2>Players</h2>
- <div class="search-bar" style="max-width:400px"><input type="search" placeholder="Search players..." id="playerSearch" oninput="searchPlayers()"></div>
- <table class="table" style="margin-top:12px">
- <thead><tr><th>Name</th><th>Level</th><th>Room</th><th>Account</th></tr></thead>
- <tbody id="playerTableBody"></tbody>
- </table>
+{{define "body-characters"}}
+<div class="editor-container">
+ <div class="editor-list" id="charList">
+ <div class="search-bar"><input type="search" placeholder="Search characters..." oninput="filterCharList(this.value)"></div>
+ <div id="listEntries"></div>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a character to view</p>
+ </div>
</div>
+<script src="/static/charactereditor.js"></script>
<script>
-API.get('/api/players').then(function(r){
- var tbody = $('#playerTableBody');
- var players = r.players || r || [];
- window._players = players;
- renderPlayerTable('');
-}).catch(function(e){notify('Failed: '+e.message,'error')});
-function searchPlayers(){
- var q = ($('#playerSearch').value||'').toLowerCase();
- renderPlayerTable(q);
-}
-function renderPlayerTable(q){
- var tbody = $('#playerTableBody');
- var rows = (window._players||[]).filter(function(p){
- var name = (p.name||p.Name||'').toLowerCase();
- return !q || name.indexOf(q) >= 0;
- }).map(function(p){
- return '<tr><td>'+esc(p.name||p.Name)+'</td><td>'+esc(p.level||p.Level)+'</td><td>'+esc(p.room||p.Room)+'</td><td>'+esc(p.account||p.Account)+'</td></tr>';
- });
- tbody.innerHTML = rows.join('') || '<tr><td colspan="4" style="color:#888">No players found</td></tr>';
-}
-function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+initCharacterEditor();
</script>
{{end}}
diff --git a/internal/player/player.go b/internal/player/player.go
index 5ffea6d..d78ae9d 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -376,6 +376,14 @@ func (p *Player) AddSkillXP(s SkillName, amount int) int {
return 0
}
+func (p *Player) TotalLevel() int {
+ total := 0
+ for _, s := range AllSkills {
+ total += p.Level(s)
+ }
+ return total
+}
+
func (p *Player) CombatLevel() int {
base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Technology))
att := float64(p.Level(Accuracy))
diff --git a/internal/player/store.go b/internal/player/store.go
index 2691d71..9bfdf40 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -154,6 +154,26 @@ func (s *AccountStore) FindCharacter(name string) (string, error) {
// data/players/characters/*.yaml file. Used by room-ID migration to walk and
// rewrite offline characters. Online characters are excluded by the caller
// (their in-memory state is updated directly).
+func (s *AccountStore) ListAccountNames() ([]string, error) {
+ dir := filepath.Join(s.dataDir, "players", "accounts")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ var names []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := strings.TrimSuffix(e.Name(), ".yaml")
+ if name == "" {
+ continue
+ }
+ names = append(names, name)
+ }
+ return names, nil
+}
+
func (s *AccountStore) ListCharacterNames() ([]string, error) {
dir := filepath.Join(s.dataDir, "players", "characters")
entries, err := os.ReadDir(dir)