This commit is contained in:
+102
@@ -95,6 +95,9 @@
|
||||
let voiceDisclosureAcknowledged = false;
|
||||
let adminOffset = 0;
|
||||
const adminPageSize = 50;
|
||||
let presenceTimer = null;
|
||||
let playerMessagesPrimed = false;
|
||||
const seenPlayerMessageIds = new Set();
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(path, {credentials: 'same-origin', ...options});
|
||||
@@ -308,6 +311,8 @@
|
||||
messageField.focus();
|
||||
}
|
||||
function showAdmin() {
|
||||
if (presenceTimer) clearInterval(presenceTimer);
|
||||
presenceTimer = null;
|
||||
voiceController.stopAll();
|
||||
auth.hidden = true;
|
||||
play.hidden = true;
|
||||
@@ -379,6 +384,7 @@
|
||||
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.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}))
|
||||
].sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
||||
adminElement('h2', 'Kronologiskt spelarspår');
|
||||
@@ -519,8 +525,22 @@
|
||||
}
|
||||
await loadSavedDialogue();
|
||||
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() {
|
||||
if (presenceTimer) clearInterval(presenceTimer);
|
||||
presenceTimer = null;
|
||||
playerMessagesPrimed = false;
|
||||
seenPlayerMessageIds.clear();
|
||||
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.');
|
||||
characterDraft = null;
|
||||
@@ -587,6 +607,7 @@
|
||||
else await proposeWorld(message);
|
||||
return;
|
||||
}
|
||||
if (await handlePlayerMessageCommand(message)) return;
|
||||
const idempotencyKey = pendingDialogue?.message === message
|
||||
? pendingDialogue.idempotencyKey
|
||||
: crypto.randomUUID();
|
||||
@@ -667,6 +688,87 @@
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
await submitMessage(messageField.value);
|
||||
|
||||
@@ -30,6 +30,10 @@ grep -q "event.key === 'Enter' && !event.shiftKey" index.html
|
||||
grep -q "request('/api/character/draft'" index.html
|
||||
grep -q "request('/api/world/draft'" index.html
|
||||
grep -q "request('/api/dialogue'" index.html
|
||||
grep -q "request('/api/presence/heartbeat'" index.html
|
||||
grep -q "request('/api/players/nearby'" index.html
|
||||
grep -q "request('/api/player/messages'" index.html
|
||||
grep -q "Säg till Robin" index.html
|
||||
grep -q "result.intent === 'move' || result.intent === 'meet'" index.html
|
||||
grep -q 'dialogue_temporarily_unavailable:' index.html
|
||||
grep -q 'id="admin-view"' index.html
|
||||
|
||||
Reference in New Issue
Block a user