439 lines
22 KiB
HTML
439 lines
22 KiB
HTML
<!doctype html>
|
||
<html lang="sv">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="light dark">
|
||
<title>Svensk livssimulator</title>
|
||
<style>
|
||
:root { font: 1rem/1.6 system-ui, sans-serif; }
|
||
body { max-width: 52rem; margin: 0 auto; padding: 1rem; }
|
||
h1, h2 { line-height: 1.2; }
|
||
form { display: grid; gap: .75rem; max-width: 32rem; }
|
||
label { display: grid; gap: .25rem; font-weight: 600; }
|
||
input, textarea, button { font: inherit; padding: .65rem; }
|
||
button { justify-self: start; cursor: pointer; }
|
||
#dialogue { list-style: none; padding: 0; margin: 0; }
|
||
#dialogue li { margin-block: 1rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
#dialogue li[data-role="user"] { font-weight: 650; }
|
||
#dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
|
||
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
|
||
#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-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
|
||
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||
[hidden] { display: none !important; }
|
||
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<section id="auth" aria-labelledby="auth-heading">
|
||
<h1 id="auth-heading">Svensk livssimulator</h1>
|
||
<p>Skapa en vuxen person och utforska en bestående värld genom dialog. Inga bilder används.</p>
|
||
<form id="auth-form">
|
||
<label>Användarnamn <input name="username" required minlength="3" maxlength="40" autocomplete="username"></label>
|
||
<label>Lösenord <input name="password" type="password" required minlength="12" maxlength="256" autocomplete="current-password"></label>
|
||
<div><button type="submit" name="intent" value="login">Logga in</button> <button type="submit" name="intent" value="register">Skapa konto</button></div>
|
||
</form>
|
||
</section>
|
||
<section id="play" aria-label="Spelets dialog" hidden>
|
||
<h1 class="sr-only">Dialog</h1>
|
||
<ol id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite" aria-relevant="additions text"></ol>
|
||
<form id="message-form">
|
||
<label for="message" id="message-label">Skriv ett meddelande</label>
|
||
<textarea id="message" name="message" maxlength="4000" aria-describedby="message-help" autocomplete="off"></textarea>
|
||
<span id="message-help" class="sr-only">Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
|
||
</form>
|
||
</section>
|
||
<section id="admin-view" aria-labelledby="admin-heading" hidden>
|
||
<h1 id="admin-heading">Administration</h1>
|
||
<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="prompts">Prompter</button>
|
||
<button type="button" data-admin-view="rules">Luau-regler</button>
|
||
<button type="button" data-admin-view="audit">Revisionslogg</button>
|
||
<button type="button" id="admin-logout">Logga ut</button>
|
||
</nav>
|
||
<div id="admin-content" aria-live="polite" aria-busy="false"></div>
|
||
</section>
|
||
</main>
|
||
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · API <span id="api-version">hämtar version…</span></footer>
|
||
<script>
|
||
const auth = document.querySelector('#auth');
|
||
const play = document.querySelector('#play');
|
||
const adminView = document.querySelector('#admin-view');
|
||
const adminContent = document.querySelector('#admin-content');
|
||
const transcript = document.querySelector('#dialogue');
|
||
const messageField = document.querySelector('#message');
|
||
const messageLabel = document.querySelector('#message-label');
|
||
let stage = 'auth';
|
||
let characterDraft = null;
|
||
let worldDraft = null;
|
||
let gameState = null;
|
||
let submitBusy = false;
|
||
let pendingDialogue = null;
|
||
let adminOffset = 0;
|
||
const adminPageSize = 50;
|
||
|
||
async function request(path, options = {}) {
|
||
const response = await fetch(path, {credentials: 'same-origin', ...options});
|
||
const body = response.status === 204 ? null : await response.json();
|
||
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
||
return body;
|
||
}
|
||
function appendLine(role, content) {
|
||
const line = document.createElement('li');
|
||
line.dataset.role = role;
|
||
line.textContent = `${role === 'user' ? 'Du' : 'Berättelsen'}: ${content}`;
|
||
transcript.append(line);
|
||
line.scrollIntoView({block: 'end'});
|
||
return line;
|
||
}
|
||
function setStage(next) {
|
||
stage = next;
|
||
messageField.disabled = false;
|
||
messageField.value = '';
|
||
messageLabel.textContent = next === 'biography'
|
||
? 'Berätta fritt om personen du vill spela'
|
||
: next === 'world-start'
|
||
? 'Skriv “försök igen” för att försöka skapa startplatsen'
|
||
: next === 'world-draft'
|
||
? 'Ändra startförslaget eller skriv “godkänn”'
|
||
: 'Skriv vad du vill göra eller fråga';
|
||
messageField.focus();
|
||
}
|
||
function showAuth() {
|
||
stage = 'auth';
|
||
play.hidden = true;
|
||
adminView.hidden = true;
|
||
auth.hidden = false;
|
||
document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator';
|
||
document.querySelector('#version-footer').hidden = false;
|
||
}
|
||
function showPlay() {
|
||
auth.hidden = true;
|
||
adminView.hidden = true;
|
||
play.hidden = false;
|
||
document.querySelector('#version-footer').hidden = true;
|
||
messageField.focus();
|
||
}
|
||
function showAdmin() {
|
||
auth.hidden = true;
|
||
play.hidden = true;
|
||
adminView.hidden = false;
|
||
document.querySelector('#version-footer').hidden = true;
|
||
}
|
||
function adminElement(tag, text, parent = adminContent) {
|
||
const element = document.createElement(tag);
|
||
if (text !== undefined) element.textContent = text;
|
||
parent.append(element);
|
||
return element;
|
||
}
|
||
function adminJson(title, value, parent = adminContent) {
|
||
const details = adminElement('details', undefined, parent);
|
||
adminElement('summary', title, details);
|
||
adminElement('pre', JSON.stringify(value, null, 2), details);
|
||
return details;
|
||
}
|
||
async function loadAdminPlayers(query = '', offset = 0) {
|
||
adminOffset = offset;
|
||
adminContent.replaceChildren();
|
||
const search = adminElement('form');
|
||
search.id = 'player-search';
|
||
const label = adminElement('label', 'Sök efter användarnamn eller karaktär', search);
|
||
const field = document.createElement('input');
|
||
field.type = 'search'; field.name = 'q'; field.value = query; field.maxLength = 100;
|
||
label.append(field);
|
||
const submit = adminElement('button', 'Sök', search); submit.type = 'submit';
|
||
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);
|
||
const list = adminElement('ul');
|
||
for (const player of result.players) {
|
||
const item = adminElement('li', undefined, list);
|
||
const button = adminElement('button', `${player.username} · ${player.character_name || 'ingen karaktär'} · världsversion ${player.world_version ?? 'saknas'}`, item);
|
||
button.type = 'button'; button.addEventListener('click', () => openAdminPlayer(player.account_id));
|
||
}
|
||
const paging = adminElement('div');
|
||
const previous = adminElement('button', 'Föregående sida', paging);
|
||
previous.type = 'button'; previous.disabled = offset <= 0; previous.addEventListener('click', () => loadAdminPlayers(query, Math.max(0, offset - adminPageSize)));
|
||
const next = adminElement('button', 'Nästa sida', paging);
|
||
next.type = 'button'; next.disabled = offset + result.players.length >= result.total; next.addEventListener('click', () => loadAdminPlayers(query, offset + adminPageSize));
|
||
}
|
||
async function openAdminPlayer(accountId) {
|
||
adminContent.replaceChildren();
|
||
const back = adminElement('button', 'Tillbaka till spelare'); back.type = 'button'; back.addEventListener('click', () => loadAdminPlayers('', adminOffset));
|
||
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.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);
|
||
}
|
||
}
|
||
async function loadAdminPrompts() {
|
||
adminContent.replaceChildren();
|
||
const result = await request('/api/admin/prompts');
|
||
adminElement('h2', 'Prompttillgångar');
|
||
for (const prompt of result.prompts) adminJson(`${prompt.id} · version ${prompt.version}`, prompt.yaml);
|
||
}
|
||
async function loadAdminRules() {
|
||
adminContent.replaceChildren();
|
||
const result = await request('/api/admin/rules');
|
||
adminElement('h2', 'Luau-regler');
|
||
for (const rule of result.rules) adminJson(`${rule.rule_key} · v${rule.version} · ${rule.active ? 'aktiv' : 'inaktiv'} · ${rule.source_hash}`, {source: rule.source, validation_results: rule.validation_results, created_at: rule.created_at});
|
||
}
|
||
async function loadAdminAudit() {
|
||
adminContent.replaceChildren();
|
||
const result = await request('/api/admin/audit?limit=200');
|
||
adminElement('h2', 'Revisionslogg');
|
||
for (const entry of result.entries) adminJson(`${entry.created_at} · ${entry.action} · ${entry.admin_account_id || entry.operator_identity}`, entry);
|
||
}
|
||
async function openAdmin() {
|
||
showAdmin();
|
||
adminContent.setAttribute('aria-busy', 'true');
|
||
try { await loadAdminPlayers(); }
|
||
catch (error) { adminContent.replaceChildren(); adminElement('p', `Administrationsvyn kunde inte öppnas: ${error.message}`); }
|
||
finally { adminContent.setAttribute('aria-busy', 'false'); }
|
||
}
|
||
function appendInitialPrompt() {
|
||
appendLine('assistant', 'Välkommen. Skriv fritt om personen du vill spela. Jag föreslår en vuxen biografi som du kan ändra innan du godkänner den.');
|
||
setStage('biography');
|
||
}
|
||
async function proposeWorld(message) {
|
||
stage = 'world-start';
|
||
messageField.disabled = true;
|
||
appendLine('user', message);
|
||
appendLine('assistant', 'Jag tar fram ett förslag på startplats och startläge…');
|
||
try {
|
||
const proposal = await request('/api/world/draft', {
|
||
method: 'POST', headers: {'content-type': 'application/json'},
|
||
body: JSON.stringify({input: message, current_draft: worldDraft}),
|
||
});
|
||
worldDraft = proposal.draft;
|
||
const senses = worldDraft.location_senses;
|
||
appendLine('assistant', `${proposal.message}\n\nVärld: ${worldDraft.name}\nStartplats: ${worldDraft.location_name}. ${worldDraft.location_description}\nLjud: ${senses.sound}\nDoft: ${senses.smell}\nLuft och temperatur: ${senses.air_and_temperature}\nBeröring och ytor: ${senses.touch_and_surfaces}\nOrientering: ${senses.spatial_orientation}\nStartbelopp: ${worldDraft.starting_balance_sek} kronor. ${worldDraft.offer_name} kostar ${worldDraft.offer_price_sek} kronor.\n\nSkriv ändringar eller “godkänn”.`);
|
||
} catch (error) {
|
||
appendLine('assistant', `Förslaget kunde inte tas fram: ${error.message}. Skriv “försök igen” när du vill försöka på nytt.`);
|
||
}
|
||
setStage('world-draft');
|
||
messageField.disabled = false;
|
||
messageField.focus();
|
||
}
|
||
async function approveWorldDraft(message) {
|
||
if (!worldDraft) {
|
||
appendLine('assistant', 'Det finns inget världsförslag att godkänna. Skriv “försök igen” först.');
|
||
return;
|
||
}
|
||
appendLine('user', message);
|
||
messageField.disabled = true;
|
||
try {
|
||
gameState = await request('/api/world/start', {
|
||
method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(worldDraft),
|
||
});
|
||
worldDraft = null;
|
||
await loadSavedDialogue();
|
||
setStage('game');
|
||
appendLine('assistant', 'Du kan fråga om platsen, dina saker eller din ekonomi, vänta, handla, prata med någon som finns här eller skriva vart du vill gå.');
|
||
} catch (error) {
|
||
appendLine('assistant', `Världsförslaget kunde inte sparas: ${error.message}. Förslaget finns kvar så att du kan försöka igen.`);
|
||
}
|
||
messageField.disabled = false;
|
||
messageField.focus();
|
||
}
|
||
async function loadSavedDialogue() {
|
||
const lines = await request('/api/dialogue');
|
||
transcript.replaceChildren();
|
||
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content);
|
||
if (lines.length === 0 && gameState?.world) {
|
||
const place = gameState.world.location;
|
||
const senses = place.senses || {};
|
||
appendLine('assistant', `Du börjar på ${place.name}. ${place.description} Ljud: ${senses.sound || 'Inget särskilt ljud beskrivet.'} Doft: ${senses.smell || 'Ingen särskild doft beskriven.'} Luft och temperatur: ${senses.air_and_temperature || 'Inte beskrivet.'} Beröring och ytor: ${senses.touch_and_surfaces || 'Inte beskrivet.'} Orientering: ${senses.spatial_orientation || 'Inte beskrivet.'}`);
|
||
}
|
||
}
|
||
async function openGame() {
|
||
gameState = await request('/api/world');
|
||
showPlay();
|
||
if (!gameState.character) {
|
||
transcript.replaceChildren();
|
||
appendInitialPrompt();
|
||
return;
|
||
}
|
||
characterDraft = null;
|
||
if (!gameState.world) {
|
||
worldDraft = null;
|
||
appendLine('assistant', 'Nu föreslår jag en startplats som passar biografin. Du kan ändra förslaget innan något sparas. Skriv “börja”.');
|
||
setStage('world-draft');
|
||
return;
|
||
}
|
||
await loadSavedDialogue();
|
||
setStage('game');
|
||
}
|
||
async function handleLogout() {
|
||
await request('/api/logout', {method: 'POST'});
|
||
characterDraft = null;
|
||
gameState = null;
|
||
transcript.replaceChildren();
|
||
showAuth();
|
||
}
|
||
async function handleBiography(message) {
|
||
if (/^(godkänn|jag godkänner|godkänn biografin)[.!]?$/i.test(message.trim())) {
|
||
if (!characterDraft) {
|
||
appendLine('assistant', 'Jag har inget biografiutkast att godkänna än. Berätta först om personen du vill spela.');
|
||
return;
|
||
}
|
||
appendLine('user', message);
|
||
messageField.disabled = true;
|
||
appendLine('assistant', 'Sparar den godkända karaktären…');
|
||
try {
|
||
await request('/api/character', {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(characterDraft)});
|
||
characterDraft = null;
|
||
await openGame();
|
||
} catch (error) {
|
||
if (error.message === 'character_already_exists') {
|
||
await openGame();
|
||
} else {
|
||
appendLine('assistant', `Karaktären kunde inte sparas: ${error.message}. Utkastet finns kvar så att du kan försöka igen.`);
|
||
setStage('biography');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
appendLine('user', message);
|
||
messageField.disabled = true;
|
||
try {
|
||
const proposal = await request('/api/character/draft', {
|
||
method: 'POST',
|
||
headers: {'content-type': 'application/json'},
|
||
body: JSON.stringify({input: message, current_draft: characterDraft}),
|
||
});
|
||
characterDraft = proposal.draft;
|
||
appendLine('assistant', `${proposal.message}\n\nFörslag: ${characterDraft.name}, ${characterDraft.age} år.\n${characterDraft.biography}\n\nSkriv ändringar eller “godkänn”.`);
|
||
} catch (error) {
|
||
appendLine('assistant', `Jag kunde inte ta fram ett biografiförslag: ${error.message}. Försök igen när tjänsten svarar.`);
|
||
}
|
||
messageField.disabled = false;
|
||
messageField.focus();
|
||
}
|
||
async function handleGameMessage(message) {
|
||
if (/^logga ut[.!]?$/i.test(message.trim())) {
|
||
appendLine('user', message);
|
||
await handleLogout();
|
||
return;
|
||
}
|
||
if (stage === 'world-start') {
|
||
if (/^försök igen[.!]?$/i.test(message.trim())) {
|
||
await proposeWorld(message);
|
||
} else {
|
||
appendLine('user', message);
|
||
appendLine('assistant', 'Startplatsen är inte skapad än. Skriv “försök igen” när du vill försöka på nytt.');
|
||
}
|
||
return;
|
||
}
|
||
if (stage === 'world-draft') {
|
||
if (/^godkänn[.!]?$/i.test(message.trim())) await approveWorldDraft(message);
|
||
else await proposeWorld(message);
|
||
return;
|
||
}
|
||
const idempotencyKey = pendingDialogue?.message === message
|
||
? pendingDialogue.idempotencyKey
|
||
: crypto.randomUUID();
|
||
pendingDialogue = {message, idempotencyKey};
|
||
appendLine('user', message);
|
||
messageField.disabled = true;
|
||
try {
|
||
let result;
|
||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||
try {
|
||
result = await request('/api/dialogue', {
|
||
method: 'POST',
|
||
headers: {'content-type': 'application/json'},
|
||
body: JSON.stringify({message, expected_world_version: gameState.world.version, idempotency_key: idempotencyKey}),
|
||
});
|
||
break;
|
||
} catch (error) {
|
||
if (error.message !== 'world_version_conflict' || attempt > 0) throw error;
|
||
gameState = await request('/api/world');
|
||
if (!gameState.world) throw new Error('world_not_available');
|
||
}
|
||
}
|
||
gameState.world.version = result.world_version;
|
||
gameState.world.world_time = result.world_time;
|
||
pendingDialogue = null;
|
||
appendLine('assistant', result.reply);
|
||
} catch (error) {
|
||
messageField.value = message;
|
||
appendLine('assistant', `Meddelandet kunde inte behandlas: ${error.message}. Texten ligger kvar i inmatningen; skicka igen för att försöka på nytt.`);
|
||
}
|
||
messageField.disabled = false;
|
||
messageField.focus();
|
||
}
|
||
|
||
document.querySelector('#auth-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const data = new FormData(form);
|
||
const intent = event.submitter?.value || 'login';
|
||
try {
|
||
await request(`/api/${intent}`, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({username: data.get('username'), password: data.get('password')})});
|
||
form.reset();
|
||
if (location.pathname === '/admin') await openAdmin(); else await openGame();
|
||
} catch (error) {
|
||
window.alert(`Kunde inte logga in: ${error.message}`);
|
||
}
|
||
});
|
||
document.querySelector('#message-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
if (submitBusy || messageField.disabled) return;
|
||
const message = messageField.value.trim();
|
||
if (!message) return;
|
||
submitBusy = true;
|
||
messageField.value = '';
|
||
try {
|
||
if (stage === 'biography') await handleBiography(message);
|
||
else await handleGameMessage(message);
|
||
} catch (error) {
|
||
appendLine('assistant', `Det gick inte att genomföra begäran: ${error.message}`);
|
||
} finally {
|
||
submitBusy = false;
|
||
if (!play.hidden && !messageField.disabled) messageField.focus();
|
||
}
|
||
});
|
||
document.querySelectorAll('[data-admin-view]').forEach(button => button.addEventListener('click', async () => {
|
||
adminContent.setAttribute('aria-busy', 'true');
|
||
try {
|
||
if (button.dataset.adminView === 'players') await loadAdminPlayers();
|
||
else if (button.dataset.adminView === 'prompts') await loadAdminPrompts();
|
||
else if (button.dataset.adminView === 'rules') await loadAdminRules();
|
||
else await loadAdminAudit();
|
||
} catch (error) { adminContent.replaceChildren(); adminElement('p', `Vyn kunde inte läsas: ${error.message}`); }
|
||
finally { adminContent.setAttribute('aria-busy', 'false'); }
|
||
}));
|
||
document.querySelector('#admin-logout').addEventListener('click', handleLogout);
|
||
messageField.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||
event.preventDefault();
|
||
document.querySelector('#message-form').requestSubmit();
|
||
}
|
||
});
|
||
request('/version').then(({version}) => {
|
||
document.querySelector('#api-version').textContent = version;
|
||
}).catch(() => {
|
||
document.querySelector('#api-version').textContent = 'otillgänglig';
|
||
});
|
||
request('/api/me').then(() => location.pathname === '/admin' ? openAdmin() : openGame()).catch(showAuth);
|
||
</script>
|
||
</body>
|
||
</html>
|