Add NPC editor to admin page
Build and publish frontend / publish (push) Successful in 58s

This commit is contained in:
Ein Anderssono
2026-10-22 00:04:31 +02:00
parent 9a8eb9ae85
commit 196ff1429b
3 changed files with 125 additions and 1 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ The dialogue also offers **Gör om senaste** and **Torrkör senaste**. Both rese
If the player asks to find or approach someone and no NPC is present, the direct dialogue scene may propose one person and an opening line. Rust creates that NPC only for the explicit request and commits the first exchange idempotently; a later turn continues the saved conversation. Clear requests about the current location are answered from its saved facts without asking the player to restate what they want to inspect.
The separate `/admin` route provides an authenticated operations view for player search/profile/trace, YAML prompt assets, generated Luau rules with validation results, runtime debug logging, and the append-only audit log. All privileged data comes from the Rust `/api/admin/...` API; the browser never connects directly to PostgreSQL. Backend endpoints require `accounts.is_admin=true`. The Driftloggar view lets an administrator switch `debug=true` on or off without restarting pods; the setting is stored in PostgreSQL and propagated to running replicas. Verbose pod logs include request, AI-call, validation, and commit metadata but never raw player text, prompts, model responses, sessions, or secrets. Full protected prompt/provider exchanges remain in the audited player trace.
The separate `/admin` route provides an authenticated operations view for player search/profile/trace, NPC selection and editing, YAML prompt assets, generated Luau rules with validation results, runtime debug logging, and the append-only audit log. NPC editing covers identity and voice, typed profile fields plus future JSON properties, existing memory reconstructions, and owned items. The UI displays current position and commitments for context. Every NPC read and write goes through Rust, checks `accounts.is_admin=true`, validates domain limits, and writes an audit record in the same transaction as each mutation; the browser never connects directly to PostgreSQL. Profile and memory writes use expected-current values to detect stale edits. The Driftloggar view lets an administrator switch `debug=true` on or off without restarting pods; the setting is stored in PostgreSQL and propagated to running replicas. Verbose pod logs include request, AI-call, validation, and commit metadata but never raw player text, prompts, model responses, sessions, or secrets. Full protected prompt/provider exchanges remain in the audited player trace.
The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced. A clearly labeled **Skicka** button beside the microphone submits typed text; Enter also submits while Shift+Enter inserts a newline. Typed text, speech transcriptions, and the send button all use the same submission flow.
+116
View File
@@ -104,6 +104,7 @@
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
<nav id="admin-toolbar" aria-label="Administrationsvyer">
<button type="button" data-admin-view="players">Spelare</button>
<button type="button" data-admin-view="npcs">NPC:er</button>
<button type="button" data-admin-view="prompts">Prompter</button>
<button type="button" data-admin-view="rules">Luau-regler</button>
<button type="button" data-admin-view="logging">Driftloggar</button>
@@ -650,6 +651,120 @@
}
}
}
async function loadAdminNpcs(query = '', offset = 0) {
adminContent.replaceChildren();
const form = adminElement('form');
const label = adminElement('label', 'Sök NPC eller värld', form);
const input = document.createElement('input'); input.type = 'search'; input.maxLength = 100; input.value = query; label.append(input);
const search = adminElement('button', 'Sök', form); search.type = 'submit';
form.addEventListener('submit', event => { event.preventDefault(); loadAdminNpcs(input.value, 0); });
const result = await request(`/api/admin/npcs?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`);
adminElement('p', `${result.total} NPC:er. Välj en för att granska och redigera profil, minne och ägodelar.`);
const list = adminElement('ul');
for (const npc of result.npcs) {
const item = adminElement('li', undefined, list);
const button = adminElement('button', `${npc.name} · ${npc.world} · ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}`, item);
button.type = 'button'; button.addEventListener('click', () => openAdminNpc(npc.id));
}
const paging = adminElement('div');
const previous = adminElement('button', 'Föregående sida', paging); previous.type = 'button'; previous.disabled = offset <= 0; previous.addEventListener('click', () => loadAdminNpcs(query, Math.max(0, offset - 50)));
const next = adminElement('button', 'Nästa sida', paging); next.type = 'button'; next.disabled = offset + result.npcs.length >= result.total; next.addEventListener('click', () => loadAdminNpcs(query, offset + 50));
}
async function openAdminNpc(npcId) {
adminContent.replaceChildren();
const back = adminElement('button', 'Tillbaka till NPC-listan'); back.type = 'button'; back.addEventListener('click', () => loadAdminNpcs());
const npc = await request(`/api/admin/npcs/${encodeURIComponent(npcId)}`);
adminElement('h2', `${npc.name} · ${npc.world}`);
adminElement('p', `Plats: ${npc.location}${npc.scene ? ` · ${npc.scene}` : ''}. ID och position visas som referens och ändras inte i den här vyn.`);
adminElement('h3', 'Namn, bakgrund och röstidentitet');
const identityForm = adminElement('form');
const nameLabel = adminElement('label', 'Namn ', identityForm);
const nameInput = document.createElement('input'); nameInput.required = true; nameInput.maxLength = 120; nameInput.value = npc.name; nameLabel.append(nameInput);
const biographyLabel = adminElement('label', 'Bakgrund ', identityForm);
const biographyInput = document.createElement('textarea'); biographyInput.required = true; biographyInput.maxLength = 4000; biographyInput.rows = 5; biographyInput.value = npc.biography; biographyLabel.append(biographyInput);
const voiceLabel = adminElement('label', 'Röstidentitet ', identityForm);
const voiceSelect = document.createElement('select');
for (const value of ['masculine', 'feminine', 'neutral']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === npc.voice_identity; voiceSelect.append(option); }
voiceLabel.append(voiceSelect);
const identitySave = adminElement('button', 'Spara identitet', identityForm); identitySave.type = 'submit';
const identityStatus = adminElement('p'); identityStatus.setAttribute('role', 'status');
identityForm.addEventListener('submit', async event => {
event.preventDefault(); identitySave.disabled = true;
try {
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/identity`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_name: npc.name, expected_biography: npc.biography, expected_voice_identity: npc.voice_identity, name: nameInput.value, biography: biographyInput.value, voice_identity: voiceSelect.value})});
identityStatus.textContent = 'Identiteten sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
} catch (error) { identityStatus.textContent = `Identiteten sparades inte: ${error.message}`; }
finally { identitySave.disabled = false; }
});
adminElement('h3', 'Egenskaper och framtida profilfält');
adminElement('p', 'Redigera profilens JSON. Kända spelvärden valideras av Rust; okända framtida egenskaper bevaras som JSON. Värld och position visas som referens och ändras inte här.');
const profileForm = adminElement('form');
const profileLabel = adminElement('label', 'NPC-profil i JSON', profileForm);
const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, 2); profileLabel.append(profileInput);
const profileSave = adminElement('button', 'Spara egenskaper', profileForm); profileSave.type = 'submit';
const profileStatus = adminElement('p'); profileStatus.setAttribute('role', 'status');
profileForm.addEventListener('submit', async event => {
event.preventDefault(); profileSave.disabled = true;
try {
const profile = JSON.parse(profileInput.value);
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/profile`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_profile: npc.profile, profile})});
profileStatus.textContent = 'Egenskaperna sparades och revisionsloggades. Ladda om NPC:n för att hämta senaste version.';
} catch (error) { profileStatus.textContent = `Egenskaperna sparades inte: ${error.message}`; }
finally { profileSave.disabled = false; }
});
adminElement('h3', 'Ägodelar');
const possessionsForm = adminElement('form');
const possessionsLabel = adminElement('label', 'En ägodel per rad: namn | antal', possessionsForm);
const possessionsInput = document.createElement('textarea'); possessionsInput.rows = 8; possessionsInput.cols = 60; possessionsInput.value = npc.possessions.map(item => `${item.name} | ${item.quantity}`).join('\n'); possessionsLabel.append(possessionsInput);
const possessionsSave = adminElement('button', 'Ersätt ägodelar', possessionsForm); possessionsSave.type = 'submit';
const possessionsStatus = adminElement('p'); possessionsStatus.setAttribute('role', 'status');
possessionsForm.addEventListener('submit', async event => {
event.preventDefault(); possessionsSave.disabled = true;
try {
const possessions = possessionsInput.value.split('\n').map(line => line.trim()).filter(Boolean).map(line => { const [name, amount] = line.split('|'); return {name: name?.trim() || '', quantity: Number(amount?.trim() || 1)}; });
const expected = npc.possessions.map(item => ({name: item.name, quantity: item.quantity}));
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/possessions`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected, possessions})});
possessionsStatus.textContent = 'Ägodelarna ersattes och revisionsloggades.';
} catch (error) { possessionsStatus.textContent = `Ägodelarna sparades inte: ${error.message}`; }
finally { possessionsSave.disabled = false; }
});
adminElement('h3', 'Minnesrekonstruktioner');
adminElement('p', 'Varje ändring behåller minnespostens källa och läggs i revisionsloggen. Ändringar påverkar NPC:ns aktiva minne direkt.');
if (npc.memories.length === 0) adminElement('p', 'Inga sparade minnen för den här NPC:n.');
for (const memory of npc.memories) {
const section = adminElement('section');
adminElement('h4', `${memory.character_name} · ${memory.layer} · ${memory.active ? 'aktivt' : 'inaktivt'} · ${memory.visibility}`, section);
adminElement('p', `Källa ${memory.source_event_id}; informationsväg ${memory.information_source}; prioritet ${memory.priority}.`, section);
const form = adminElement('form', undefined, section);
const label = adminElement('label', 'Minnestext', form);
const text = document.createElement('textarea'); text.rows = 4; text.cols = 80; text.required = true; text.maxLength = 3000; text.value = memory.text; label.append(text);
const priorityLabel = adminElement('label', ' Prioritet 0–100 ', form);
const priority = document.createElement('input'); priority.type = 'number'; priority.min = '0'; priority.max = '100'; priority.value = memory.priority; priorityLabel.append(priority);
const activeLabel = adminElement('label', undefined, form);
const active = document.createElement('input'); active.type = 'checkbox'; active.checked = memory.active; activeLabel.append(active, document.createTextNode(' Aktivt minne'));
const visibilityLabel = adminElement('label', ' Synlighet ', form);
const visibility = document.createElement('select');
for (const value of ['private', 'confidential', 'shareable']) { const option = document.createElement('option'); option.value = value; option.textContent = value; option.selected = value === memory.visibility; visibility.append(option); }
visibilityLabel.append(visibility);
const save = adminElement('button', 'Spara minnesändring', form); save.type = 'submit';
const status = adminElement('p', undefined, section); status.setAttribute('role', 'status');
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
try {
await request(`/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURIComponent(memory.id)}`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify({expected_text: memory.text, expected_priority: memory.priority, expected_active: memory.active, expected_visibility: memory.visibility, text: text.value, priority: Number(priority.value), active: active.checked, visibility: visibility.value})});
status.textContent = 'Minnet sparades och revisionsloggades. Ladda om NPC:n för senaste version.';
} catch (error) { status.textContent = `Minnet sparades inte: ${error.message}`; }
finally { save.disabled = false; }
});
}
adminElement('h3', 'Löften och åtaganden');
if (npc.commitments.length === 0) adminElement('p', 'Inga registrerade åtaganden.');
for (const commitment of npc.commitments) adminJson(`${commitment.status} · ${commitment.created_at}`, commitment);
}
async function loadAdminPrompts() {
adminContent.replaceChildren();
const result = await request('/api/admin/prompts');
@@ -1243,6 +1358,7 @@
adminContent.setAttribute('aria-busy', 'true');
try {
if (button.dataset.adminView === 'players') await loadAdminPlayers();
else if (button.dataset.adminView === 'npcs') await loadAdminNpcs();
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
else if (button.dataset.adminView === 'rules') await loadAdminRules();
else if (button.dataset.adminView === 'logging') await loadAdminLogging();
+8
View File
@@ -105,6 +105,14 @@ grep -q "request('/api/admin/rules'" index.html
grep -q "request('/api/admin/settings/logging'" index.html
grep -q 'data-admin-view="logging"' index.html
grep -q '/api/admin/audit?limit=200' index.html
grep -q 'data-admin-view="npcs"' index.html
grep -q '/api/admin/npcs?q=' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/profile' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/identity' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/memories/${encodeURIComponent(memory.id)}' index.html
grep -q '/api/admin/npcs/${encodeURIComponent(npcId)}/possessions' index.html
grep -q 'Egenskaper och framtida profilfält' index.html
grep -q 'Minnesrekonstruktioner' index.html
grep -q 'entry.data.external_call' index.html
grep -q 'Externt tjänsteanrop' index.html
grep -q "request('/api/world/start'" index.html