|
|
@@ -95,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});
|
|
|
@@ -308,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;
|
|
|
@@ -379,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');
|
|
|
@@ -519,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;
|
|
|
@@ -587,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();
|
|
|
@@ -667,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);
|
|
|
|