Skip to content

Commit fbcd2fc

Browse files
authored
Skills definition overhaul & admin tool (#617)
# Description Converts skills and professions from hardcoded Go definitions into a fully data-driven, YAML-backed system. Skills and professions are now loaded from datafiles at boot, can be hot-reloaded in-game, and are fully manageable through new admin UI pages and a CRUD API. The change removes the compiled-in skill constants in favor of plain lowercase string ids, with per-skill max levels and a generalized progression model. ## Changes - Move skill and profession definitions out of Go and into per-entry YAML datafiles under `_datafiles/world/{default,empty}/skills/` and `/professions/`, loaded at server boot (skills first so professions can cross-reference). - Replace compiled-in skill-name constants (`SkillTag`) with plain lowercase string ids referenced directly throughout commands, combat, and scripting. - Add per-skill `maxlevel` (default 4) with progression math generalized to `max*(max+1)/2` skill points. - Adopt a "reject writes, zero reads" validation policy: unknown skill ids warn and no-op on write, return 0 on read, so orphaned save entries stay inert and no save migration is needed. - Restructure the `internal/skills` package into `skill.go`, `profession.go`, and `admin.go`, with new tests and test-data seeding helpers. - Add admin pages for skills, professions, and API docs (`/admin/skills`, `/admin/skills-professions`, `/admin/skills-api`) plus admin navigation entries. - Add a CRUD API for skills and professions (`/admin/api/v1/skills`, `/admin/api/v1/professions`) including per-entry YAML endpoints, gated behind a new `skills.write` permission. - Support in-game hot-reload via `reload skills`. - Add `internal/skills/README.md` and `AGENTS.md` documenting the data model, progression, philosophy, and working rules.
1 parent b1ad778 commit fbcd2fc

111 files changed

Lines changed: 2200 additions & 298 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

_datafiles/html/admin/skills-api.html

Lines changed: 282 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
{{template "header" .}}
2+
3+
<style>
4+
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
5+
.subtitle { color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.9rem; }
6+
7+
.page-layout { display: grid; grid-template-columns: 260px 1fr; gap: 1.25rem; align-items: start; }
8+
@media (max-width: 800px) { .page-layout { grid-template-columns: 1fr; } }
9+
10+
.sidebar { background: var(--color-surface-white); border: 1px solid var(--color-border); border-radius: 6px; overflow: hidden; display: flex; flex-direction: column; }
11+
.sidebar-header { padding: 0.75rem 0.9rem; border-bottom: 1px solid var(--color-border-light); }
12+
.sidebar-header input[type="search"] { width: 100%; padding: 0.35rem 0.6rem; border: 1px solid var(--color-border-medium); border-radius: 4px; font-size: 0.85rem; }
13+
.entity-list { overflow-y: auto; max-height: 72vh; }
14+
.entity-row {
15+
padding: 0.5rem 0.9rem; cursor: pointer; border-bottom: 1px solid var(--color-border-faint);
16+
display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.875rem;
17+
}
18+
.entity-row:last-child { border-bottom: none; }
19+
.entity-row:hover { background: var(--color-row-hover); }
20+
.entity-row.active { background: var(--color-primary); color: var(--color-surface-white); }
21+
.entity-id { font-size: 0.75rem; color: var(--color-text-secondary); font-family: monospace; flex-shrink: 0; }
22+
.entity-row.active .entity-id { color: var(--color-active-id); }
23+
.no-entities { padding: 1.5rem; text-align: center; color: var(--color-text-faint); font-size: 0.85rem; }
24+
.btn-new { margin: 0.6rem 0.9rem; padding: 0.4rem 0.9rem; border: none; border-radius: 4px; background: var(--color-success-text); color: var(--color-surface-white); font-size: 0.85rem; font-weight: 600; cursor: pointer; display: block; text-align: center; }
25+
.btn-new:hover { background: var(--color-btn-new-hover); }
26+
27+
.editor-panel { background: var(--color-surface-white); border: 1px solid var(--color-border); border-radius: 6px; padding: 1.25rem; }
28+
.editor-placeholder { color: var(--color-text-placeholder); text-align: center; padding: 4rem 1rem; font-size: 0.9rem; }
29+
30+
.status-bar { display: none; padding: 0.55rem 0.9rem; border-radius: 4px; font-size: 0.85rem; margin-bottom: 1rem; }
31+
.status-bar.success { background: var(--color-success-bg); color: var(--color-success-text); display: block; }
32+
.status-bar.error { background: var(--color-error-bg); color: var(--color-error-text); display: block; }
33+
34+
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem 1.25rem; }
35+
.form-grid .span2 { grid-column: 1 / -1; }
36+
.field label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--color-text-muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.03em; }
37+
.field input[type="text"], .field textarea {
38+
width: 100%; padding: 0.4rem 0.6rem; border: 1px solid var(--color-border-medium); border-radius: 4px;
39+
font-size: 0.875rem; font-family: inherit; background: var(--color-surface-white);
40+
}
41+
.field input:focus, .field textarea:focus { outline: 2px solid var(--color-primary); outline-offset: 1px; border-color: transparent; }
42+
.field input[readonly] { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; }
43+
.field textarea { resize: vertical; min-height: 70px; }
44+
.field-hint { font-size: 0.75rem; color: var(--color-text-faint); margin-top: 0.2rem; }
45+
46+
.section-title { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-subtle); padding: 0.75rem 0 0.4rem; border-bottom: 1px solid var(--color-border-light); margin-bottom: 0.75rem; grid-column: 1 / -1; }
47+
48+
.slots-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 0.4rem; grid-column: 1 / -1; }
49+
.slot-item {
50+
display: flex; align-items: center; justify-content: center;
51+
gap: 0.4rem; font-size: 0.82rem; font-weight: 500;
52+
padding: 0.4rem 0.5rem; border-radius: 5px; cursor: pointer;
53+
border: 1px solid var(--color-border); background: var(--color-surface-alt); color: var(--color-text-tertiary);
54+
user-select: none; transition: background 0.12s, color 0.12s, border-color 0.12s;
55+
}
56+
.slot-item:hover { border-color: var(--color-text-placeholder); background: var(--color-border-faint); }
57+
.slot-item input[type="checkbox"] { display: none; }
58+
.slot-item.enabled {
59+
background: var(--color-primary); color: var(--color-surface-white); border-color: var(--color-primary);
60+
}
61+
62+
.btn-row { display: flex; gap: 0.6rem; margin-top: 1.25rem; grid-column: 1 / -1; }
63+
.btn-save { padding: 0.45rem 1.2rem; background: var(--color-primary); color: var(--color-surface-white); border: none; border-radius: 4px; font-size: 0.875rem; font-weight: 600; cursor: pointer; }
64+
.btn-save:hover { background: var(--color-primary-hover); }
65+
.btn-delete { padding: 0.45rem 1rem; background: var(--color-surface-white); color: var(--color-error-text); border: 1px solid var(--color-btn-delete-border); border-radius: 4px; font-size: 0.875rem; font-weight: 600; cursor: pointer; }
66+
.btn-delete:hover { background: var(--color-error-bg); }
67+
</style>
68+
<h1>Professions</h1>
69+
<p class="subtitle">View, create, edit and delete profession definitions. See the <a href="/admin/skills-api">API reference</a>.</p>
70+
71+
<div class="page-layout">
72+
<div class="sidebar">
73+
<div class="sidebar-header">
74+
<input type="search" id="profSearch" placeholder="Search professions…" oninput="filterProfs()" />
75+
</div>
76+
<div id="profList" class="entity-list">
77+
<div class="no-entities">Loading…</div>
78+
</div>
79+
<button class="btn-new" onclick="newProf()">+ New Profession</button>
80+
</div>
81+
82+
<div class="editor-panel">
83+
<div id="editorPlaceholder" class="editor-placeholder">Select a profession to edit, or click "New Profession".</div>
84+
<div id="editorForm" style="display:none">
85+
<div id="statusBar" class="status-bar"></div>
86+
<div class="form-grid">
87+
<div style="grid-column:1/-1;display:flex;align-items:baseline;gap:0.75rem;margin-bottom:0.25rem">
88+
<p class="section-title" id="editorTitle" style="margin:0;flex:1">Edit Profession</p>
89+
<button class="yaml-badge" id="yaml-view-btn" style="display:none" onclick="showYamlModal(this.dataset.url,'Profession YAML')">YAML</button>
90+
</div>
91+
92+
<div class="field">
93+
<label for="fProfId">Profession ID</label>
94+
<input type="text" id="fProfId" placeholder="treasure hunter" />
95+
<div class="field-hint">Lowercase, spaces allowed. Immutable after creation.</div>
96+
</div>
97+
<div class="field">
98+
<label for="fName">Name</label>
99+
<input type="text" id="fName" placeholder="Treasure Hunter" />
100+
</div>
101+
102+
<div class="field span2">
103+
<label for="fDesc">Description</label>
104+
<textarea id="fDesc" rows="2" placeholder="A description of this profession."></textarea>
105+
</div>
106+
107+
<div class="section-title">Skills</div>
108+
<div class="field-hint span2" style="grid-column:1/-1; margin-bottom:0.5rem;">Click a skill to toggle it. Highlighted skills belong to this profession.</div>
109+
<div class="slots-grid" id="skillsGrid"></div>
110+
111+
<div class="btn-row">
112+
<button class="btn-save" onclick="saveProf()">Save</button>
113+
<button class="btn-delete" id="btnDelete" onclick="deleteProf()" style="display:none">Delete</button>
114+
</div>
115+
</div>
116+
</div>
117+
</div>
118+
</div>
119+
120+
<script>
121+
let profsData = {}; // id(string) -> Profession
122+
let allSkillIds = []; // sorted skill ids
123+
let activeProfId = null;
124+
let isNew = false;
125+
126+
async function loadProfs() {
127+
const [res, skillsRes] = await AdminAPI.all([
128+
AdminAPI.get('/admin/api/v1/professions'),
129+
AdminAPI.get('/admin/api/v1/skills'),
130+
]);
131+
if (!res.ok) { console.error('Failed to load professions:', res.error); return; }
132+
profsData = (res.data && res.data.data) || {};
133+
if (skillsRes.ok) {
134+
const skillsMap = (skillsRes.data && skillsRes.data.data) || {};
135+
allSkillIds = Object.keys(skillsMap).sort();
136+
buildSkillsGrid();
137+
}
138+
renderList();
139+
const hashId = decodeURIComponent(location.hash.slice(1));
140+
if (hashId && profsData[hashId]) selectProf(hashId);
141+
}
142+
143+
function filterProfs() {
144+
const q = document.getElementById('profSearch').value.toLowerCase();
145+
document.querySelectorAll('.entity-row').forEach(row => {
146+
const name = (row.dataset.name || '').toLowerCase();
147+
row.style.display = name.includes(q) ? '' : 'none';
148+
});
149+
}
150+
151+
function renderList() {
152+
const list = document.getElementById('profList');
153+
const ids = Object.keys(profsData).sort();
154+
if (ids.length === 0) {
155+
list.innerHTML = '<div class="no-entities">No professions found.</div>';
156+
return;
157+
}
158+
list.innerHTML = ids.map(id => {
159+
const p = profsData[id];
160+
return `<div class="entity-row" data-id="${escAttr(id)}" data-name="${escAttr(id + ' ' + (p.Name || ''))}"
161+
onclick="selectProf('${escAttr(id)}')">
162+
<span>${escHtml(p.Name || id)}</span>
163+
</div>`;
164+
}).join('');
165+
if (activeProfId !== null) highlightRow(activeProfId);
166+
}
167+
168+
function highlightRow(id) {
169+
document.querySelectorAll('.entity-row').forEach(r => r.classList.remove('active'));
170+
const row = document.querySelector(`.entity-row[data-id="${cssEsc(id)}"]`);
171+
if (row) row.classList.add('active');
172+
}
173+
174+
function selectProf(id) {
175+
activeProfId = id;
176+
isNew = false;
177+
location.hash = encodeURIComponent(id);
178+
highlightRow(id);
179+
populateForm(profsData[id]);
180+
}
181+
182+
function newProf() {
183+
activeProfId = null;
184+
isNew = true;
185+
document.querySelectorAll('.entity-row').forEach(r => r.classList.remove('active'));
186+
populateForm(null);
187+
}
188+
189+
function buildSkillsGrid() {
190+
const grid = document.getElementById('skillsGrid');
191+
if (!grid) return;
192+
grid.innerHTML = '';
193+
for (const id of allSkillIds) {
194+
const lbl = document.createElement('label');
195+
lbl.className = 'slot-item';
196+
lbl.setAttribute('onclick', 'toggleSkill(this)');
197+
lbl.innerHTML = '<input type="checkbox" class="skill-cb" value="' + escAttr(id) + '" /> ' + escHtml(id);
198+
grid.appendChild(lbl);
199+
}
200+
}
201+
202+
function toggleSkill(label) {
203+
const cb = label.querySelector('.skill-cb');
204+
cb.checked = !cb.checked;
205+
label.classList.toggle('enabled', cb.checked);
206+
}
207+
208+
function populateForm(prof) {
209+
document.getElementById('editorPlaceholder').style.display = 'none';
210+
document.getElementById('editorForm').style.display = '';
211+
document.getElementById('statusBar').className = 'status-bar';
212+
document.getElementById('statusBar').textContent = '';
213+
document.getElementById('btnDelete').style.display = isNew ? 'none' : '';
214+
document.getElementById('editorTitle').textContent = isNew ? 'New Profession' : `Edit: ${prof ? prof.Name : ''}`;
215+
216+
const idInput = document.getElementById('fProfId');
217+
idInput.readOnly = !isNew;
218+
219+
var yamlBtn = document.getElementById('yaml-view-btn');
220+
if (!isNew && activeProfId != null) {
221+
yamlBtn.dataset.url = '/admin/api/v1/professions/' + encodeURIComponent(activeProfId) + '/yaml';
222+
yamlBtn.style.display = '';
223+
} else {
224+
yamlBtn.style.display = 'none';
225+
}
226+
227+
const p = prof || {};
228+
idInput.value = p.ProfessionId || '';
229+
document.getElementById('fName').value = p.Name || '';
230+
document.getElementById('fDesc').value = p.Description || '';
231+
232+
const selected = new Set((p.Skills || []).map(s => s.toLowerCase()));
233+
document.querySelectorAll('.skill-cb').forEach(cb => {
234+
cb.checked = selected.has(cb.value);
235+
cb.closest('.slot-item').classList.toggle('enabled', cb.checked);
236+
});
237+
}
238+
239+
function buildPayload() {
240+
const skills = [];
241+
document.querySelectorAll('.skill-cb:checked').forEach(cb => skills.push(cb.value));
242+
return {
243+
ProfessionId: document.getElementById('fProfId').value.trim().toLowerCase(),
244+
Name: document.getElementById('fName').value.trim(),
245+
Description: document.getElementById('fDesc').value.trim(),
246+
Skills: skills,
247+
};
248+
}
249+
250+
async function saveProf() {
251+
const payload = buildPayload();
252+
if (!payload.ProfessionId) { showStatus('error', 'Profession ID is required.'); return; }
253+
if (!payload.Name) { showStatus('error', 'Name is required.'); return; }
254+
if (payload.Skills.length === 0) { showStatus('error', 'Select at least one skill.'); return; }
255+
256+
let res;
257+
if (isNew) {
258+
res = await AdminAPI.post('/admin/api/v1/professions', payload);
259+
if (res.ok && res.data && res.data.data) {
260+
const id = res.data.data.ProfessionId;
261+
profsData[id] = res.data.data;
262+
activeProfId = id;
263+
isNew = false;
264+
location.hash = encodeURIComponent(id);
265+
renderList();
266+
populateForm(profsData[id]);
267+
showStatus('success', `Profession "${id}" created.`);
268+
} else {
269+
showStatus('error', res.error || 'Save failed.');
270+
}
271+
} else {
272+
res = await AdminAPI.patch(`/admin/api/v1/professions/${encodeURIComponent(activeProfId)}`, payload);
273+
if (res.ok) {
274+
profsData[activeProfId] = (res.data && res.data.data) || payload;
275+
renderList();
276+
highlightRow(activeProfId);
277+
document.getElementById('editorTitle').textContent = `Edit: ${payload.Name}`;
278+
showStatus('success', `Profession "${activeProfId}" saved.`);
279+
} else {
280+
showStatus('error', res.error || 'Save failed.');
281+
}
282+
}
283+
}
284+
285+
async function deleteProf() {
286+
if (activeProfId === null) return;
287+
if (!confirm(`Delete profession "${activeProfId}"? This cannot be undone.`)) return;
288+
const res = await AdminAPI.delete(`/admin/api/v1/professions/${encodeURIComponent(activeProfId)}`);
289+
if (res.ok) {
290+
delete profsData[activeProfId];
291+
activeProfId = null;
292+
history.replaceState(null, '', location.pathname + location.search);
293+
renderList();
294+
document.getElementById('editorForm').style.display = 'none';
295+
document.getElementById('editorPlaceholder').style.display = '';
296+
} else {
297+
showStatus('error', res.error || 'Delete failed.');
298+
}
299+
}
300+
301+
function showStatus(type, msg) {
302+
const bar = document.getElementById('statusBar');
303+
bar.className = 'status-bar ' + type;
304+
bar.textContent = msg;
305+
if (type === 'success') setTimeout(() => { bar.className = 'status-bar'; }, 3000);
306+
}
307+
308+
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
309+
function escAttr(s) { return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
310+
function cssEsc(s) { return String(s).replace(/"/g,'\\"'); }
311+
312+
loadProfs();
313+
</script>
314+
315+
{{template "footer" .}}

0 commit comments

Comments
 (0)