|
|
|
@@ -31,9 +31,80 @@
|
|
|
|
|
#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-reply) { display: none; }
|
|
|
|
|
#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 pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
|
|
|
|
|
#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; }
|
|
|
|
@@ -110,6 +181,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="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>
|
|
|
|
@@ -620,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();
|
|
|
|
@@ -717,7 +1218,11 @@
|
|
|
|
|
let showedPlayerInput = false;
|
|
|
|
|
for (const event of group.events) {
|
|
|
|
|
const payload = event.payload || {};
|
|
|
|
|
if (payload.player_message) { adminElement('p', `Spelaren: ${payload.player_message}`, card); showedPlayerInput = true; }
|
|
|
|
|
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);
|
|
|
|
@@ -738,13 +1243,22 @@
|
|
|
|
|
}
|
|
|
|
|
if (!showedPlayerInput) {
|
|
|
|
|
const failedTurnInput = [...group.interactions].reverse().map(latestPlayerText).find(Boolean);
|
|
|
|
|
if (failedTurnInput) adminElement('p', `Spelaren: ${failedTurnInput}`, card);
|
|
|
|
|
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 message of group.messages) adminElement('p', `Spelarmeddelande: ${message.message}`, card);
|
|
|
|
|
for (const interaction of group.interactions) {
|
|
|
|
|
if (!interaction.response) continue;
|
|
|
|
|
const button = adminElement('button', `AI-svar · ${interaction.prompt_id} · ${interaction.outcome}: ${interaction.response}`, card);
|
|
|
|
|
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));
|
|
|
|
@@ -853,7 +1367,7 @@
|
|
|
|
|
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 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 => {
|
|
|
|
@@ -1150,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);
|
|
|
|
@@ -1210,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);
|
|
|
|
|
}
|
|
|
|
@@ -1550,6 +2064,7 @@
|
|
|
|
|
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();
|
|
|
|
|