611 lines
34 KiB
HTML
611 lines
34 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; }
|
||
#message-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
||
#voice-status { min-height: 1.5em; }
|
||
#dialogue li[data-role="voice-controls"] { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
|
||
#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-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>
|
||
<div id="message-tools"><button type="button" id="microphone" aria-pressed="false" aria-describedby="voice-status">Starta mikrofon</button></div>
|
||
<span id="message-help" class="sr-only">Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
|
||
</form>
|
||
<p id="voice-status" role="status" aria-live="polite"></p>
|
||
</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 src="/voice.js"></script>
|
||
<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 latestAssistantText = '';
|
||
const voiceSettingsKey = 'ai-spel.voice-settings.v1';
|
||
let voiceSettings = window.GameVoice.loadSettings(localStorage, voiceSettingsKey);
|
||
let voiceController;
|
||
let audioContext;
|
||
let voiceDisclosureAcknowledged = false;
|
||
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, options = {}) {
|
||
const line = document.createElement('li');
|
||
line.dataset.role = role;
|
||
line.textContent = `${options.speaker || (role === 'user' ? 'Du' : 'Berättelsen')}: ${content}`;
|
||
transcript.append(line);
|
||
line.scrollIntoView({block: 'end'});
|
||
if (role === 'assistant') {
|
||
latestAssistantText = content;
|
||
if (options.speak !== false && !content.trim().endsWith('…') && !content.trim().endsWith('...')
|
||
&& voiceSettings.mode === 'game-voice' && voiceSettings.autoSpeak && !play.hidden) {
|
||
voiceController?.speak(content, options.voice || {});
|
||
}
|
||
}
|
||
return line;
|
||
}
|
||
function setVoiceStatus(message = '') { document.querySelector('#voice-status').textContent = message; }
|
||
function refreshMicrophone() {
|
||
const button = document.querySelector('#microphone');
|
||
if (button) button.disabled = play.hidden || messageField.disabled || submitBusy;
|
||
}
|
||
function persistVoiceSettings() {
|
||
if (!window.GameVoice.saveSettings(localStorage, voiceSettingsKey, voiceSettings)) {
|
||
setVoiceStatus('Röstinställningen kunde inte sparas i den här webbläsaren.');
|
||
}
|
||
voiceController?.update(voiceSettings);
|
||
if (voiceSettings.mode !== 'game-voice') voiceController?.stopAll();
|
||
else if (!voiceSettings.autoSpeak) voiceController?.cancelSpeech();
|
||
transcript.setAttribute('aria-live', voiceSettings.mode === 'game-voice' ? 'off' : 'polite');
|
||
}
|
||
function playVoiceSignal(state) {
|
||
if (!voiceSettings.soundSignals || !['LISTENING', 'PROCESSING', 'ERROR', 'STOPPED'].includes(state)) return;
|
||
try {
|
||
const Audio = window.AudioContext || window.webkitAudioContext;
|
||
if (!Audio) return;
|
||
audioContext ||= new Audio();
|
||
if (audioContext.state === 'suspended') audioContext.resume();
|
||
const oscillator = audioContext.createOscillator(); const gain = audioContext.createGain();
|
||
oscillator.frequency.value = state === 'ERROR' ? 260 : state === 'PROCESSING' ? 820 : state === 'STOPPED' ? 420 : 560;
|
||
gain.gain.value = 0.025; oscillator.connect(gain); gain.connect(audioContext.destination);
|
||
oscillator.start(); oscillator.stop(audioContext.currentTime + 0.055);
|
||
} catch (_) { /* Audio cues are optional and never block the game. */ }
|
||
}
|
||
voiceController = window.GameVoice.createVoiceController({
|
||
settings: voiceSettings,
|
||
onState(state, message) {
|
||
const microphone = document.querySelector('#microphone');
|
||
const listening = state === 'LISTENING';
|
||
microphone.setAttribute('aria-pressed', String(listening));
|
||
microphone.textContent = listening ? 'Avsluta mikrofon' : 'Starta mikrofon';
|
||
refreshMicrophone();
|
||
if (message) setVoiceStatus(message);
|
||
playVoiceSignal(state === 'IDLE' && /Mikrofonen är avstängd/.test(message) ? 'STOPPED' : state);
|
||
},
|
||
onTranscript(text) {
|
||
return submitMessage(text, 'voice');
|
||
},
|
||
});
|
||
persistVoiceSettings();
|
||
|
||
function addVoiceButton(parent, label, handler) {
|
||
const button = document.createElement('button'); button.type = 'button'; button.textContent = label;
|
||
button.addEventListener('click', handler); parent.append(button); return button;
|
||
}
|
||
function appendVoiceSettings() {
|
||
const item = document.createElement('li'); item.dataset.role = 'voice-controls';
|
||
item.setAttribute('aria-label', 'Röstinställningar och uppläsning');
|
||
const heading = document.createElement('strong'); heading.textContent = 'Röstinställningar'; item.append(heading);
|
||
const fieldset = document.createElement('fieldset');
|
||
const legend = document.createElement('legend'); legend.textContent = 'Välj hur dialogen ska läsas upp'; fieldset.append(legend);
|
||
const modeLabel = document.createElement('label'); modeLabel.textContent = 'Läge';
|
||
const mode = document.createElement('select');
|
||
[['screen-reader', 'Skärmläsare'], ['game-voice', 'Spelets röst'], ['text-only', 'Endast text']].forEach(([value, label]) => {
|
||
const option = document.createElement('option'); option.value = value; option.textContent = label; mode.append(option);
|
||
});
|
||
mode.value = voiceSettings.mode; modeLabel.append(mode); fieldset.append(modeLabel);
|
||
const autoLabel = document.createElement('label'); const auto = document.createElement('input');
|
||
auto.type = 'checkbox'; auto.checked = voiceSettings.autoSpeak;
|
||
autoLabel.append(auto, document.createTextNode(' Läs nya repliker automatiskt')); fieldset.append(autoLabel);
|
||
const speedLabel = document.createElement('label'); speedLabel.textContent = `Talhastighet: ${Number(voiceSettings.rate).toFixed(1)}`;
|
||
const speed = document.createElement('input'); speed.type = 'range'; speed.min = '0.5'; speed.max = '2'; speed.step = '0.1'; speed.value = String(voiceSettings.rate);
|
||
speed.setAttribute('aria-label', 'Talhastighet'); speed.addEventListener('input', () => { speedLabel.firstChild.textContent = `Talhastighet: ${Number(speed.value).toFixed(1)}`; });
|
||
speedLabel.append(speed); fieldset.append(speedLabel);
|
||
const voiceLabel = document.createElement('label'); voiceLabel.textContent = 'Svensk röst';
|
||
const voice = document.createElement('select'); voice.setAttribute('aria-label', 'Svensk röst');
|
||
const defaultVoice = document.createElement('option'); defaultVoice.value = ''; defaultVoice.textContent = 'Systemets standardröst'; voice.append(defaultVoice);
|
||
function refreshVoices() {
|
||
const selected = voiceSettings.voiceName;
|
||
voice.replaceChildren(defaultVoice.cloneNode(true));
|
||
voiceController.getVoices().filter(entry => /^sv(-|$)/i.test(entry.lang)).forEach(entry => {
|
||
const option = document.createElement('option'); option.value = entry.name; option.textContent = `${entry.name} (${entry.lang})`; voice.append(option);
|
||
});
|
||
voice.value = selected;
|
||
}
|
||
refreshVoices();
|
||
window.speechSynthesis?.addEventListener?.('voiceschanged', refreshVoices);
|
||
voice.value = voiceSettings.voiceName; voiceLabel.append(voice); fieldset.append(voiceLabel);
|
||
const signalLabel = document.createElement('label'); const signals = document.createElement('input');
|
||
signals.type = 'checkbox'; signals.checked = voiceSettings.soundSignals;
|
||
signalLabel.append(signals, document.createTextNode(' Ljudsignal vid röstinmatning')); fieldset.append(signalLabel);
|
||
item.append(fieldset);
|
||
const speakLatest = () => {
|
||
if (!voiceController.speak(latestAssistantText)) setVoiceStatus('Talsyntes stöds inte här eller är avstängd. Dialogen finns kvar som text.');
|
||
};
|
||
addVoiceButton(item, 'Läs senaste repliken', speakLatest);
|
||
addVoiceButton(item, 'Upprepa', speakLatest);
|
||
addVoiceButton(item, 'Avbryt uppläsning', () => voiceController.cancelSpeech());
|
||
mode.addEventListener('change', () => { voiceSettings.mode = mode.value; persistVoiceSettings(); });
|
||
auto.addEventListener('change', () => { voiceSettings.autoSpeak = auto.checked; persistVoiceSettings(); });
|
||
speed.addEventListener('change', () => { voiceSettings.rate = Number(speed.value); persistVoiceSettings(); });
|
||
voice.addEventListener('change', () => { voiceSettings.voiceName = voice.value; persistVoiceSettings(); });
|
||
signals.addEventListener('change', () => { voiceSettings.soundSignals = signals.checked; persistVoiceSettings(); });
|
||
addVoiceButton(item, 'Stäng röstinställningar', () => item.remove());
|
||
transcript.append(item); item.scrollIntoView({block: 'end'}); mode.focus();
|
||
}
|
||
function requestVoiceDisclosure() {
|
||
const item = document.createElement('li'); item.dataset.role = 'voice-controls';
|
||
item.setAttribute('aria-label', 'Information om mikrofonen');
|
||
const message = document.createElement('p');
|
||
message.textContent = 'Innan du startar: webbläsarens taligenkänning kan behandla ljud genom en tjänst som webbläsaren väljer. Spelet tar inte emot eller sparar ljudinspelningen. Den färdiga transkriberingen skickas och sparas som din vanliga dialog. Fortsätt bara om du godkänner webbläsarens hantering av tal.';
|
||
item.append(message);
|
||
addVoiceButton(item, 'Jag förstår – starta mikrofonen', () => {
|
||
voiceDisclosureAcknowledged = true;
|
||
item.remove();
|
||
voiceController.startListening();
|
||
});
|
||
transcript.append(item); item.scrollIntoView({block: 'end'});
|
||
item.querySelector('button').focus();
|
||
}
|
||
function handleVoiceCommand(message, source) {
|
||
const parsed = window.GameVoice.parseVoiceCommand(message, {requirePrefix: source === 'voice'});
|
||
if (!parsed) return false;
|
||
if (parsed.command === 'settings') appendVoiceSettings();
|
||
else if (parsed.command === 'repeat') {
|
||
if (!voiceController.speak(latestAssistantText)) setVoiceStatus('Talsyntes stöds inte här eller är avstängd. Dialogen finns kvar som text.');
|
||
} else if (parsed.command === 'stop') {
|
||
voiceController.cancelSpeech(); voiceController.stopListening(); setVoiceStatus('Röstinteraktionen är avbruten.');
|
||
} else if (parsed.command === 'help') {
|
||
appendLine('assistant', 'Skriv röstinställningar för röstlägen. I spelets röstläge kan du tala till mikrofonen eller skriva. Använd “röstkommando upprepa” för att höra senaste repliken igen.');
|
||
} else setVoiceStatus('Okänt röstkommando. Skriv röstinställningar för alternativen.');
|
||
return true;
|
||
}
|
||
document.querySelector('#microphone').addEventListener('click', () => {
|
||
if (voiceController.getState() === 'LISTENING') voiceController.stopListening();
|
||
else if (!voiceDisclosureAcknowledged && voiceController.supportsRecognition()) requestVoiceDisclosure();
|
||
else voiceController.startListening();
|
||
});
|
||
document.addEventListener('visibilitychange', () => { if (document.hidden) voiceController.stopAll(); });
|
||
window.addEventListener('pagehide', () => voiceController.stopAll());
|
||
function setStage(next) {
|
||
stage = next;
|
||
messageField.disabled = false;
|
||
refreshMicrophone();
|
||
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;
|
||
refreshMicrophone();
|
||
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);
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
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();
|
||
latestAssistantText = '';
|
||
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content, {speak: false});
|
||
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;
|
||
const speakingNpc = result.intent === 'speak' ? gameState.world.npc : null;
|
||
appendLine('assistant', result.reply, speakingNpc
|
||
? {speaker: speakingNpc.name, voice: {characterId: speakingNpc.id}}
|
||
: {speaker: 'Berättelsen'});
|
||
} 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}`);
|
||
}
|
||
});
|
||
async function submitMessage(input, source = 'text') {
|
||
if (submitBusy || messageField.disabled) return;
|
||
const message = String(input || '').trim();
|
||
if (!message) return;
|
||
if (handleVoiceCommand(message, source)) { messageField.value = ''; return; }
|
||
submitBusy = true;
|
||
refreshMicrophone();
|
||
voiceController.stopListening();
|
||
voiceController.cancelSpeech();
|
||
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;
|
||
refreshMicrophone();
|
||
if (!play.hidden && !messageField.disabled) messageField.focus();
|
||
}
|
||
}
|
||
document.querySelector('#message-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
await submitMessage(messageField.value);
|
||
});
|
||
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>
|