|
|
@@ -63,6 +63,7 @@
|
|
|
|
<button type="button" data-admin-view="players">Spelare</button>
|
|
|
|
<button type="button" data-admin-view="players">Spelare</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>
|
|
|
|
|
|
|
|
<button type="button" data-admin-view="logging">Driftloggar</button>
|
|
|
|
<button type="button" data-admin-view="audit">Revisionslogg</button>
|
|
|
|
<button type="button" data-admin-view="audit">Revisionslogg</button>
|
|
|
|
<button type="button" id="admin-logout">Logga ut</button>
|
|
|
|
<button type="button" id="admin-logout">Logga ut</button>
|
|
|
|
</nav>
|
|
|
|
</nav>
|
|
|
@@ -94,6 +95,9 @@
|
|
|
|
let voiceDisclosureAcknowledged = false;
|
|
|
|
let voiceDisclosureAcknowledged = false;
|
|
|
|
let adminOffset = 0;
|
|
|
|
let adminOffset = 0;
|
|
|
|
const adminPageSize = 50;
|
|
|
|
const adminPageSize = 50;
|
|
|
|
|
|
|
|
let presenceTimer = null;
|
|
|
|
|
|
|
|
let playerMessagesPrimed = false;
|
|
|
|
|
|
|
|
const seenPlayerMessageIds = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
async function request(path, options = {}) {
|
|
|
|
async function request(path, options = {}) {
|
|
|
|
const response = await fetch(path, {credentials: 'same-origin', ...options});
|
|
|
|
const response = await fetch(path, {credentials: 'same-origin', ...options});
|
|
|
@@ -116,6 +120,24 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return line;
|
|
|
|
return line;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function biographyContextText(context) {
|
|
|
|
|
|
|
|
if (!context || typeof context !== 'object') return '';
|
|
|
|
|
|
|
|
const details = [];
|
|
|
|
|
|
|
|
if (typeof context.current_situation === 'string' && context.current_situation.trim()) {
|
|
|
|
|
|
|
|
details.push(`Nuvarande situation: ${context.current_situation.trim()}.`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const [key, label] of [
|
|
|
|
|
|
|
|
['skills', 'Färdigheter'],
|
|
|
|
|
|
|
|
['possessions', 'Ägodelar som nämnts'],
|
|
|
|
|
|
|
|
['obligations', 'Åtaganden'],
|
|
|
|
|
|
|
|
['past_events', 'Tidigare händelser'],
|
|
|
|
|
|
|
|
['known_people', 'Kända personer'],
|
|
|
|
|
|
|
|
]) {
|
|
|
|
|
|
|
|
const values = context[key];
|
|
|
|
|
|
|
|
if (Array.isArray(values) && values.length) details.push(`${label}: ${values.join(', ')}.`);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return details.length ? `\n\nStartuppgifter att granska: ${details.join(' ')}` : '';
|
|
|
|
|
|
|
|
}
|
|
|
|
function explainDialogueError(error) {
|
|
|
|
function explainDialogueError(error) {
|
|
|
|
const code = String(error?.message || '');
|
|
|
|
const code = String(error?.message || '');
|
|
|
|
const known = {
|
|
|
|
const known = {
|
|
|
@@ -289,6 +311,8 @@
|
|
|
|
messageField.focus();
|
|
|
|
messageField.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function showAdmin() {
|
|
|
|
function showAdmin() {
|
|
|
|
|
|
|
|
if (presenceTimer) clearInterval(presenceTimer);
|
|
|
|
|
|
|
|
presenceTimer = null;
|
|
|
|
voiceController.stopAll();
|
|
|
|
voiceController.stopAll();
|
|
|
|
auth.hidden = true;
|
|
|
|
auth.hidden = true;
|
|
|
|
play.hidden = true;
|
|
|
|
play.hidden = true;
|
|
|
@@ -360,6 +384,7 @@
|
|
|
|
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`);
|
|
|
|
const trace = await request(`/api/admin/players/${encodeURIComponent(accountId)}/trace?limit=200`);
|
|
|
|
const entries = [
|
|
|
|
const entries = [
|
|
|
|
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
|
|
|
|
...trace.events.map(event => ({kind: 'Världshändelse', at: event.recorded_at, order: event.commit_sequence, data: event})),
|
|
|
|
|
|
|
|
...trace.player_messages.map(message => ({kind: 'Privat spelarmeddelande', at: message.recorded_at, order: message.sequence, data: message})),
|
|
|
|
...trace.ai_interactions.map(interaction => ({kind: `AI ${interaction.prompt_id} · ${interaction.outcome}`, at: interaction.requested_at, order: interaction.correlation_id, data: interaction}))
|
|
|
|
...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)));
|
|
|
|
].sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
|
|
adminElement('h2', 'Kronologiskt spelarspår');
|
|
|
|
adminElement('h2', 'Kronologiskt spelarspår');
|
|
|
@@ -392,6 +417,32 @@
|
|
|
|
adminElement('h2', 'Revisionslogg');
|
|
|
|
adminElement('h2', 'Revisionslogg');
|
|
|
|
for (const entry of result.entries) adminJson(`${entry.created_at} · ${entry.action} · ${entry.admin_account_id || entry.operator_identity}`, entry);
|
|
|
|
for (const entry of result.entries) adminJson(`${entry.created_at} · ${entry.action} · ${entry.admin_account_id || entry.operator_identity}`, entry);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function loadAdminLogging() {
|
|
|
|
|
|
|
|
adminContent.replaceChildren();
|
|
|
|
|
|
|
|
const result = await request('/api/admin/settings/logging');
|
|
|
|
|
|
|
|
adminElement('h2', 'Driftloggar');
|
|
|
|
|
|
|
|
adminElement('p', 'debug=true skriver detaljerade tekniska händelser för HTTP, AI-anrop, validering och commit. debug=false behåller korta tekniska åtkomstloggar och fel. Ändringen sparas i databasen och börjar gälla utan omstart. Spelartext, promptinnehåll, AI-svar, sessionsuppgifter och hemligheter skrivs aldrig till poddloggarna.');
|
|
|
|
|
|
|
|
const form = adminElement('form');
|
|
|
|
|
|
|
|
const label = adminElement('label', undefined, form);
|
|
|
|
|
|
|
|
const checkbox = document.createElement('input');
|
|
|
|
|
|
|
|
checkbox.type = 'checkbox'; checkbox.name = 'debug'; checkbox.checked = result.debug;
|
|
|
|
|
|
|
|
label.append(checkbox, document.createTextNode(' Aktivera verbose felsökningsloggar (debug=true)'));
|
|
|
|
|
|
|
|
const save = adminElement('button', 'Spara loggnivå', form); save.type = 'submit';
|
|
|
|
|
|
|
|
const status = adminElement('p', `Aktuell nivå: debug=${result.debug}`);
|
|
|
|
|
|
|
|
status.setAttribute('role', 'status');
|
|
|
|
|
|
|
|
form.addEventListener('submit', async event => {
|
|
|
|
|
|
|
|
event.preventDefault(); save.disabled = true;
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const updated = await request('/api/admin/settings/logging', {
|
|
|
|
|
|
|
|
method: 'PUT', headers: {'content-type': 'application/json'},
|
|
|
|
|
|
|
|
body: JSON.stringify({debug: checkbox.checked}),
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
status.textContent = `Aktuell nivå: debug=${updated.debug}. Ändringen gäller utan omstart.`;
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
status.textContent = `Loggnivån kunde inte ändras: ${error.message}`;
|
|
|
|
|
|
|
|
} finally { save.disabled = false; }
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
async function openAdmin() {
|
|
|
|
async function openAdmin() {
|
|
|
|
showAdmin();
|
|
|
|
showAdmin();
|
|
|
|
adminContent.setAttribute('aria-busy', 'true');
|
|
|
|
adminContent.setAttribute('aria-busy', 'true');
|
|
|
@@ -474,8 +525,22 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
await loadSavedDialogue();
|
|
|
|
await loadSavedDialogue();
|
|
|
|
setStage('game');
|
|
|
|
setStage('game');
|
|
|
|
|
|
|
|
startPlayerPresence();
|
|
|
|
|
|
|
|
announcePlayerChatHelp();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function announcePlayerChatHelp() {
|
|
|
|
|
|
|
|
const key = 'ai-spel.player-chat-help.v1';
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
if (localStorage.getItem(key) === 'true') return;
|
|
|
|
|
|
|
|
localStorage.setItem(key, 'true');
|
|
|
|
|
|
|
|
} catch (_) { /* The help remains available through dialogue questions. */ }
|
|
|
|
|
|
|
|
appendLine('assistant', 'Du kan fråga vilka andra spelare som är här, säga “Säg till Robin: hej” för att skriva direkt till en spelare eller be mig visa meddelanden från spelare.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
async function handleLogout() {
|
|
|
|
async function handleLogout() {
|
|
|
|
|
|
|
|
if (presenceTimer) clearInterval(presenceTimer);
|
|
|
|
|
|
|
|
presenceTimer = null;
|
|
|
|
|
|
|
|
playerMessagesPrimed = false;
|
|
|
|
|
|
|
|
seenPlayerMessageIds.clear();
|
|
|
|
await request('/api/logout', {method: 'POST'});
|
|
|
|
await request('/api/logout', {method: 'POST'});
|
|
|
|
if (location.pathname !== '/admin') appendLine('assistant', 'Du har loggat ut. Logga in igen för att fortsätta spela.');
|
|
|
|
if (location.pathname !== '/admin') appendLine('assistant', 'Du har loggat ut. Logga in igen för att fortsätta spela.');
|
|
|
|
characterDraft = null;
|
|
|
|
characterDraft = null;
|
|
|
@@ -515,7 +580,7 @@
|
|
|
|
body: JSON.stringify({input: message, current_draft: characterDraft}),
|
|
|
|
body: JSON.stringify({input: message, current_draft: characterDraft}),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
characterDraft = proposal.draft;
|
|
|
|
characterDraft = proposal.draft;
|
|
|
|
appendLine('assistant', `${proposal.message}\n\nFörslag: ${characterDraft.name}, ${characterDraft.age} år.\n${characterDraft.biography}\n\nSkriv ändringar eller “godkänn”.`);
|
|
|
|
appendLine('assistant', `${proposal.message}\n\nFörslag: ${characterDraft.name}, ${characterDraft.age} år.\n${characterDraft.biography}${biographyContextText(characterDraft.life_context)}\n\nSkriv ändringar eller “godkänn”.`);
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
appendLine('assistant', `Jag kunde inte ta fram ett biografiförslag: ${error.message}. Försök igen när tjänsten svarar.`);
|
|
|
|
appendLine('assistant', `Jag kunde inte ta fram ett biografiförslag: ${error.message}. Försök igen när tjänsten svarar.`);
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -542,6 +607,7 @@
|
|
|
|
else await proposeWorld(message);
|
|
|
|
else await proposeWorld(message);
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (await handlePlayerMessageCommand(message)) return;
|
|
|
|
const idempotencyKey = pendingDialogue?.message === message
|
|
|
|
const idempotencyKey = pendingDialogue?.message === message
|
|
|
|
? pendingDialogue.idempotencyKey
|
|
|
|
? pendingDialogue.idempotencyKey
|
|
|
|
: crypto.randomUUID();
|
|
|
|
: crypto.randomUUID();
|
|
|
@@ -622,6 +688,87 @@
|
|
|
|
if (!play.hidden && !messageField.disabled) messageField.focus();
|
|
|
|
if (!play.hidden && !messageField.disabled) messageField.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function handlePlayerMessageCommand(message) {
|
|
|
|
|
|
|
|
const listPlayers = /^(?:vilka (?:andra )?spelare är här|finns det andra spelare här)\??$/i.test(message.trim());
|
|
|
|
|
|
|
|
const sendMatch = /^(?:säg|skriv|skicka) till ([^:]{1,80}):\s*(.{1,4000})$/is.exec(message.trim());
|
|
|
|
|
|
|
|
const readMessages = /^(?:visa|läs upp) (?:de senaste )?meddelanden från spelare\.?$/i.test(message.trim());
|
|
|
|
|
|
|
|
if (!listPlayers && !sendMatch && !readMessages) return false;
|
|
|
|
|
|
|
|
appendLine('user', message);
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
if (listPlayers) {
|
|
|
|
|
|
|
|
const players = await request('/api/players/nearby');
|
|
|
|
|
|
|
|
appendLine('assistant', players.length
|
|
|
|
|
|
|
|
? `Här finns ${players.map(player => player.name).join(', ')} att prata med.`
|
|
|
|
|
|
|
|
: 'Just nu finns inga andra inloggade spelare på platsen.');
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (readMessages) {
|
|
|
|
|
|
|
|
const messages = await request('/api/player/messages');
|
|
|
|
|
|
|
|
const recent = messages.slice(-10);
|
|
|
|
|
|
|
|
if (recent.length === 0) appendLine('assistant', 'Du har inga sparade meddelanden från andra spelare.');
|
|
|
|
|
|
|
|
else for (const entry of recent) {
|
|
|
|
|
|
|
|
seenPlayerMessageIds.add(entry.id);
|
|
|
|
|
|
|
|
const isIncoming = entry.direction === 'incoming';
|
|
|
|
|
|
|
|
appendLine(isIncoming ? 'assistant' : 'user', entry.message, isIncoming
|
|
|
|
|
|
|
|
? {speaker: entry.sender_name, voice: {characterId: entry.sender_character_id, voiceIdentity: entry.sender_voice_identity}}
|
|
|
|
|
|
|
|
: {speaker: 'Du', speak: false});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const players = await request('/api/players/nearby');
|
|
|
|
|
|
|
|
const targetName = sendMatch[1].trim();
|
|
|
|
|
|
|
|
const matches = players.filter(player => player.name.localeCompare(targetName, 'sv', {sensitivity: 'base'}) === 0);
|
|
|
|
|
|
|
|
if (matches.length === 0) {
|
|
|
|
|
|
|
|
appendLine('assistant', `Jag hittar ingen inloggad spelare som heter ${targetName} på platsen. Fråga vilka andra spelare som är här.`);
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (matches.length > 1) {
|
|
|
|
|
|
|
|
appendLine('assistant', `Flera spelare heter ${targetName}. Säg vilket namn de använder i spelet.`);
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const key = pendingPlayerMessage?.text === sendMatch[2] && pendingPlayerMessage?.target === matches[0].character_id
|
|
|
|
|
|
|
|
? pendingPlayerMessage.key
|
|
|
|
|
|
|
|
: crypto.randomUUID();
|
|
|
|
|
|
|
|
pendingPlayerMessage = {text: sendMatch[2], target: matches[0].character_id, key};
|
|
|
|
|
|
|
|
const result = await request(`/api/players/${encodeURIComponent(matches[0].character_id)}/messages`, {
|
|
|
|
|
|
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
|
|
|
|
|
|
body: JSON.stringify({message: sendMatch[2], idempotency_key: key}),
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
pendingPlayerMessage = null;
|
|
|
|
|
|
|
|
appendLine('assistant', `Ditt meddelande skickades till ${matches[0].name}.`);
|
|
|
|
|
|
|
|
return Boolean(result.message_id);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
messageField.value = message;
|
|
|
|
|
|
|
|
appendLine('assistant', `Spelarmeddelandet kunde inte hanteras: ${explainDialogueError(error)}`);
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
let pendingPlayerMessage = null;
|
|
|
|
|
|
|
|
async function startPlayerPresence() {
|
|
|
|
|
|
|
|
if (presenceTimer) clearInterval(presenceTimer);
|
|
|
|
|
|
|
|
const refresh = async (announceNewMessages) => {
|
|
|
|
|
|
|
|
if (play.hidden || stage !== 'game') return;
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await request('/api/presence/heartbeat', {method: 'POST'});
|
|
|
|
|
|
|
|
const messages = await request('/api/player/messages');
|
|
|
|
|
|
|
|
for (const entry of messages) {
|
|
|
|
|
|
|
|
if (seenPlayerMessageIds.has(entry.id)) continue;
|
|
|
|
|
|
|
|
if (announceNewMessages && entry.direction === 'incoming'
|
|
|
|
|
|
|
|
&& (submitBusy || ['LISTENING', 'PROCESSING', 'SPEAKING'].includes(voiceController.getState()))) continue;
|
|
|
|
|
|
|
|
seenPlayerMessageIds.add(entry.id);
|
|
|
|
|
|
|
|
if (announceNewMessages && entry.direction === 'incoming') {
|
|
|
|
|
|
|
|
appendLine('assistant', entry.message, {
|
|
|
|
|
|
|
|
speaker: entry.sender_name,
|
|
|
|
|
|
|
|
voice: {characterId: entry.sender_character_id, voiceIdentity: entry.sender_voice_identity},
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
playerMessagesPrimed = true;
|
|
|
|
|
|
|
|
} catch (_) { /* Presence is a hint; the text game remains usable if it is unavailable. */ }
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
await refresh(playerMessagesPrimed);
|
|
|
|
|
|
|
|
presenceTimer = setInterval(() => refresh(true), 30_000);
|
|
|
|
|
|
|
|
}
|
|
|
|
document.querySelector('#message-form').addEventListener('submit', async (event) => {
|
|
|
|
document.querySelector('#message-form').addEventListener('submit', async (event) => {
|
|
|
|
event.preventDefault();
|
|
|
|
event.preventDefault();
|
|
|
|
await submitMessage(messageField.value);
|
|
|
|
await submitMessage(messageField.value);
|
|
|
@@ -632,6 +779,7 @@
|
|
|
|
if (button.dataset.adminView === 'players') await loadAdminPlayers();
|
|
|
|
if (button.dataset.adminView === 'players') await loadAdminPlayers();
|
|
|
|
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();
|
|
|
|
|
|
|
|
else if (button.dataset.adminView === 'logging') await loadAdminLogging();
|
|
|
|
else await loadAdminAudit();
|
|
|
|
else await loadAdminAudit();
|
|
|
|
} catch (error) { adminContent.replaceChildren(); adminElement('p', `Vyn kunde inte läsas: ${error.message}`); }
|
|
|
|
} catch (error) { adminContent.replaceChildren(); adminElement('p', `Vyn kunde inte läsas: ${error.message}`); }
|
|
|
|
finally { adminContent.setAttribute('aria-busy', 'false'); }
|
|
|
|
finally { adminContent.setAttribute('aria-busy', 'false'); }
|
|
|
|