diff --git a/index.html b/index.html
index d0dac7b..cd275d8 100644
--- a/index.html
+++ b/index.html
@@ -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);
diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh
index 63e258c..0c702c3 100755
--- a/scripts/check-frontend.sh
+++ b/scripts/check-frontend.sh
@@ -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