Compare commits

..
4 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
5 changed files with 332 additions and 4 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.76 0.1.78
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator description: Static web frontend for the Swedish life simulator
type: application type: application
version: 0.1.76 version: 0.1.78
appVersion: "0.1.76" appVersion: "0.1.78"
+290 -1
View File
@@ -59,6 +59,38 @@
.json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; 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 { margin-inline-start: .5rem; color: #475569; font-size: .9em; }
.json-raw-value > .json-long-text { border-inline-start: 2px solid #94a3b8; } .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 (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.json-view { border-color: #475569; background: #111827; color: #e5e7eb; } .json-view { border-color: #475569; background: #111827; color: #e5e7eb; }
@@ -149,6 +181,7 @@
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p> <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"> <nav id="admin-toolbar" aria-label="Administrationsvyer">
<button type="button" data-admin-view="players">Spelare</button> <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="npcs">NPC:er</button>
<button type="button" data-admin-view="prompts">Prompter</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="rules">Luau-regler</button>
@@ -842,6 +875,258 @@
renderJsonTree(adminElement('div', undefined, details), value); renderJsonTree(adminElement('div', undefined, details), value);
return details; 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) { async function loadAdminPlayers(query = '', offset = 0) {
adminOffset = offset; adminOffset = offset;
adminContent.replaceChildren(); adminContent.replaceChildren();
@@ -969,8 +1254,11 @@
} }
for (const interaction of group.interactions) { for (const interaction of group.interactions) {
if (!interaction.response) continue; 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'); button.type = 'button'; button.setAttribute('aria-expanded', 'false');
if (responseHasJson) renderAdminAiResponse(card, interaction.response);
const detail = adminElement('section', undefined, card); detail.hidden = true; const detail = adminElement('section', undefined, card); detail.hidden = true;
button.addEventListener('click', () => { button.addEventListener('click', () => {
detail.hidden = !detail.hidden; button.setAttribute('aria-expanded', String(!detail.hidden)); detail.hidden = !detail.hidden; button.setAttribute('aria-expanded', String(!detail.hidden));
@@ -1776,6 +2064,7 @@
adminContent.setAttribute('aria-busy', 'true'); adminContent.setAttribute('aria-busy', 'true');
try { try {
if (button.dataset.adminView === 'players') await loadAdminPlayers(); 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 === 'npcs') await loadAdminNpcs();
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts(); else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
else if (button.dataset.adminView === 'rules') await loadAdminRules(); else if (button.dataset.adminView === 'rules') await loadAdminRules();
+9
View File
@@ -103,6 +103,15 @@ grep -q 'function renderJsonTree' index.html
grep -q 'function decodeEscapedSequences' index.html grep -q 'function decodeEscapedSequences' index.html
grep -q 'decodeEscapedSequences(item)' index.html grep -q 'decodeEscapedSequences(item)' index.html
grep -q "Visa lagrad text med escape-tecken" 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 "'Öppna alla block'" index.html
grep -q "'Fäll ihop nästlade block'" index.html grep -q "'Fäll ihop nästlade block'" index.html
grep -q "nested.className = 'json-node'" index.html grep -q "nested.className = 'json-node'" index.html
+30
View File
@@ -79,3 +79,33 @@ test('admin JSON text decodes common JSON and YAML escapes while retaining the s
assert.equal(decode(String.raw`\U0001F642`), '🙂'); assert.equal(decode(String.raw`\U0001F642`), '🙂');
assert.equal(decode(String.raw`okänt: \q`), String.raw`okänt: \q`); 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'/);
});