From f189adb5d67d18dbc263c2c4dc7c5b233d915351 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Thu, 15 Oct 2026 18:07:39 +0200 Subject: [PATCH] Add dialog-based player messaging --- index.html | 102 ++++++++++++++++++++++++++++++++++++++ scripts/check-frontend.sh | 4 ++ 2 files changed, 106 insertions(+) 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