Compare commits

...
23 Commits
Author SHA1 Message Date
Gitea Actions 89d5588cd6 Release 0.1.78 [auto-version-bump] 2026-10-24 03:45:45 +02:00
Ein Anderssono 29c5abe355 Add visual graph map to admin
Build and publish frontend / publish (push) Successful in 1m15s
2026-10-24 03:45:07 +02:00
Gitea Actions 6680dd88d6 Release 0.1.77 [auto-version-bump] 2026-10-24 03:38:12 +02:00
Ein Anderssono 652afb7673 Pretty print fenced JSON in admin AI replies
Build and publish frontend / publish (push) Successful in 1m14s
2026-10-24 03:37:36 +02:00
Gitea Actions 1ba98b998d Release 0.1.76 [auto-version-bump] 2026-10-24 03:34:07 +02:00
Ein Anderssono 23d7527e4f Decode escaped text in admin JSON views
Build and publish frontend / publish (push) Successful in 53s
2026-10-24 03:33:38 +02:00
Gitea Actions 47466f4176 Release 0.1.75 [auto-version-bump] 2026-10-24 03:30:21 +02:00
Ein Anderssono 06dea70ed1 Add collapsible JSON tree to admin views
Build and publish frontend / publish (push) Successful in 54s
2026-10-24 03:29:54 +02:00
Gitea Actions 4101be2447 Release 0.1.74 [auto-version-bump] 2026-10-24 03:26:39 +02:00
Ein Anderssono f54f672104 Match JSON indentation source literally
Build and publish frontend / publish (push) Successful in 55s
2026-10-24 03:26:12 +02:00
Ein Anderssono 0010743024 Update source checks for formatted JSON
Build and publish frontend / publish (push) Failing after 3s
2026-10-24 03:24:52 +02:00
Ein Anderssono a746c29acb Make admin JSON responses easier to scan
Build and publish frontend / publish (push) Failing after 2s
2026-10-24 03:23:37 +02:00
Gitea Actions 6a0c2fb22c Release 0.1.73 [auto-version-bump] 2026-10-24 03:17:05 +02:00
Ein Anderssono 50ec861bba Show player turns in admin reading view
Build and publish frontend / publish (push) Successful in 57s
2026-10-24 03:16:38 +02:00
Gitea Actions d6469b6a3a Release 0.1.72 [auto-version-bump] 2026-10-24 02:57:08 +02:00
Ein Anderssono 51ab6dd23f Update trace pagination check for GraphQL
Build and publish frontend / publish (push) Successful in 1m2s
2026-10-24 02:56:36 +02:00
Ein Anderssono 344996233f Read game conversations through GraphQL
Build and publish frontend / publish (push) Failing after 3s
2026-10-24 02:56:06 +02:00
Gitea Actions 2531142517 Release 0.1.71 [auto-version-bump] 2026-10-23 22:35:28 +02:00
Ein Anderssono 4405307c99 Add storyteller-only admin reading view
Build and publish frontend / publish (push) Successful in 56s
2026-10-23 22:35:02 +02:00
Gitea Actions c38be1562e Release 0.1.70 [auto-version-bump] 2026-10-23 22:31:38 +02:00
Ein Anderssono 11fc44351a Add account conversation tracing to admin UI
Build and publish frontend / publish (push) Successful in 55s
2026-10-23 22:31:08 +02:00
Gitea Actions 66cc7f8628 Release 0.1.69 [auto-version-bump] 2026-10-22 00:05:02 +02:00
Ein Anderssono 196ff1429b Add NPC editor to admin page
Build and publish frontend / publish (push) Successful in 58s
2026-10-22 00:04:31 +02:00
6 changed files with 1004 additions and 36 deletions
+2 -2
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.
@@ -26,7 +26,7 @@ System voice commands require the prefix `röstkommando`: `upprepa`, `avbryt`, `
On iPhone and iPad, Safari requires a user gesture to unlock Web Audio. The frontend resumes the audio context during a trusted tap or key press and then reads generated replies automatically. Voice settings include **Testa ljudet**; if a game reply does not start, a **Läs senaste repliken** button appears in the dialogue. First use can take longer while the voice model downloads; progress and errors are announced in the dialogue. Actual iPhone/iPad playback remains a release acceptance check and must not be claimed until tested on a device.
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/graphql`, `/version`, and `/health` to the Rust service. Player dialogue is read through the authenticated, cursor-paginated GraphQL `dialogue(first, before)` query. Admin conversation traces use the audited `adminPlayerTrace(accountId, limit, offset)` GraphQL query. Writes remain explicit Rust command routes. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
The public footer displays the frontend, static-service, and API versions. It fetches `/static/version.json` without caching; the static service creates that file from its sole `VERSION` source during the same image build used for the matching Helm chart and release tag.
+1 -1
View File
@@ -1 +1 @@
0.1.68
0.1.78
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator
type: application
version: 0.1.68
appVersion: "0.1.68"
version: 0.1.78
appVersion: "0.1.78"
+851 -28
View File
@@ -27,7 +27,84 @@
#dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
#admin-content { display: grid; gap: 1rem; }
#admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
#admin-content article > button { display: block; max-width: 100%; margin-block: .5rem; text-align: start; white-space: normal; }
#admin-content article > .admin-story-reply { display: none; }
#admin-content .admin-simple-conversation > h2,
#admin-content .admin-simple-conversation > p:not(.admin-story-empty),
#admin-content .admin-simple-conversation article > :not(.admin-story-player):not(.admin-story-reply) { display: none; }
#admin-content .admin-simple-conversation article > .admin-story-player,
#admin-content .admin-simple-conversation article > .admin-story-reply { display: block; margin-block: .85rem; white-space: pre-wrap; }
#admin-content .admin-simple-conversation article > .admin-story-player { font-weight: 650; }
#admin-content pre, #debug-trace pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
.json-view { box-sizing: border-box; margin-block: .65rem; padding: .65rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; font: .88rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
.json-tree-tools { display: flex; flex-wrap: wrap; gap: .4rem; margin-block-end: .6rem; }
.json-tree-tools button { font-size: .88rem; padding: .3rem .55rem; }
.json-tree, .json-children { display: grid; gap: .3rem; }
.json-children { margin-inline-start: .7rem; padding-inline-start: .65rem; border-inline-start: 2px solid #cbd5e1; }
.json-node { min-width: 0; }
.json-node > summary { cursor: pointer; padding: .3rem .45rem; border-radius: .25rem; background: #e2e8f0; overflow-wrap: anywhere; }
.json-node > summary:focus-visible, .json-tree-tools button:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
.json-label { color: #075985; font-weight: 750; }
.json-kind { color: #475569; font-size: .86em; }
.json-entry { display: grid; grid-template-columns: minmax(8rem, .35fr) minmax(0, 1fr); gap: .5rem; padding: .2rem .45rem; border-radius: .2rem; overflow-wrap: anywhere; }
.json-entry:nth-child(even) { background: #f1f5f9; }
.json-key { color: #075985; font-weight: 700; }
.json-value-string { color: #166534; white-space: pre-wrap; }
.json-value-number { color: #6d28d9; font-weight: 650; }
.json-value-boolean { color: #b45309; font-weight: 750; }
.json-value-null { color: #be123c; font-style: italic; }
.json-empty { padding: .25rem .65rem; color: #475569; font-style: italic; }
.json-long-value { border-inline-start: 2px solid #94a3b8; margin-inline-start: .5rem; }
.json-long-value > summary, .json-raw-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; }
.json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; overflow-wrap: anywhere; }
.json-raw-value { margin-inline-start: .5rem; color: #475569; font-size: .9em; }
.json-raw-value > .json-long-text { border-inline-start: 2px solid #94a3b8; }
.admin-ai-response { margin: .35rem 0 .75rem; }
.admin-ai-response-text { white-space: pre-wrap; overflow-wrap: anywhere; }
.admin-ai-response-code { max-width: 100%; overflow: auto; padding: .65rem; border-inline-start: 2px solid #94a3b8; white-space: pre-wrap; overflow-wrap: anywhere; }
.world-map-controls { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; }
.world-map-scroll { max-width: 100%; overflow: auto; border: 1px solid #94a3b8; border-radius: .4rem; background: #f8fafc; }
.world-map-svg { display: block; color: #0f172a; font: 14px/1.4 system-ui, sans-serif; }
.world-map-location-frame { fill: #eff6ff; stroke: #64748b; stroke-dasharray: 5 4; }
.world-map-edge { stroke: #64748b; stroke-width: 2.5; marker-end: url(#world-map-arrow); }
.world-map-edge.is-closed { stroke: #b91c1c; stroke-dasharray: 6 4; }
.world-map-edge.is-structural { stroke: #94a3b8; stroke-dasharray: 4 5; marker-end: none; }
.world-map-node rect { fill: #fff; stroke: #334155; stroke-width: 2; rx: 9; }
.world-map-node.is-location rect { fill: #dbeafe; stroke: #1d4ed8; }
.world-map-node.is-blocked rect { fill: #fee2e2; stroke: #b91c1c; }
.world-map-node a { color: inherit; text-decoration: none; }
.world-map-node a:focus rect, .world-map-node a:hover rect { stroke: #7c3aed; stroke-width: 4; }
.world-map-node-title { fill: currentColor; font-weight: 750; }
.world-map-node-kind, .world-map-node-description { fill: #334155; font-size: 12px; }
.world-map-legend { display: flex; flex-wrap: wrap; gap: .75rem; }
.world-map-legend span { border-inline-start: 1rem solid #64748b; padding-inline-start: .35rem; }
.world-map-legend .route { border-color: #15803d; }
.world-map-legend .closed { border-color: #b91c1c; }
.world-map-legend .structural { border-color: #94a3b8; border-inline-start-style: dashed; }
.world-map-destinations { padding-inline-start: 1.5rem; }
@media (prefers-color-scheme: dark) {
.world-map-scroll { background: #111827; }
.world-map-svg { color: #f8fafc; }
.world-map-location-frame { fill: #172033; stroke: #64748b; }
.world-map-node rect { fill: #1f2937; stroke: #94a3b8; }
.world-map-node.is-location rect { fill: #172554; stroke: #60a5fa; }
.world-map-node.is-blocked rect { fill: #450a0a; stroke: #f87171; }
.world-map-node-kind, .world-map-node-description { fill: #cbd5e1; }
}
@media (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } }
@media (prefers-color-scheme: dark) {
.json-view { border-color: #475569; background: #111827; color: #e5e7eb; }
.json-children { border-inline-start-color: #475569; }
.json-node > summary { background: #263244; }
.json-label, .json-key { color: #7dd3fc; }
.json-kind, .json-empty { color: #cbd5e1; }
.json-entry:nth-child(even), .json-long-text { background: #1f2937; }
.json-value-string { color: #86efac; }
.json-value-number { color: #c4b5fd; }
.json-value-boolean { color: #fcd34d; }
.json-value-null { color: #fda4af; }
.json-raw-value { color: #cbd5e1; }
}
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
body.debug-layout { max-width: none; margin: 0; padding: .5rem; height: 100vh; overflow: hidden; }
@@ -104,6 +181,8 @@
<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="world-map">Världskarta</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>
@@ -165,6 +244,8 @@
let voiceDisclosureAcknowledged = false;
let adminOffset = 0;
const adminPageSize = 50;
let adminTraceOffset = 0;
let adminSimpleConversation = false;
let presenceTimer = null;
let playerMessagesPrimed = false;
const seenPlayerMessageIds = new Set();
@@ -175,6 +256,22 @@
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
return body;
}
async function graphqlQuery(query, variables = {}) {
const response = await request('/graphql', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({query, variables}),
});
if (response.errors?.length) throw new Error(response.errors[0].message || 'GraphQL-förfrågan misslyckades');
return response.data;
}
async function getAdminTrace(accountId = null, limit = 200, offset = 0) {
const data = await graphqlQuery(
'query AdminPlayerTrace($accountId: String, $limit: Int!, $offset: Int!) { adminPlayerTrace(accountId: $accountId, limit: $limit, offset: $offset) }',
{accountId, limit, offset},
);
return data.adminPlayerTrace;
}
function appendLine(role, content, options = {}) {
const line = document.createElement('li');
line.dataset.role = role;
@@ -595,12 +692,441 @@
parent.append(element);
return element;
}
function decodeEscapedSequences(text) {
const source = String(text).replace(/\\(?:\r\n|\n|\r)[ \t]*/g, '');
const escapes = {
'0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\x0b',
f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', "'": "'",
'/': '/', '\\': '\\', N: '\u0085', _: '\u00a0', L: '\u2028', P: '\u2029',
};
return source.replace(/\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (sequence, escape) => {
if (Object.hasOwn(escapes, escape)) return escapes[escape];
if (/^x[0-9a-fA-F]{2}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
if (/^u[0-9a-fA-F]{4}$/.test(escape)) return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
if (/^U[0-9a-fA-F]{8}$/.test(escape)) {
const point = Number.parseInt(escape.slice(1), 16);
if (point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)) return String.fromCodePoint(point);
}
return sequence;
});
}
function renderJsonTree(container, value) {
container.className = 'json-view';
container.setAttribute('aria-label', 'Utfällbart JSON-svar');
const tree = document.createElement('div');
tree.className = 'json-tree';
const nodeValues = new WeakMap();
const controls = document.createElement('div');
controls.className = 'json-tree-tools';
const expand = document.createElement('button');
expand.type = 'button';
expand.textContent = 'Öppna alla block';
const collapse = document.createElement('button');
collapse.type = 'button';
collapse.textContent = 'Fäll ihop nästlade block';
controls.append(expand, collapse);
container.replaceChildren(controls, tree);
const isGroup = item => item !== null && typeof item === 'object';
const groupSummary = item => Array.isArray(item)
? `Lista · ${item.length} ${item.length === 1 ? 'post' : 'poster'}`
: `Objekt · ${Object.keys(item).length} fält`;
const addRawText = (parent, value) => {
const raw = document.createElement('details');
raw.className = 'json-raw-value';
const rawSummary = document.createElement('summary');
rawSummary.textContent = 'Visa lagrad text med escape-tecken';
const rawText = document.createElement('div');
rawText.className = 'json-long-text';
rawText.textContent = value;
raw.append(rawSummary, rawText);
parent.append(raw);
};
const addLeaf = (parent, label, item) => {
const readable = typeof item === 'string' ? decodeEscapedSequences(item) : item;
if (typeof item === 'string' && item.length > 180) {
const details = document.createElement('details');
details.className = 'json-long-value';
const summary = document.createElement('summary');
summary.append(document.createTextNode(`${label ? `${label} · ` : ''}Text · ${item.length} tecken · `));
const preview = document.createElement('span');
preview.className = 'json-value-string';
const previewText = readable.slice(0, 100);
preview.textContent = `${previewText}${readable.length > 100 ? '…' : ''}`;
summary.append(preview);
const fullText = document.createElement('div');
fullText.className = 'json-long-text';
fullText.textContent = readable;
details.append(summary, fullText);
parent.append(details);
if (readable !== item) addRawText(parent, item);
return;
}
const row = document.createElement('div');
row.className = 'json-entry';
if (label) {
const key = document.createElement('span');
key.className = 'json-key';
key.textContent = label;
row.append(key);
}
const valueNode = document.createElement('span');
const type = item === null ? 'null' : typeof item;
valueNode.className = `json-value-${type === 'boolean' ? 'boolean' : type}`;
valueNode.textContent = typeof readable === 'string' ? readable : String(readable);
row.append(valueNode);
parent.append(row);
if (typeof item === 'string' && readable !== item) addRawText(parent, item);
};
const populate = (details, item, depth) => {
if (details.dataset.loaded === 'true') return;
details.dataset.loaded = 'true';
const children = document.createElement('div');
children.className = 'json-children';
if (depth >= 64) {
const note = document.createElement('div');
note.className = 'json-empty';
note.textContent = 'Ytterligare nästling döljs efter 64 nivåer.';
children.append(note);
} else {
const entries = Array.isArray(item)
? item.map((child, index) => [`[${index}]`, child])
: Object.entries(item);
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'json-empty';
empty.textContent = Array.isArray(item) ? 'Listan är tom.' : 'Objektet saknar fält.';
children.append(empty);
}
for (const [key, child] of entries) {
if (isGroup(child)) {
const nested = document.createElement('details');
nested.className = 'json-node';
nodeValues.set(nested, child);
nested.dataset.depth = String(depth + 1);
const nestedSummary = document.createElement('summary');
const label = document.createElement('span');
label.className = 'json-label';
label.textContent = key;
const kind = document.createElement('span');
kind.className = 'json-kind';
kind.textContent = ` · ${groupSummary(child)}`;
nestedSummary.append(label, kind);
nested.append(nestedSummary);
nested.addEventListener('toggle', () => {
if (nested.open) populate(nested, child, depth + 1);
});
children.append(nested);
} else {
addLeaf(children, key, child);
}
}
}
details.append(children);
};
if (isGroup(value)) {
const root = document.createElement('details');
root.className = 'json-node json-root';
root.open = true;
const summary = document.createElement('summary');
const label = document.createElement('span');
label.className = 'json-label';
label.textContent = 'Innehåll';
const kind = document.createElement('span');
kind.className = 'json-kind';
kind.textContent = ` · ${groupSummary(value)}`;
summary.append(label, kind);
root.append(summary);
root.addEventListener('toggle', () => {
if (root.open) populate(root, value, 0);
});
populate(root, value, 0);
tree.append(root);
expand.addEventListener('click', () => {
const openAll = details => {
details.open = true;
if (details === root || details.dataset.loaded === 'true') {
const childGroups = [...details.querySelectorAll(':scope > .json-children > details.json-node')];
for (const child of childGroups) {
if (child.dataset.loaded !== 'true') {
if (nodeValues.has(child)) populate(child, nodeValues.get(child), Number(child.dataset.depth || 1));
}
openAll(child);
}
}
};
openAll(root);
});
collapse.addEventListener('click', () => {
for (const nested of tree.querySelectorAll('details.json-node:not(.json-root)')) nested.open = false;
root.open = true;
});
} else {
addLeaf(tree, '', value);
expand.disabled = true;
collapse.disabled = true;
}
return container;
}
function adminJson(title, value, parent = adminContent) {
const details = adminElement('details', undefined, parent);
adminElement('summary', title, details);
adminElement('pre', JSON.stringify(value, null, 2), details);
renderJsonTree(adminElement('div', undefined, details), value);
return details;
}
async function loadAdminWorldMap() {
adminContent.replaceChildren();
adminElement('h2', 'Världskarta');
adminElement('p', 'Kartan visar lagrade platser, scener, beskrivningar och relationer. Heldragna gröna linjer är etablerade passager; streckade linjer visar struktur eller andra relationer. Rutnätet ordnar noderna och anger inte exakta avstånd.');
const map = await request('/api/admin/world-map');
const worlds = map.worlds || [];
if (worlds.length === 0) {
adminElement('p', 'Det finns inga världar att visa.');
return;
}
const controls = adminElement('div');
controls.className = 'world-map-controls';
const worldSelect = document.createElement('select');
worldSelect.setAttribute('aria-label', 'Välj värld att visa');
for (const world of worlds) {
const option = document.createElement('option');
option.value = world.id;
option.textContent = `${world.name || 'Namnlös värld'} · version ${world.version}`;
worldSelect.append(option);
}
if (worlds.length > 1) {
const label = adminElement('label', 'Värld', controls);
label.append(worldSelect);
} else {
adminElement('p', `${worlds[0].name || 'Namnlös värld'} · version ${worlds[0].version}`, controls);
}
const mapContent = adminElement('div');
worldSelect.addEventListener('change', () => renderWorld(worldSelect.value));
function renderWorld(worldId) {
mapContent.replaceChildren();
const world = worlds.find(item => item.id === worldId);
if (!world) return;
const locations = (map.locations || []).filter(item => item.world_id === worldId);
const locationIds = new Set(locations.map(item => item.id));
const nodes = (map.nodes || []).filter(item => item.world_id === worldId && locationIds.has(item.location_id));
const locationConnections = (map.location_connections || []).filter(item => item.world_id === worldId && locationIds.has(item.from_location_id) && locationIds.has(item.to_location_id));
const sceneRelations = (map.scene_relations || []).filter(item => item.world_id === worldId && locationIds.has(item.location_id));
adminElement('h3', `${world.name || 'Namnlös värld'} · ${locations.length} platser · ${nodes.length} scennoder`, mapContent);
adminElement('p', `Världstid ${world.world_time}. Noderna nedan har stabila ID:n. En passage visas som framkomlig bara om en etablerad rutt finns och dess ändpunkter inte är markerade som låsta eller blockerade.`, mapContent);
const entities = new Map();
const nodesById = new Map(nodes.map(item => [item.id, item]));
const nodesByLocation = new Map();
for (const node of nodes) {
if (!nodesByLocation.has(node.location_id)) nodesByLocation.set(node.location_id, []);
nodesByLocation.get(node.location_id).push(node);
}
locations.forEach((location, column) => {
const centerX = 190 + column * 350;
const locationAnchor = `world-map-location-${location.id}`;
entities.set(`location:${location.id}`, {
id: `location:${location.id}`, anchor: locationAnchor, name: location.name,
kind: 'Plats', description: location.description, x: centerX - 140, y: 54, width: 280, height: 76,
location, isLocation: true,
});
(nodesByLocation.get(location.id) || []).forEach((node, index) => {
entities.set(`scene:${node.id}`, {
id: `scene:${node.id}`, anchor: `world-map-node-${node.id}`, name: node.name,
kind: `${node.kind} · ${node.node_key}`, description: node.description,
x: centerX - 140, y: 164 + index * 112, width: 280, height: 88, node,
isBlocked: node.state?.locked === true || node.state?.blocked === true || node.state?.traversable === false,
});
});
});
const edges = [];
for (const connection of locationConnections) {
edges.push({
from: `location:${connection.from_location_id}`, to: `location:${connection.to_location_id}`,
label: connection.route_description, relation: 'platsförbindelse', traversable: true,
});
}
for (const location of locations) {
const area = (nodesByLocation.get(location.id) || []).find(node => node.node_key === 'area');
if (area) edges.push({from: `location:${location.id}`, to: `scene:${area.id}`, label: 'innehåller startscen', relation: 'structure'});
}
for (const node of nodes) {
if (node.parent_node_id && nodesById.has(node.parent_node_id)) {
edges.push({from: `scene:${node.parent_node_id}`, to: `scene:${node.id}`, label: 'ligger i', relation: 'structure'});
}
}
for (const relation of sceneRelations) {
const from = nodesById.get(relation.from_node_id);
const to = nodesById.get(relation.to_node_id);
if (!from || !to) continue;
const endpointsBlocked = [from, to].some(node => node.state?.locked === true || node.state?.blocked === true || node.state?.traversable === false);
edges.push({
from: `scene:${from.id}`, to: `scene:${to.id}`,
label: relation.route_description, relation: relation.relation,
traversable: relation.traversable === true,
blocked: relation.traversable === true && endpointsBlocked,
});
}
const maxSceneCount = Math.max(0, ...locations.map(location => (nodesByLocation.get(location.id) || []).length));
const width = Math.max(640, 380 + Math.max(0, locations.length - 1) * 350);
const height = Math.max(300, 184 + maxSceneCount * 112);
const scroll = adminElement('div', undefined, mapContent);
scroll.className = 'world-map-scroll';
const svgNs = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNs, 'svg');
svg.classList.add('world-map-svg');
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.setAttribute('role', 'group');
svg.setAttribute('aria-label', `Karta över ${world.name || 'världen'} med ${locations.length} platser och ${nodes.length} scennoder`);
const defs = document.createElementNS(svgNs, 'defs');
const marker = document.createElementNS(svgNs, 'marker');
marker.setAttribute('id', 'world-map-arrow'); marker.setAttribute('markerWidth', '8'); marker.setAttribute('markerHeight', '8');
marker.setAttribute('refX', '7'); marker.setAttribute('refY', '3'); marker.setAttribute('orient', 'auto');
const arrow = document.createElementNS(svgNs, 'path');
arrow.setAttribute('d', 'M0,0 L0,6 L8,3 z'); arrow.setAttribute('fill', '#64748b');
marker.append(arrow); defs.append(marker); svg.append(defs);
for (const location of locations) {
const frame = document.createElementNS(svgNs, 'rect');
const entity = entities.get(`location:${location.id}`);
const count = (nodesByLocation.get(location.id) || []).length;
frame.setAttribute('x', String(entity.x - 15)); frame.setAttribute('y', '144');
frame.setAttribute('width', '310'); frame.setAttribute('height', String(Math.max(130, 36 + count * 112)));
frame.setAttribute('rx', '14'); frame.classList.add('world-map-location-frame');
svg.append(frame);
}
const center = entity => ({x: entity.x + entity.width / 2, y: entity.y + entity.height / 2});
for (const edge of edges) {
const from = entities.get(edge.from); const to = entities.get(edge.to);
if (!from || !to) continue;
const start = center(from); const end = center(to);
const line = document.createElementNS(svgNs, 'line');
line.setAttribute('x1', String(start.x)); line.setAttribute('y1', String(start.y));
line.setAttribute('x2', String(end.x)); line.setAttribute('y2', String(end.y));
line.classList.add('world-map-edge');
if (edge.relation === 'structure' || !edge.traversable) line.classList.add('is-structural');
else if (edge.blocked) line.classList.add('is-closed');
const title = document.createElementNS(svgNs, 'title');
title.textContent = `${edge.relation}: ${from.name} → ${to.name}. ${edge.label || ''}`;
line.append(title); svg.append(line);
}
for (const entity of entities.values()) {
const group = document.createElementNS(svgNs, 'g');
group.classList.add('world-map-node');
if (entity.isLocation) group.classList.add('is-location');
if (entity.isBlocked) group.classList.add('is-blocked');
const anchor = document.createElementNS(svgNs, 'a');
anchor.setAttribute('href', `#${entity.anchor}`);
anchor.setAttribute('aria-label', `${entity.kind}: ${entity.name}. ${entity.description}`);
const rect = document.createElementNS(svgNs, 'rect');
rect.setAttribute('x', String(entity.x)); rect.setAttribute('y', String(entity.y));
rect.setAttribute('width', String(entity.width)); rect.setAttribute('height', String(entity.height));
const kind = document.createElementNS(svgNs, 'text');
kind.setAttribute('x', String(entity.x + 12)); kind.setAttribute('y', String(entity.y + 20));
kind.classList.add('world-map-node-kind'); kind.textContent = entity.kind;
const name = document.createElementNS(svgNs, 'text');
name.setAttribute('x', String(entity.x + 12)); name.setAttribute('y', String(entity.y + 42));
name.classList.add('world-map-node-title'); name.textContent = entity.name;
const description = document.createElementNS(svgNs, 'text');
description.setAttribute('x', String(entity.x + 12)); description.setAttribute('y', String(entity.y + 63));
description.classList.add('world-map-node-description');
const descriptionText = entity.description || '';
description.textContent = descriptionText.length > 38 ? `${descriptionText.slice(0, 35)}…` : descriptionText;
anchor.append(rect, kind, name, description); group.append(anchor); svg.append(group);
}
scroll.append(svg);
const legend = adminElement('div', undefined, mapContent);
legend.className = 'world-map-legend';
for (const [className, label] of [['route', 'Framkomlig passage'], ['closed', 'Passage spärrad av nodens tillstånd'], ['structural', 'Struktur eller ej passerbar relation']]) {
const item = adminElement('span', label, legend); item.className = className;
}
const destinationLinks = (parent, originId, candidates) => {
const list = adminElement('ul', undefined, parent); list.className = 'world-map-destinations';
let count = 0;
for (const edge of candidates) {
if (edge.from !== originId || edge.relation === 'structure') continue;
const destination = entities.get(edge.to);
if (!destination) continue;
count += 1;
const item = adminElement('li', undefined, list);
if (edge.traversable && !edge.blocked) {
const link = document.createElement('a'); link.href = `#${destination.anchor}`; link.textContent = destination.name;
item.append(link, document.createTextNode(` · ${edge.label || edge.relation}`));
} else {
item.textContent = `${destination.name} · ${edge.label || edge.relation}${edge.blocked ? ' · spärrad just nu' : ' · relationen är inte en passage'}`;
}
}
if (count === 0) list.replaceChildren(adminElement('li', 'Ingen lagrad utgående passage från den här noden.', list));
};
const detailList = adminElement('section', undefined, mapContent);
adminElement('h3', 'Noder och beskrivningar', detailList);
adminElement('p', 'Välj en nod i kartan eller följ en destinationslänk. Bara sparade passager presenteras som möjliga vägar.', detailList);
for (const location of locations) {
const locationArticle = adminElement('article', undefined, detailList);
locationArticle.id = `world-map-location-${location.id}`;
adminElement('h4', `Plats · ${location.name}`, locationArticle);
adminElement('p', location.description, locationArticle);
adminJson('Platsens sinnesbeskrivning', location.sensory_description, locationArticle);
const locationEdges = locationConnections.map(item => ({
from: `location:${item.from_location_id}`, to: `location:${item.to_location_id}`,
label: `${item.route_description} · ${item.travel_minutes} min`, relation: 'platsförbindelse', traversable: true,
}));
destinationLinks(locationArticle, `location:${location.id}`, locationEdges);
for (const node of nodesByLocation.get(location.id) || []) {
const article = adminElement('article', undefined, locationArticle);
article.id = `world-map-node-${node.id}`;
adminElement('h5', `${node.kind} · ${node.name}`, article);
adminElement('p', node.description, article);
adminElement('p', `Nyckel ${node.node_key} · ID ${node.id}${node.parent_node_id ? ` · överordnad nod ${nodesById.get(node.parent_node_id)?.name || node.parent_node_id}` : ''} · lokal position ${node.position_m.join(', ')} m`, article);
adminJson('Sinnesbeskrivning', node.sensory_description, article);
const relationEdges = sceneRelations.filter(item => item.location_id === location.id).map(item => {
const from = nodesById.get(item.from_node_id); const to = nodesById.get(item.to_node_id);
const endpointsBlocked = [from, to].some(candidate => candidate?.state?.locked === true || candidate?.state?.blocked === true || candidate?.state?.traversable === false);
return {from: `scene:${item.from_node_id}`, to: `scene:${item.to_node_id}`, label: `${item.route_description} · ${item.travel_minutes} min`, relation: item.relation, traversable: item.traversable === true, blocked: item.traversable === true && endpointsBlocked};
});
destinationLinks(article, `scene:${node.id}`, relationEdges);
}
}
}
renderWorld(worlds[0].id);
}
function parseAiResponseSegments(response) {
const text = String(response ?? '');
const fencedJson = /```json\s*([\s\S]*?)```/gi;
const segments = [];
let cursor = 0;
for (const match of text.matchAll(fencedJson)) {
const index = match.index ?? cursor;
if (index > cursor) segments.push({type: 'text', value: text.slice(cursor, index)});
const source = match[1].trim();
try {
segments.push({type: 'json', value: JSON.parse(source)});
} catch (_) {
segments.push({type: 'code', value: source});
}
cursor = index + match[0].length;
}
if (cursor < text.length || segments.length === 0) segments.push({type: 'text', value: text.slice(cursor)});
return segments;
}
function renderAdminAiResponse(parent, response) {
const segments = parseAiResponseSegments(response);
if (!segments.some(segment => segment.type === 'json' || segment.type === 'code')) return false;
const output = adminElement('section', undefined, parent);
output.className = 'admin-ai-response';
for (const segment of segments) {
if (segment.type === 'json') {
adminJson('JSON-svar · formaterat', segment.value, output);
} else if (segment.type === 'code') {
const code = adminElement('pre', segment.value, output);
code.className = 'admin-ai-response-code';
} else if (segment.value.trim()) {
const text = adminElement('p', decodeEscapedSequences(segment.value.trim()), output);
text.className = 'admin-ai-response-text';
}
}
return true;
}
async function loadAdminPlayers(query = '', offset = 0) {
adminOffset = offset;
adminContent.replaceChildren();
@@ -614,6 +1140,22 @@
search.addEventListener('submit', event => { event.preventDefault(); loadAdminPlayers(field.value, 0); });
const result = await request(`/api/admin/players?q=${encodeURIComponent(query)}&limit=${adminPageSize}&offset=${offset}`);
adminElement('p', `${result.total} spelare. Visar ${result.offset + 1}–${Math.min(result.total, result.offset + result.players.length)}.`, adminContent);
if (result.players.length > 0) {
const chooser = adminElement('form');
const chooserLabel = adminElement('label', 'Välj konto att följa', chooser);
const chooserSelect = document.createElement('select');
chooserSelect.required = true;
for (const player of result.players) {
const option = document.createElement('option');
option.value = player.account_id;
option.textContent = `${player.username} · ${player.character_name || 'ingen karaktär'}`;
chooserSelect.append(option);
}
chooserLabel.append(chooserSelect);
const chooserButton = adminElement('button', 'Visa konto och konversationer', chooser);
chooserButton.type = 'submit';
chooser.addEventListener('submit', event => { event.preventDefault(); openAdminPlayer(chooserSelect.value); });
}
const list = adminElement('ul');
for (const player of result.players) {
const item = adminElement('li', undefined, list);
@@ -632,23 +1174,261 @@
adminElement('h2', 'Spelarprofil');
const profile = await request(`/api/admin/players/${encodeURIComponent(accountId)}`);
adminJson(`${profile.username} · profil och biografi`, profile);
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`);
const entries = [
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
...trace.player_messages.map(message => ({kind: 'Privat spelarmeddelande', at: message.recorded_at, order: message.sequence, data: message})),
...trace.ai_interactions.map(interaction => ({kind: `AI ${interaction.prompt_id} · ${interaction.outcome}`, at: interaction.requested_at, order: interaction.correlation_id, data: interaction}))
].sort((a, b) => String(a.at).localeCompare(String(b.at)));
adminElement('h2', 'Kronologiskt spelarspår');
adminElement('p', 'Registreringstid visas separat från världstid och commitordning. AI-frågor, svar och världshändelser är korrelerade där korrelations-ID finns.');
for (const [index, entry] of entries.entries()) {
const status = entry.data.execution_mode || entry.data.metadata?.decision || entry.data.outcome || 'status okänd';
adminJson(`${index + 1}. ${entry.kind} · ${status} · ${entry.at} · ${entry.order}`, entry.data);
if (entry.data.external_call && Object.keys(entry.data.external_call).length > 0) {
const call = entry.data.external_call;
const outcome = call.error || `HTTP ${call.response_status ?? 'okänt'}`;
adminJson(`Externt tjänsteanrop · ${call.service || 'okänd tjänst'} · ${outcome} · ${call.duration_ms ?? '?'} ms`, call);
adminTraceOffset = 0;
adminSimpleConversation = false;
const trace = await getAdminTrace(accountId, 200, 0);
renderAdminConversations(trace, accountId);
}
function renderAdminConversations(trace, accountId, existingRoot, hasMoreOverride) {
const root = existingRoot || adminElement('section');
root.replaceChildren();
const groups = new Map();
const groupFor = (correlationId, at) => {
const key = correlationId || `unlinked:${at || groups.size}`;
if (!groups.has(key)) groups.set(key, {correlationId, events: [], messages: [], interactions: [], at});
return groups.get(key);
};
for (const event of trace.events || []) groupFor(event.correlation_id, event.recorded_at).events.push(event);
for (const message of trace.player_messages || []) groupFor(message.correlation_id, message.recorded_at).messages.push(message);
for (const interaction of trace.ai_interactions || []) groupFor(interaction.correlation_id, interaction.requested_at).interactions.push(interaction);
const conversations = [...groups.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)));
adminElement('h2', 'Samtal och beslut', root);
adminElement('p', `Visar ${conversations.length} korrelerade samtalsturer från den här sidan. Välj en berättarreplik eller ett AI-svar för att öppna hela registrerade beslutskedjan. Registreringstid, världstid och commitordning visas var för sig.`, root);
const readingToggle = adminElement('button', adminSimpleConversation ? 'Visa felsökningsspår' : 'Lättläst konversation', root);
readingToggle.type = 'button';
readingToggle.setAttribute('aria-pressed', String(adminSimpleConversation));
readingToggle.addEventListener('click', () => {
adminSimpleConversation = !adminSimpleConversation;
root.classList.toggle('admin-simple-conversation', adminSimpleConversation);
readingToggle.textContent = adminSimpleConversation ? 'Visa felsökningsspår' : 'Lättläst konversation';
readingToggle.setAttribute('aria-pressed', String(adminSimpleConversation));
});
root.classList.toggle('admin-simple-conversation', adminSimpleConversation);
if (conversations.length === 0) {
const empty = adminElement('p', 'Det finns inga sparade berättarrepliker för kontot ännu.', root);
empty.className = 'admin-story-empty';
return;
}
for (const [index, group] of conversations.entries()) {
group.events.sort((a, b) => (a.commit_sequence ?? 0) - (b.commit_sequence ?? 0));
group.messages.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
group.interactions.sort((a, b) => String(a.requested_at).localeCompare(String(b.requested_at)));
const card = adminElement('article', undefined, root);
adminElement('h3', `Tur ${index + 1} · ${group.at || 'tid saknas'} · ${group.correlationId || 'utan korrelations-ID'}`, card);
let showedPlayerInput = false;
for (const event of group.events) {
const payload = event.payload || {};
if (payload.player_message) {
const playerLine = adminElement('p', `Spelaren: ${payload.player_message}`, card);
playerLine.className = 'admin-story-player';
showedPlayerInput = true;
}
const reply = payload.reply || payload.npc_reply;
if (reply) {
const story = adminElement('p', `Berättelsen: ${reply}`, card);
story.className = 'admin-story-reply';
const button = adminElement('button', `Berättaren${payload.npc_reply ? ' / NPC' : ''}: ${reply}`, card);
button.type = 'button';
button.setAttribute('aria-expanded', 'false');
const detail = adminElement('section', undefined, card); detail.hidden = true;
button.addEventListener('click', () => {
detail.hidden = !detail.hidden;
button.setAttribute('aria-expanded', String(!detail.hidden));
if (!detail.hidden && !detail.dataset.loaded) {
renderDecisionProvenance(group, event, detail);
detail.dataset.loaded = 'true';
}
});
}
}
if (!showedPlayerInput) {
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
if (failedTurnInput) {
const playerLine = adminElement('p', `Spelaren: ${failedTurnInput}`, card);
playerLine.className = 'admin-story-player';
}
}
for (const message of group.messages) {
const playerLine = adminElement('p', `Spelare: ${message.message}`, card);
playerLine.className = 'admin-story-player';
}
for (const interaction of group.interactions) {
if (!interaction.response) continue;
const responseHasJson = parseAiResponseSegments(interaction.response).some(segment => segment.type === 'json' || segment.type === 'code');
const responseButtonLabel = `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}${responseHasJson ? ' · JSON' : `: ${interaction.response}`}`;
const button = adminElement('button', responseButtonLabel, card);
button.type = 'button'; button.setAttribute('aria-expanded', 'false');
if (responseHasJson) renderAdminAiResponse(card, interaction.response);
const detail = adminElement('section', undefined, card); detail.hidden = true;
button.addEventListener('click', () => {
detail.hidden = !detail.hidden; button.setAttribute('aria-expanded', String(!detail.hidden));
if (!detail.hidden && !detail.dataset.loaded) { renderDecisionProvenance(group, null, detail, interaction); detail.dataset.loaded = 'true'; }
});
}
if (group.events.length === 0 && group.interactions.every(item => !item.response)) {
adminElement('p', 'Ingen färdig replik. Öppna turens tekniska spår:', card);
for (const interaction of group.interactions) adminJson(`${interaction.prompt_id} · ${interaction.outcome}`, interaction, card);
}
}
const pageHasMore = hasMoreOverride ?? trace.has_more ?? (Math.max(trace.events?.length || 0, trace.ai_interactions?.length || 0, trace.player_messages?.length || 0) >= (trace.limit || 200));
if (pageHasMore) {
const older = adminElement('button', adminSimpleConversation ? 'Visa äldre berättarrepliker' : 'Visa äldre samtalsturer', root);
older.type = 'button';
older.addEventListener('click', async () => {
older.disabled = true;
older.textContent = 'Hämtar äldre samtal…';
adminTraceOffset += trace.limit || 200;
const next = await getAdminTrace(accountId, trace.limit || 200, adminTraceOffset);
const merge = (key, idKey) => [...(trace[key] || []), ...(next[key] || [])].filter((item, index, all) => all.findIndex(candidate => candidate[idKey] === item[idKey]) === index);
renderAdminConversations({events: merge('events', 'event_id'), ai_interactions: merge('ai_interactions', 'id'), player_messages: merge('player_messages', 'id'), limit: next.limit, offset: next.offset}, accountId, root, next.has_more);
});
}
}
function latestPlayerText(interaction) {
const messages = interaction.rendered_messages;
if (!Array.isArray(messages)) return '';
const userMessage = [...messages].reverse().find(message => message?.role === 'user');
if (!userMessage) return '';
if (typeof userMessage.content === 'string') return userMessage.content;
return userMessage.content == null ? '' : JSON.stringify(userMessage.content);
}
function renderDecisionProvenance(group, selectedEvent, container, selectedInteraction) {
adminElement('h4', 'Beslutsspår', container);
if (group.correlationId) adminElement('p', `Korrelations-ID: ${group.correlationId}`, container);
const interactions = selectedInteraction ? [selectedInteraction] : group.interactions;
for (const interaction of interactions) {
const ai = adminElement('section', undefined, container);
ai.className = 'trace-step'; ai.dataset.engine = 'ai';
adminElement('h5', `AI · ${interaction.prompt_id} v${interaction.prompt_version} · ${interaction.outcome}`, ai);
adminJson('YAML-prompt och renderad kontext', {prompt_yaml: interaction.prompt_yaml, rendered_messages: interaction.rendered_messages}, ai);
adminJson('Externt tjänsteanrop · förfrågan, svar och metadata', {external_call: interaction.external_call, response: interaction.response, outcome: interaction.outcome, metadata: interaction.metadata}, ai);
}
const events = selectedEvent ? [selectedEvent] : group.events;
for (const event of events) {
const payload = event.payload || {};
const details = payload.details || {};
const luau = details.luau_trace || payload.luau_trace;
const rust = details.rust_validation || payload.rust_validation;
if (luau) { const step = adminElement('section', undefined, container); step.className = 'trace-step'; step.dataset.engine = 'luau'; adminJson('Luau · regelindata och effektförslag', luau, step); }
if (rust) { const step = adminElement('section', undefined, container); step.className = 'trace-step'; step.dataset.engine = 'rust'; adminJson('Rust · validering och commit', rust, step); }
const commit = adminElement('section', undefined, container); commit.className = 'trace-step'; commit.dataset.engine = 'rust';
adminJson(`Rust · ${event.commit_status || 'händelse'} · världstid ${event.world_time ?? 'okänd'} · commit ${event.commit_sequence ?? 'saknas'}`, event, commit);
}
if (!interactions.length && !events.length) adminElement('p', 'Beslutsspår saknas för denna post.', container);
}
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, '\t'); 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();
@@ -750,12 +1530,53 @@
messageField.disabled = false;
messageField.focus();
}
function savedDialogueLine(line) {
const element = document.createElement('li');
element.dataset.role = line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant';
element.textContent = `${line.speaker || (line.role === 'user' ? 'Du' : 'Berättelsen')}: ${line.content}`;
return element;
}
function dialogueLinesFromPage(page) {
return page.edges.flatMap(edge => edge.node.lines);
}
function addOlderDialogueControl(cursor) {
const item = document.createElement('li');
item.dataset.role = 'system';
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Hämta äldre konversation';
button.addEventListener('click', async () => {
button.disabled = true;
button.textContent = 'Hämtar äldre konversation…';
try {
const page = (await graphqlQuery(
'query OlderDialogue($before: String!) { dialogue(first: 50, before: $before) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
{before: cursor},
)).dialogue;
const oldLines = dialogueLinesFromPage(page);
const fragment = document.createDocumentFragment();
for (const line of oldLines) fragment.append(savedDialogueLine(line));
item.remove();
transcript.prepend(fragment);
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
} catch (error) {
button.disabled = false;
button.textContent = `Kunde inte hämta äldre repliker: ${error.message}. Försök igen`;
}
});
item.append(button);
transcript.prepend(item);
}
async function loadSavedDialogue() {
const lines = await request('/api/dialogue');
const page = (await graphqlQuery(
'query CurrentDialogue { dialogue(first: 100) { edges { cursor node { lines { role content speaker } } } pageInfo { hasPreviousPage startCursor } } }',
)).dialogue;
const lines = dialogueLinesFromPage(page);
transcript.replaceChildren();
latestAssistantText = '';
latestAssistantText = [...lines].reverse().find(line => line.role === 'assistant')?.content || '';
latestPlayerAction = [...lines].reverse().find(line => line.role === 'user')?.content || '';
for (const line of lines) appendLine(line.role === 'user' ? 'user' : line.role === 'system' ? 'system' : 'assistant', line.content, {speaker: line.speaker, speak: false});
for (const line of lines) transcript.append(savedDialogueLine(line));
if (page.pageInfo.hasPreviousPage && page.pageInfo.startCursor) addOlderDialogueControl(page.pageInfo.startCursor);
refreshActionTools();
if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
@@ -843,9 +1664,9 @@
const addValue = (name, value) => {
const valueHeading = document.createElement('strong');
valueHeading.textContent = name;
const pre = document.createElement('pre');
pre.textContent = typeof value === 'string' ? value : JSON.stringify(value ?? 'Ingen uppgift sparad', null, 2);
card.append(valueHeading, pre);
const json = document.createElement('div');
renderJsonTree(json, value === undefined ? 'Ingen uppgift sparad' : value);
card.append(valueHeading, json);
};
addValue('Indata', input);
addValue('Utdata', output);
@@ -903,10 +1724,10 @@
const raw = document.createElement('details');
raw.className = 'trace-raw';
const rawSummary = document.createElement('summary');
rawSummary.textContent = 'Visa all rå spårdata';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(group, null, 2);
raw.append(rawSummary, pre);
rawSummary.textContent = 'Visa hela spårdata';
const json = document.createElement('div');
renderJsonTree(json, group);
raw.append(rawSummary, json);
details.append(summary, chain, raw);
debugTrace.append(details);
}
@@ -916,7 +1737,7 @@
debugRefreshBusy = true;
debugStatus.textContent = 'Hämtar revisionsloggad spårning…';
try {
const trace = await request('/api/admin/self/trace?limit=200');
const trace = await getAdminTrace(null, 200, 0);
renderDecisionTrace(trace);
debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`;
} catch (error) {
@@ -1243,6 +2064,8 @@
adminContent.setAttribute('aria-busy', 'true');
try {
if (button.dataset.adminView === 'players') await loadAdminPlayers();
else if (button.dataset.adminView === 'world-map') await loadAdminWorldMap();
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();
+37 -3
View File
@@ -19,6 +19,7 @@ grep -q "request('/version')" index.html
grep -q 'id="static-version">hämtar version…' index.html
grep -q "request('/static/version.json', {cache: 'no-store'})" index.html
grep -Fq "credentials: 'same-origin'" index.html
grep -q "request('/graphql'" index.html
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
grep -q 'id="message" name="message"' index.html
grep -q 'id="auth-intro"' index.html
@@ -95,17 +96,50 @@ grep -q 'dialogue_temporarily_unavailable:' index.html
grep -q 'id="admin-view"' index.html
grep -q 'id="play-layout"' index.html
grep -q 'id="debug-panel"' index.html
grep -q "request('/api/admin/self/trace?limit=200')" index.html
grep -q "query AdminPlayerTrace" index.html
grep -q 'adminPlayerTrace(accountId: \$accountId, limit: \$limit, offset: \$offset)' index.html
grep -q 'function renderDecisionTrace' index.html
grep -q 'JSON.stringify(group, null, 2)' index.html
grep -q 'function renderJsonTree' index.html
grep -q 'function decodeEscapedSequences' index.html
grep -q 'decodeEscapedSequences(item)' index.html
grep -q "Visa lagrad text med escape-tecken" index.html
grep -q 'function parseAiResponseSegments' index.html
grep -q 'function renderAdminAiResponse' index.html
grep -q "JSON-svar · formaterat" index.html
grep -q 'admin-ai-response-code' index.html
grep -q 'function loadAdminWorldMap' index.html
grep -q "request('/api/admin/world-map')" index.html
grep -q 'data-admin-view="world-map"' index.html
grep -q 'world-map-edge' index.html
grep -q 'Noder och beskrivningar' index.html
grep -q "'Öppna alla block'" index.html
grep -q "'Fäll ihop nästlade block'" index.html
grep -q "nested.className = 'json-node'" index.html
grep -q 'json-value-string' index.html
grep -q "setAttribute('aria-label', 'Utfällbart JSON-svar')" index.html
grep -q 'debug-enabled' index.html
grep -q '/api/admin/players?' index.html
grep -q 'Välj konto att följa' index.html
grep -q 'function renderAdminConversations' index.html
grep -q 'function renderDecisionProvenance' index.html
grep -q 'Visa äldre samtalsturer' index.html
grep -q 'Lättläst konversation' index.html
grep -q 'admin-simple-conversation' index.html
grep -q 'aria-expanded' index.html
grep -q "request('/api/admin/prompts'" index.html
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 'entry.data.external_call' 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 'interaction.external_call' index.html
grep -q 'Externt tjänsteanrop' index.html
grep -q "request('/api/world/start'" index.html
grep -q "request('/api/character'" index.html
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const page = fs.readFileSync(new URL('../index.html', `file://${__filename}`), 'utf8');
test('admin can select a searched account before opening its conversations', () => {
assert.match(page, /Välj konto att följa/);
assert.match(page, /chooserSelect\.value\);/);
assert.match(page, /\/api\/admin\/players\?q=/);
});
test('conversation transcript exposes player and narrator messages as expandable replies', () => {
assert.match(page, /function renderAdminConversations/);
assert.match(page, /payload\.player_message/);
assert.match(page, /payload\.reply \|\| payload\.npc_reply/);
assert.match(page, /aria-expanded/);
assert.match(page, /renderDecisionProvenance\(group, event, detail\)/);
});
test('selected replies reveal AI, Luau and Rust provenance without injecting HTML', () => {
assert.match(page, /function renderDecisionProvenance/);
assert.match(page, /interaction\.prompt_yaml/);
assert.match(page, /interaction\.external_call/);
assert.match(page, /details\.luau_trace/);
assert.match(page, /details\.rust_validation/);
assert.match(page, /element\.textContent = text/);
});
test('account trace can be paged until all stored turns are loaded', () => {
assert.match(page, /Visa äldre samtalsturer/);
assert.match(page, /query AdminPlayerTrace\(\$accountId: String, \$limit: Int!, \$offset: Int!\)/);
assert.match(page, /getAdminTrace\(accountId, trace\.limit \|\| 200, adminTraceOffset\)/);
});
test('admin can switch to a storyteller-only reading view and return to diagnostics', () => {
assert.match(page, /Lättläst konversation/);
assert.match(page, /Visa felsökningsspår/);
assert.match(page, /root\.classList\.toggle\('admin-simple-conversation', adminSimpleConversation\)/);
assert.match(page, /playerLine\.className = 'admin-story-player'/);
assert.match(page, /story\.className = 'admin-story-reply'/);
assert.match(page, /article > :not\(\.admin-story-player\):not\(\.admin-story-reply\)/);
assert.match(page, /article > \.admin-story-player,\s*#admin-content \.admin-simple-conversation article > \.admin-story-reply/);
});
test('admin JSON responses are readable as accessible collapsible trees', () => {
assert.match(page, /function renderJsonTree/);
assert.match(page, /nested\.className = 'json-node'/);
assert.match(page, /const nodeValues = new WeakMap\(\)/);
assert.match(page, /nodeValues\.set\(nested, child\)/);
assert.match(page, /groupSummary/);
assert.match(page, /Lista · \$\{item\.length\}/);
assert.match(page, /Objekt · \$\{Object\.keys\(item\)\.length\} fält/);
assert.match(page, /Öppna alla block/);
assert.match(page, /Fäll ihop nästlade block/);
assert.match(page, /setAttribute\('aria-label', 'Utfällbart JSON-svar'\)/);
assert.match(page, /json-value-string/);
assert.match(page, /json-value-number/);
assert.match(page, /json-value-boolean/);
assert.match(page, /json-value-null/);
assert.match(page, /renderJsonTree\(json, group\)/);
assert.match(page, /rawSummary\.textContent = 'Visa hela spårdata'/);
assert.match(page, /JSON\.stringify\(npc\.profile, null, '\\t'\)/);
assert.doesNotMatch(page, /jsnview|cdn\.jsdelivr/);
});
test('admin JSON text decodes common JSON and YAML escapes while retaining the stored form', () => {
const decoder = page.match(/function decodeEscapedSequences\(text\) \{[\s\S]*?\n \}/);
assert.ok(decoder, 'escape decoder should be present');
assert.match(page, /Visa lagrad text med escape-tecken/);
assert.match(page, /decodeEscapedSequences\(item\)/);
const decode = new Function(`${decoder[0]}; return decodeEscapedSequences;`)();
assert.equal(decode(String.raw`Rad 1\nRad 2`), 'Rad 1\nRad 2');
assert.equal(decode(String.raw`Kolumn 1\tKolumn 2`), 'Kolumn 1\tKolumn 2');
assert.equal(decode(String.raw`citat: \'hej\'`), "citat: 'hej'");
assert.equal(decode(String.raw`\u00E5\x21`), 'å!');
assert.equal(decode(String.raw`\U0001F642`), '🙂');
assert.equal(decode(String.raw`okänt: \q`), String.raw`okänt: \q`);
});
test('fenced JSON in AI replies is parsed for readable formatting', () => {
const parser = page.match(/function parseAiResponseSegments\(response\) \{[\s\S]*?\n \}/);
assert.ok(parser, 'AI response segment parser should be present');
assert.match(page, /renderAdminAiResponse\(card, interaction\.response\)/);
assert.match(page, /JSON-svar · formaterat/);
assert.match(page, /admin-ai-response-code/);
const parseSegments = new Function(`${parser[0]}; return parseAiResponseSegments;`)();
const segments = parseSegments('dialogue-turn · completed: ```json { "intent": "speak", "reply": "Rad 1\\nRad 2" }```');
assert.equal(segments[0].type, 'text');
assert.equal(segments[0].value, 'dialogue-turn · completed: ');
assert.equal(segments[1].type, 'json');
assert.deepEqual(segments[1].value, {intent: 'speak', reply: 'Rad 1\nRad 2'});
});
test('admin world map draws stored nodes and exposes only established routes as links', () => {
assert.match(page, /data-admin-view="world-map">Världskarta/);
assert.match(page, /function loadAdminWorldMap/);
assert.match(page, /request\('\/api\/admin\/world-map'\)/);
assert.match(page, /function renderWorld\(worldId\)/);
assert.match(page, /world-map-edge/);
assert.match(page, /world-map-node-\$\{node\.id\}/);
assert.match(page, /edge\.traversable && !edge\.blocked/);
assert.match(page, /location_connections/);
assert.match(page, /scene_relations/);
assert.match(page, /Noder och beskrivningar/);
assert.match(page, /Välj en nod i kartan eller följ en destinationslänk/);
assert.match(page, /button\.dataset\.adminView === 'world-map'/);
});