From 3ae550bb76d7e064b598114c8b911d0445f90248 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sat, 14 Mar 2026 20:07:38 +1100 Subject: [PATCH] feat: add Number Match game command for terminal-based number matching puzzle --- website/logos/bearanticropped.png | Bin 0 -> 88611 bytes website/matrix-client.js | 1115 +++++++++++++++++++ website/scripts/commands/help.js | 1 + website/scripts/commands/numbermatch.js | 905 +++++++++++++++ website/terminal.js | 1346 +++-------------------- 5 files changed, 2185 insertions(+), 1182 deletions(-) create mode 100644 website/logos/bearanticropped.png create mode 100644 website/matrix-client.js create mode 100644 website/scripts/commands/numbermatch.js diff --git a/website/logos/bearanticropped.png b/website/logos/bearanticropped.png new file mode 100644 index 0000000000000000000000000000000000000000..0980aedace22745707df4d612b38c2789caf12f0 GIT binary patch literal 88611 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

USuBSSz*p`+pa13;z!{T(9uXyGlFuE* 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 True if bridge loaded successfully + */ +async function initMatrixBridge() { + if (!config.bridgeUrl) { + throw new Error('Bridge URL not configured'); + } + + if (matrixBridge.iframe) { + return matrixBridge.ready; + } + + return new Promise((resolve) => { + const iframe = document.createElement('iframe'); + iframe.src = config.bridgeUrl; + iframe.style.display = 'none'; + iframe.style.position = 'absolute'; + iframe.style.width = '0'; + iframe.style.height = '0'; + iframe.style.border = 'none'; + + const timeout = setTimeout(() => { + console.error('[Matrix Bridge] Failed to load iframe bridge'); + matrixBridge.ready = false; + resolve(false); + }, 10000); + + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'matrix:bridge:ready') { + clearTimeout(timeout); + matrixBridge.ready = true; + console.log('[Matrix Bridge] Iframe bridge ready'); + resolve(true); + } + }); + + document.body.appendChild(iframe); + matrixBridge.iframe = iframe; + }); +} + +/** + * Send a request to the Matrix bridge via postMessage + * @param {string} type - Request type (e.g. 'matrix:auth', 'matrix:sendMessage') + * @param {object} payload - Request payload + * @returns {Promise} Response from bridge + */ +function matrixBridgeRequest(type, payload) { + return new Promise((resolve, reject) => { + if (!matrixBridge.iframe || !matrixBridge.ready) { + reject(new Error('Matrix bridge not initialized')); + return; + } + + const requestId = `req_${++matrixBridge.requestCounter}`; + const timeout = setTimeout(() => { + matrixBridge.pendingRequests.delete(requestId); + reject(new Error('Matrix bridge request timeout')); + }, 30000); + + matrixBridge.pendingRequests.set(requestId, { resolve, reject, timeout }); + + matrixBridge.iframe.contentWindow.postMessage({ + type, + requestId, + payload + }, config.bridgeUrl); + }); +} + +/** + * Handle responses from the Matrix bridge + */ +window.addEventListener('message', (event) => { + if (config.bridgeUrl && event.origin !== new URL(config.bridgeUrl).origin) { + return; + } + + const { type, requestId, payload } = event.data; + + if (!type || !requestId || !type.includes(':response')) { + return; + } + + const pending = matrixBridge.pendingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeout); + matrixBridge.pendingRequests.delete(requestId); + pending.resolve(payload); + } +}); + +// Matrix API helper +export const matrixApi = async (endpoint, method = 'GET', body = null) => { + if (!window.matrixSession) return null; + + // Use iframe bridge if configured + if (config.useBridge) { + if (!matrixBridge.ready) { + const initialized = await initMatrixBridge(); + if (!initialized) { + return { + errcode: 'M_BRIDGE_UNAVAILABLE', + error: 'Matrix bridge failed to initialize' + }; + } + } + + try { + const result = await matrixBridgeRequest('matrix:api', { + endpoint, + method, + body, + accessToken: window.matrixSession.accessToken + }); + return result; + } catch (error) { + return { + errcode: 'M_BRIDGE_ERROR', + error: error.message + }; + } + } + + // Direct API call + const url = `${config.homeserver}/_matrix/client/r0${endpoint}`; + const headers = { + 'Content-Type': 'application/json' + }; + + if (window.matrixSession.accessToken) { + headers['Authorization'] = `Bearer ${window.matrixSession.accessToken}`; + } + + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch(url, options); + return response.json(); +}; + +/** + * Fetch the latest public message from a room + * @param {string} roomAlias - Room alias (e.g. #room:server.com) + * @returns {Promise<{sender: string, body: string, timestamp: number} | null>} + */ +export async function fetchPublicLastMessage(roomAlias) { + if (!config.publicReadToken) { + console.warn('No public read token configured'); + return null; + } + + try { + // Use iframe bridge if configured + if (config.useBridge) { + if (!matrixBridge.ready) { + await initMatrixBridge(); + } + + const result = await matrixBridgeRequest('matrix:fetchLastMessage', { + roomAlias, + publicToken: config.publicReadToken + }); + + if (!result || result.error || !Array.isArray(result.chunk)) { + return null; + } + + const lastEvent = result.chunk.find(e => + e && e.type === 'm.room.message' && e.content && e.content.body + ); + + if (!lastEvent) return null; + + return { + sender: lastEvent.sender, + body: lastEvent.content.body, + timestamp: lastEvent.origin_server_ts || Date.now() + }; + } + + // Direct API call + const resolvedAlias = encodeURIComponent(roomAlias); + const roomResponse = await fetch( + `${config.homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, + { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } + ); + + if (!roomResponse.ok) return null; + + const roomData = await roomResponse.json(); + const roomId = roomData?.room_id; + if (!roomId) return null; + + const messagesResponse = await fetch( + `${config.homeserver}/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=10`, + { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } + ); + + if (!messagesResponse.ok) return null; + + const messagesData = await messagesResponse.json(); + const lastEvent = messagesData.chunk?.find(e => + e && e.type === 'm.room.message' && e.content && e.content.body + ); + + if (!lastEvent) return null; + + return { + sender: lastEvent.sender, + body: lastEvent.content.body, + timestamp: lastEvent.origin_server_ts || Date.now() + }; + } catch (error) { + console.error('Failed to fetch public last message:', error); + return null; + } +} + +/** + * Fetch user presence + * @param {string} userId - Full Matrix user ID + * @returns {Promise<{presence: string, lastActive: number} | null>} + */ +export async function fetchPublicPresence(userId) { + if (!config.publicReadToken) { + console.warn('No public read token configured'); + return null; + } + + try { + // Use iframe bridge if configured + if (config.useBridge) { + if (!matrixBridge.ready) { + await initMatrixBridge(); + } + + const result = await matrixBridgeRequest('matrix:fetchPresence', { + userId, + publicToken: config.publicReadToken + }); + + if (result && !result.error && result.presence) { + return { + presence: result.presence, + lastActive: result.last_active_ago || 0 + }; + } + return null; + } + + // Direct API call + const response = await fetch( + `${config.homeserver}/_matrix/client/r0/presence/${encodeURIComponent(userId)}/status`, + { headers: { 'Authorization': `Bearer ${config.publicReadToken}` } } + ); + + if (!response.ok) return null; + + const data = await response.json(); + return { + presence: data.presence, + lastActive: data.last_active_ago || 0 + }; + } catch (error) { + console.error('Failed to fetch presence:', error); + return null; + } +} + +/** + * Format a unix-millisecond timestamp as relative age text. + * @param {number} timestampMs - Epoch timestamp in milliseconds + * @returns {string} Relative time label + */ +export function formatTimeAgo(timestampMs) { + if (typeof timestampMs !== 'number') { + return 'unknown'; + } + + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000)); + if (elapsedSeconds < 60) { + return 'just now'; + } + + if (elapsedSeconds < 3600) { + return `${Math.floor(elapsedSeconds / 60)}m ago`; + } + + if (elapsedSeconds < 86400) { + return `${Math.floor(elapsedSeconds / 3600)}h ago`; + } + + return `${Math.floor(elapsedSeconds / 86400)}d ago`; +} + +// Get display name for a user (with caching) +async function getDisplayName(userId) { + // Check cache first + if (chatMode.displayNames[userId]) { + return chatMode.displayNames[userId]; + } + + try { + const data = await matrixApi(`/profile/${encodeURIComponent(userId)}/displayname`, 'GET'); + const displayName = data.displayname || userId.split(':')[0].substring(1); + chatMode.displayNames[userId] = displayName; + return displayName; + } catch (error) { + // Fallback to username part + const fallback = userId.split(':')[0].substring(1); + chatMode.displayNames[userId] = fallback; + return fallback; + } +} + +// Get color for user based on their ID (consistent hashing) +function getUserColor(username) { + // Color palette that works well on dark green background + const colors = [ + '\x1b[91m', // bright red + '\x1b[92m', // bright green + '\x1b[93m', // bright yellow + '\x1b[94m', // bright blue + '\x1b[95m', // bright magenta + '\x1b[96m', // bright cyan + '\x1b[33m', // yellow + '\x1b[35m', // magenta + '\x1b[36m', // cyan + ]; + + // Simple hash function + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = ((hash << 5) - hash) + username.charCodeAt(i); + hash = hash & hash; // Convert to 32bit integer + } + + return colors[Math.abs(hash) % colors.length]; +} + +// Check if display name is already taken +export async function isDisplayNameTaken(newName) { + try { + const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); + if (members && members.joined) { + for (const [userId, member] of Object.entries(members.joined)) { + // Skip our own user + if (userId === window.matrixSession.userId) continue; + + const displayName = member.display_name || userId.split(':')[0].substring(1); + if (displayName.toLowerCase() === newName.toLowerCase()) { + return true; + } + } + } + return false; + } catch (error) { + console.error('Error checking display names:', error); + return false; // Allow on error + } +} + +/** + * Determine if a Matrix display name appears unchanged/default. + * @param {string} displayName - Current display name + * @returns {boolean} True when name looks like an auto-generated default + */ +function isDefaultDisplayName(displayName) { + if (!displayName) { + return true; + } + + const trimmedName = displayName.trim(); + if (!trimmedName) { + return true; + } + + const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidV4Pattern.test(trimmedName); +} + +/** + * Check whether to show a nickname setup hint for the current user. + * @returns {Promise} True when the user should be prompted to change nickname + */ +async function shouldShowNicknameHint() { + if (!window.matrixSession || !window.matrixSession.userId) { + return false; + } + + try { + const encodedUserId = encodeURIComponent(window.matrixSession.userId); + const profileData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); + const displayName = profileData && typeof profileData.displayname === 'string' + ? profileData.displayname + : ''; + + return isDefaultDisplayName(displayName); + } catch (error) { + return false; + } +} + +/** + * Sync mention directory for autocomplete and mention rendering. + * @param {boolean} force - When true, bypass cache time + * @returns {Promise} + */ +async function syncMentionDirectory(force = false) { + if (!window.matrixSession || !window.matrixSession.roomId) { + return; + } + + const now = Date.now(); + if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { + return; + } + + try { + const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); + if (!members || !members.joined) { + return; + } + + const directory = []; + Object.entries(members.joined).forEach(([userId, member]) => { + const fallbackName = userId.split(':')[0].substring(1); + const displayName = member && member.display_name ? member.display_name : fallbackName; + chatMode.displayNames[userId] = displayName; + directory.push({ userId, displayName }); + }); + + chatMode.mentionDirectory = directory; + chatMode.mentionLoadedAt = now; + } catch (error) { + // Ignore mention directory refresh failures. + } +} + +/** + * Get mention autocomplete matches for query. + * @param {string} query - Partial display name text without @ + * @returns {Array<{userId: string, displayName: string}>} Sorted matches + */ +function getMentionMatches(query) { + const queryLower = query.toLowerCase(); + const matches = chatMode.mentionDirectory.filter((entry) => { + const display = entry.displayName.toLowerCase(); + const localPart = entry.userId.split(':')[0].substring(1).toLowerCase(); + return display.startsWith(queryLower) || localPart.startsWith(queryLower); + }); + + matches.sort((left, right) => left.displayName.localeCompare(right.displayName)); + return matches; +} + +/** + * Escape text for HTML output. + * @param {string} value - Raw text + * @returns {string} HTML-escaped text + */ +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Extract current mention token context from the input cursor. + * @returns {{atIndex: number, cursorPosition: number, tokenText: string} | null} Token info or null + */ +function getMentionTokenContext() { + const cursorPosition = inlineInput.selectionStart; + const fullValue = inlineInput.value; + const textBeforeCursor = fullValue.slice(0, cursorPosition); + const tokenStart = textBeforeCursor.search(/(?:^|\s)@[^\s]*$/); + + if (tokenStart === -1) { + return null; + } + + const atIndex = textBeforeCursor.indexOf('@', tokenStart); + if (atIndex === -1) { + return null; + } + + const tokenText = textBeforeCursor.slice(atIndex + 1); + if (!tokenText || tokenText.includes(':')) { + return null; + } + + return { atIndex, cursorPosition, tokenText }; +} + +/** + * Render mention suggestions above the inline input. + * @returns {void} + */ +function renderMentionSuggestions() { + const { matches, index } = chatMode.mentionAutocomplete; + if (!chatMode.active || matches.length === 0) { + mentionSuggestions.style.display = 'none'; + return; + } + + const limitedMatches = matches.slice(0, 5); + mentionSuggestions.innerHTML = limitedMatches.map((entry, entryIndex) => { + const selectedClass = index >= 0 && entryIndex === (index % limitedMatches.length) ? ' selected' : ''; + return `@${escapeHtml(entry.displayName)}`; + }).join(''); + mentionSuggestions.style.display = 'block'; + positionInlineInput(); +} + +/** + * Check whether mention suggestions are currently visible. + * @returns {boolean} True when suggestion strip is active + */ +export function hasVisibleMentionSuggestions() { + return chatMode.active + && mentionSuggestions.style.display !== 'none' + && chatMode.mentionAutocomplete.matches.length > 0; +} + +/** + * Hide mention suggestion UI and clear state. + * @returns {void} + */ +export function resetMentionAutocomplete() { + chatMode.mentionAutocomplete = { + tokenStart: -1, + tokenEnd: -1, + matches: [], + index: 0 + }; + mentionSuggestions.style.display = 'none'; + mentionSuggestions.innerHTML = ''; +} + +/** + * Refresh mention suggestions based on current cursor token. + * @returns {Promise} + */ +export async function refreshMentionSuggestionsFromInput() { + if (!chatMode.active) { + resetMentionAutocomplete(); + return; + } + + const context = getMentionTokenContext(); + if (!context) { + resetMentionAutocomplete(); + return; + } + + await syncMentionDirectory(); + const matches = getMentionMatches(context.tokenText); + if (matches.length === 0) { + resetMentionAutocomplete(); + return; + } + + chatMode.mentionAutocomplete = { + tokenStart: context.atIndex, + tokenEnd: context.cursorPosition, + matches, + index: -1 + }; + renderMentionSuggestions(); +} + +/** + * Replace selected mention token in input with selected display name. + * @param {{userId: string, displayName: string}} selectedEntry - Selected mention entry + * @returns {void} + */ +function applyMentionReplacement(selectedEntry) { + const rangeStart = chatMode.mentionAutocomplete.tokenStart; + const rangeEnd = chatMode.mentionAutocomplete.tokenEnd; + if (rangeStart < 0 || rangeEnd < 0) { + return; + } + + const fullValue = inlineInput.value; + const valueBeforeMention = fullValue.slice(0, rangeStart); + const valueAfterMention = fullValue.slice(rangeEnd); + const mentionText = `@${selectedEntry.displayName}`; + const hasTrailingSpace = valueAfterMention.startsWith(' '); + const nextValue = `${valueBeforeMention}${mentionText}${hasTrailingSpace ? '' : ' '}${valueAfterMention}`; + const nextCursor = valueBeforeMention.length + mentionText.length + (hasTrailingSpace ? 0 : 1); + + inlineInput.value = nextValue; + inlineInput.setSelectionRange(nextCursor, nextCursor); + chatMode.inputLine = nextValue; + chatMode.mentionAutocomplete.tokenEnd = nextCursor; +} + +/** + * Commit the currently selected mention suggestion into input text. + * @returns {boolean} True when a suggestion was applied + */ +export function commitSelectedMentionSuggestion() { + if (!hasVisibleMentionSuggestions()) { + return false; + } + + if (chatMode.mentionAutocomplete.index < 0) { + chatMode.mentionAutocomplete.index = 0; + } + + const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; + applyMentionReplacement(selectedEntry); + resetMentionAutocomplete(); + return true; +} + +/** + * Resolve typed @displayname mentions to Matrix user IDs before sending. + * @param {string} message - Outgoing message text + * @returns {string} Message with mentions converted to @user:server + */ +function transformOutgoingMentions(message) { + if (!message || !chatMode.mentionDirectory.length) { + return message; + } + + return message.replace(/(^|\s)@([^\s:]+)\b/g, (fullMatch, leadingWhitespace, mentionValue) => { + const matchedEntry = chatMode.mentionDirectory.find((entry) => { + return entry.displayName.toLowerCase() === mentionValue.toLowerCase(); + }); + + if (!matchedEntry) { + return fullMatch; + } + + return `${leadingWhitespace}${matchedEntry.userId}`; + }); +} + +/** + * Build a plain Matrix text payload with canonical mention IDs. + * @param {string} message - Outgoing raw message + * @returns {{msgtype: string, body: string}} Matrix content payload + */ +function buildPlainMentionMessageContent(message) { + return { + msgtype: 'm.text', + body: transformOutgoingMentions(message) + }; +} + +/** + * Check whether canonical Matrix mentions exist in text. + * @param {string} text - Message text + * @returns {boolean} True when one or more @user:server mentions are present + */ +function hasCanonicalMentions(text) { + return /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/.test(text); +} + +/** + * Build formatted HTML body with matrix.to links for mentions. + * @param {string} resolvedBody - Message body with canonical mention IDs + * @returns {string} HTML formatted body + */ +function buildFormattedMentionBody(resolvedBody) { + const mentionRegex = /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g; + return escapeHtml(resolvedBody).replace(mentionRegex, (matchedValue, userBody) => { + const userId = `@${userBody}`; + const knownDisplayName = chatMode.displayNames[userId]; + const fallbackDisplayName = userId.split(':')[0].substring(1); + const displayName = knownDisplayName || fallbackDisplayName; + const matrixToUrl = `https://matrix.to/#/${userId}`; + return `@${escapeHtml(displayName)}`; + }); +} + +/** + * Build rich mention payload without m.mentions for compatibility fallback. + * @param {string} message - Outgoing raw message + * @returns {{msgtype: string, body: string, format?: string, formatted_body?: string}} Matrix content payload + */ +function buildRichMentionFallbackContent(message) { + const resolvedBody = transformOutgoingMentions(message); + if (!hasCanonicalMentions(resolvedBody)) { + return { + msgtype: 'm.text', + body: resolvedBody + }; + } + + return { + msgtype: 'm.text', + body: resolvedBody, + format: 'org.matrix.custom.html', + formatted_body: buildFormattedMentionBody(resolvedBody) + }; +} + +/** + * Build Matrix message content with mention metadata and formatted HTML. + * @param {string} message - Outgoing raw message + * @returns {{msgtype: string, body: string, "m.mentions"?: object, format?: string, formatted_body?: string}} Matrix content payload + */ +function buildMentionMessageContent(message) { + const resolvedBody = transformOutgoingMentions(message); + const hasMentions = hasCanonicalMentions(resolvedBody); + + if (!hasMentions) { + return { + msgtype: 'm.text', + body: resolvedBody + }; + } + + return { + msgtype: 'm.text', + body: resolvedBody, + 'm.mentions': {}, + format: 'org.matrix.custom.html', + formatted_body: buildFormattedMentionBody(resolvedBody) + }; +} + +/** + * Render Matrix user mentions as highlighted @displayname values. + * @param {string} text - Chat message body + * @returns {string} Formatted message text for terminal output + */ +function formatChatTextWithMentions(text) { + if (!text) { + return ''; + } + + return text.replace(/@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g, (matchValue, userBody) => { + const userId = `@${userBody}`; + const knownDisplayName = chatMode.displayNames[userId]; + const fallbackDisplayName = userId.split(':')[0].substring(1); + const displayName = knownDisplayName || fallbackDisplayName; + return `\x1b[1;96m@${displayName}\x1b[0m`; + }); +} + +/** + * Cycle mention suggestions and apply selected display name. + * @returns {Promise} + */ +export async function applyMentionAutocomplete() { + if (!chatMode.active) { + return; + } + + if (chatMode.mentionAutocomplete.matches.length === 0) { + await refreshMentionSuggestionsFromInput(); + if (chatMode.mentionAutocomplete.matches.length === 0) { + return; + } + chatMode.mentionAutocomplete.index = 0; + } else { + chatMode.mentionAutocomplete.index = (chatMode.mentionAutocomplete.index + 1) + % chatMode.mentionAutocomplete.matches.length; + } + + const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; + applyMentionReplacement(selectedEntry); + renderMentionSuggestions(); +} + +// Enter chat mode +export async function enterChatMode() { + if (!window.matrixSession || !window.matrixSession.roomId) { + term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n'); + return; + } + + chatMode.active = true; + chatMode.messages = []; + chatMode.lastSync = null; + chatMode.inputLine = ''; + chatMode.mentionAutocomplete = { + tokenStart: -1, + tokenEnd: -1, + matches: [], + index: 0 + }; + resetMentionAutocomplete(); + await syncMentionDirectory(true); + + term.clear(); + term.writeln('╔════════════════════════════════════════════════════════════╗'); + term.writeln('║ CHAT - #generalchat ║'); + term.writeln('║ Type /help for commands ║'); + term.writeln('╚════════════════════════════════════════════════════════════╝'); + + // Fetch initial messages + await syncChatMessages(); + + // Show nickname setup hint in message history when display name is still default + const showNicknameHint = await shouldShowNicknameHint(); + if (showNicknameHint) { + const hintTime = new Date().toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + term.writeln(`\x1b[90m[${hintTime}]\x1b[0m \x1b[93mSystem:\x1b[0m You are using a default name. Use /nick [name] to change it.`); + } + + // Start polling for new messages + chatMode.pollInterval = setInterval(async () => { + await syncChatMessages(true); + }, 3000); + + // Add separator and initial prompt + term.writeln(''); + term.writeln('─'.repeat(term.cols || 60)); + term.write('\x1b[1;32m>\x1b[0m '); + + // Delay showing input to ensure terminal has rendered and cursor is positioned + setTimeout(() => { + showInlineInput(); + }, 50); + + updateQuickCommands('chat'); +} + +// Exit chat mode +export function exitChatMode() { + chatMode.active = false; + if (chatMode.pollInterval) { + clearInterval(chatMode.pollInterval); + chatMode.pollInterval = null; + } + chatMode.displayNames = {}; // Clear display name cache + chatMode.mentionDirectory = []; + chatMode.mentionLoadedAt = 0; + resetMentionAutocomplete(); + term.clear(); + term.writeln(' Exited chat mode.'); + writePrompt(); + showInlineInput(); + updateQuickCommands('terminal'); +} + +/** + * Update quick command buttons based on context + * @param {string} mode - 'terminal' or 'chat' + */ +export function updateQuickCommands(mode) { + const container = document.querySelector('.quick-commands'); + const statusLeft = document.querySelector('.status-left'); + if (!container) return; + + if (mode === 'chat') { + if (statusLeft) statusLeft.textContent = 'Chat Mode'; + container.innerHTML = ` + + + + `; + } else { + if (statusLeft) statusLeft.textContent = 'Ready'; + container.innerHTML = ` + + + + + `; + } +} + +/** + * Run a chat command from button click + * @param {string} command - The chat command to run + */ +export function runChatCommand(command) { + if (!chatMode.active) return; + + if (command === '/nick') { + // For /nick, just fill in the command prefix + inlineInput.value = '/nick '; + chatMode.inputLine = inlineInput.value; + resetMentionAutocomplete(); + inlineInput.focus(); + return; + } + + // Execute the command + inlineInput.value = command; + submitInlineInput(); +} + +// Sync messages from Matrix +async function syncChatMessages(onlyNew = false) { + try { + let endpoint = `/rooms/${window.matrixSession.roomId}/messages?dir=b&limit=50`; + const data = await matrixApi(endpoint); + + if (!data || !data.chunk) return; + + const newMessages = []; + for (const event of data.chunk.reverse()) { + if (event.type === 'm.room.message' && event.content.msgtype === 'm.text') { + const msgId = event.event_id; + const exists = chatMode.messages.find(m => m.id === msgId); + + if (!exists) { + const timestamp = new Date(event.origin_server_ts); + const time = timestamp.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + + // Get display name for sender + const displayName = await getDisplayName(event.sender); + + newMessages.push({ + id: msgId, + time: time, + sender: displayName, + userId: event.sender, + text: event.content.body + }); + } + } + } + + if (newMessages.length > 0) { + chatMode.messages.push(...newMessages); + + // Only keep last 100 messages in memory + if (chatMode.messages.length > 100) { + chatMode.messages = chatMode.messages.slice(-100); + } + + // Render new messages if in chat mode and only updating + if (onlyNew && chatMode.active) { + newMessages.forEach(msg => { + renderChatMessage(msg); + }); + } else if (!onlyNew && chatMode.active) { + // Initial load - show last 20 messages (simple print, no cursor manipulation) + const recent = chatMode.messages.slice(-20); + recent.forEach(msg => { + const color = getUserColor(msg.sender); + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + }); + } + } + + chatMode.lastSync = Date.now(); + } catch (error) { + console.error('Sync error:', error); + } +} + +// Render a chat message (insert above the prompt area) +function renderChatMessage(msg) { + const color = getUserColor(msg.sender); + + // Move up to separator line and clear it and the prompt line + term.write('\x1b[1A\x1b[2K\r'); // Move up to separator, clear it + + // Write the new message + term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); + + // Redraw separator + term.writeln('─'.repeat(term.cols || 60)); + + // Redraw prompt with current input + term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); + + // Scroll to bottom to show new message + term.scrollToBottom(); + + // Reposition the inline input after terminal updates + setTimeout(() => positionInlineInput(), 10); +} + +// Render chat input prompt (efficiently) +export function renderChatPrompt() { + // Move to beginning of line, redraw prompt and input + term.write('\r\x1b[K'); // CR + clear rest of line + term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); +} + +// Send chat message +export async function sendChatMessage(message) { + if (!message.trim()) return; + + try { + await syncMentionDirectory(); + const content = buildMentionMessageContent(message); + const hasMentions = hasCanonicalMentions(content.body); + const txnId = Date.now().toString(); + let sendResult = await matrixApi( + `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`, + 'PUT', + content + ); + + if (sendResult && sendResult.errcode && hasMentions) { + const richFallbackContent = buildRichMentionFallbackContent(message); + sendResult = await matrixApi( + `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-richfallback`, + 'PUT', + richFallbackContent + ); + } + + if (sendResult && sendResult.errcode && !hasMentions) { + const fallbackContent = buildPlainMentionMessageContent(message); + sendResult = await matrixApi( + `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-fallback`, + 'PUT', + fallbackContent + ); + } + + if (sendResult && sendResult.errcode) { + throw new Error(sendResult.error || sendResult.errcode || 'Failed to send message'); + } + + // Clear the input + chatMode.inputLine = ''; + resetMentionAutocomplete(); + renderChatPrompt(); + + // Reposition input after render + setTimeout(() => positionInlineInput(), 10); + + // Immediately sync to show our message + setTimeout(() => syncChatMessages(true), 500); + } catch (error) { + // Show error above separator + term.write('\x1b[1A\x1b[2K\r'); + term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`); + term.writeln('─'.repeat(term.cols || 60)); + term.write(`\x1b[1;32m>\x1b[0m `); + setTimeout(() => positionInlineInput(), 10); + } +} diff --git a/website/scripts/commands/help.js b/website/scripts/commands/help.js index 8f65cd6..fb59951 100644 --- a/website/scripts/commands/help.js +++ b/website/scripts/commands/help.js @@ -20,6 +20,7 @@ export default { writeClickable(' [command=banner] - Display welcome banner'); writeClickable(' [command=bluesky] - Fetch recent posts from Bluesky'); writeClickable(' [command=chat] - Enter interactive chat (type /quit to exit)'); + writeClickable(' [command=numbermatch] - Play number matching puzzle game'); writeClickable(' [command=github] - Visit GitHub repository'); writeClickable(' [command=contact] - Display contact information'); writeClickable(' [command=privacy] - Display privacy policy'); diff --git a/website/scripts/commands/numbermatch.js b/website/scripts/commands/numbermatch.js new file mode 100644 index 0000000..af1930d --- /dev/null +++ b/website/scripts/commands/numbermatch.js @@ -0,0 +1,905 @@ +/** + * Number Match game command - A terminal-based number matching puzzle game + * Match pairs of numbers that are equal or sum to 10 + */ + +/** + * Play a click/select sound + * @returns {void} + */ +function playClickSound() { + if (window.terminalSounds) { + const sounds = window.terminalSounds; + sounds.play(sounds.pickRandom(sounds.files.typing), 0.25); + } +} + +/** + * Play a match success sound + * @returns {void} + */ +function playMatchSound() { + if (window.terminalSounds) { + const sounds = window.terminalSounds; + sounds.play(sounds.pickRandom(sounds.files.enter), 0.3); + } +} + +/** + * Play an error/invalid sound + * @returns {void} + */ +function playErrorSound() { + if (window.terminalSounds) { + const sounds = window.terminalSounds; + sounds.play(sounds.files.scroll, 0.2); + } +} + +/** + * Shuffle an array in place using the Fisher-Yates algorithm. + * @template T + * @param {T[]} array - Array to shuffle + * @returns {T[]} The shuffled array + */ +function shuffle(array) { + for (let i = array.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + return array; +} + +/** + * Core game state manager for Number Match logic. + */ +class NumberMatchGame { + /** + * @param {{ width?: number, rows?: number }} [options] - Configuration object + */ + constructor(options = {}) { + const { width = 9, rows = 4 } = options; + this.width = width; + this.initialRows = rows; + this.reset(); + } + + /** + * Regenerate the board with a fresh puzzle. + * @returns {void} + */ + reset() { + const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows); + this.tiles = initialState.boardTiles; + } + + /** + * Get a shallow copy of the current tiles. + * @returns {(number|null)[]} Current tile values + */ + getTiles() { + return [...this.tiles]; + } + + /** + * Count the remaining non-null tiles. + * @returns {number} Number of tiles left on the board + */ + getRemainingCount() { + return this.tiles.filter((tile) => tile !== null).length; + } + + /** + * Determine whether the board has been cleared. + * @returns {boolean} True when all tiles have been removed + */ + isComplete() { + return this.tiles.every((tile) => tile === null); + } + + /** + * Attempt to match and remove two indices. + * @param {number} firstIndex - Index of the first tile + * @param {number} secondIndex - Index of the second tile + * @returns {boolean} True if the match succeeded + */ + selectPair(firstIndex, secondIndex) { + if (!this.canPair(firstIndex, secondIndex)) { + return false; + } + + this.tiles[firstIndex] = null; + this.tiles[secondIndex] = null; + this.compactEmptyRows(); + return true; + } + + /** + * Check whether the two indices can form a valid pair. + * @param {number} firstIndex - Index of the first tile + * @param {number} secondIndex - Index of the second tile + * @returns {boolean} True if the tiles satisfy match rules and path constraints + */ + canPair(firstIndex, secondIndex) { + if (firstIndex === secondIndex) { + return false; + } + + if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || + !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) { + return false; + } + + const valueA = this.tiles[firstIndex]; + const valueB = this.tiles[secondIndex]; + + if (valueA === null || valueB === null) { + return false; + } + + if (!(valueA === valueB || valueA + valueB === 10)) { + return false; + } + + return this.hasClearPath(firstIndex, secondIndex); + } + + /** + * Append remaining tiles in reading order. + * @returns {boolean} True when tiles were appended + */ + addNumbers() { + this.compactEmptyRows(); + const remaining = this.tiles.filter((tile) => tile !== null); + if (remaining.length === 0) { + return false; + } + + this.trimTrailingEmptySlots(); + this.tiles.push(...remaining); + return true; + } + + /** + * Remove trailing null placeholders from the board. + * @returns {void} + */ + trimTrailingEmptySlots() { + while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) { + this.tiles.pop(); + } + } + + /** + * Verify that a straight path between two indices is unobstructed. + * @param {number} firstIndex - One tile index + * @param {number} secondIndex - The other tile index + * @returns {boolean} True if any allowed path between the indices is clear + */ + hasClearPath(firstIndex, secondIndex) { + const start = Math.min(firstIndex, secondIndex); + const end = Math.max(firstIndex, secondIndex); + + const rowStart = Math.floor(start / this.width); + const rowEnd = Math.floor(end / this.width); + const colStart = start % this.width; + const colEnd = end % this.width; + + // Horizontal (same row, adjacent or with empty cells between) + if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) { + return true; + } + + // Vertical (same column) + const diff = end - start; + if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) { + return true; + } + + // Diagonal down-right (row increases, col increases) + const rowDelta = rowEnd - rowStart; + const colDelta = colEnd - colStart; + + if (rowDelta === colDelta && rowDelta > 0) { + // Down-right diagonal: step = width + 1 + if (this.isSegmentClear(start, end, this.width + 1)) { + return true; + } + } + + // Diagonal down-left (row increases, col decreases) + if (rowDelta === -colDelta && rowDelta > 0) { + // Down-left diagonal: step = width - 1 + if (this.isSegmentClear(start, end, this.width - 1)) { + return true; + } + } + + // Wrap-around horizontal (end of one row to start of next) + if (rowEnd === rowStart + 1 && colStart === this.width - 1 && colEnd === 0) { + // Adjacent via wrap + return true; + } + if (diff === 1 || diff === this.width) { + // Directly adjacent + return true; + } + + return false; + } + + /** + * Remove any fully empty rows from the board to keep the grid compact. + * @returns {void} + */ + compactEmptyRows() { + let index = 0; + while (index < this.tiles.length) { + const remaining = this.tiles.length - index; + const span = Math.min(this.width, remaining); + const slice = this.tiles.slice(index, index + span); + const isEmptyRow = slice.every((value) => value === null); + if (isEmptyRow) { + this.tiles.splice(index, span); + continue; + } + index += this.width; + } + } + + /** + * Verify that the indices along a stepped path are empty. + * @param {number} start - Starting index (inclusive) + * @param {number} end - Ending index (inclusive) + * @param {number} step - Increment applied each iteration + * @returns {boolean} True if no non-null tiles exist between start and end + */ + isSegmentClear(start, end, step) { + for (let current = start + step; current < end; current += step) { + if (this.tiles[current] !== null) { + return false; + } + } + return true; + } + + /** + * Find a valid pair hint. + * @returns {[number, number]|null} A pair of indices or null if none available + */ + findHint() { + const tiles = this.tiles; + for (let i = 0; i < tiles.length; i++) { + if (tiles[i] === null) continue; + for (let j = i + 1; j < tiles.length; j++) { + if (tiles[j] === null) continue; + if (this.canPair(i, j)) { + return [i, j]; + } + } + } + return null; + } + + /** + * Determine whether the provided index is valid for the current tile array. + * @param {number} index - Index to evaluate + * @param {(number|null)[]} tiles - Tile array + * @returns {boolean} True when the index is within bounds + */ + static isValidIndex(index, tiles) { + return index >= 0 && index < tiles.length; + } + + /** + * Create a randomized board configuration. + * @param {number} width - Number of columns + * @param {number} rows - Number of initial rows + * @returns {{ boardTiles: number[] }} Generated tile set + */ + static generateInitialState(width, rows) { + const boardCapacity = width * rows; + const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2)); + const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches); + const forcedAssignments = new Map(); + + const horizontalStarts = []; + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + for (let colIndex = 0; colIndex < width - 1; colIndex += 1) { + horizontalStarts.push(rowIndex * width + colIndex); + } + } + + const shuffledStarts = shuffle(horizontalStarts); + const reserved = new Set(); + let startCursor = 0; + + forcedPairs.forEach((pair) => { + let startIndex = null; + while (startCursor < shuffledStarts.length) { + const candidate = shuffledStarts[startCursor]; + startCursor += 1; + if (reserved.has(candidate) || reserved.has(candidate + 1)) { + continue; + } + startIndex = candidate; + break; + } + + if (startIndex === null) { + return; + } + + forcedAssignments.set(startIndex, pair[0]); + forcedAssignments.set(startIndex + 1, pair[1]); + reserved.add(startIndex); + reserved.add(startIndex + 1); + }); + + const boardTiles = new Array(boardCapacity).fill(null); + + for (let index = 0; index < boardCapacity; index += 1) { + const forcedValue = forcedAssignments.get(index); + if (typeof forcedValue === 'number') { + boardTiles[index] = forcedValue; + continue; + } + + const rawNextForced = forcedAssignments.get(index + 1); + const nextForcedValue = typeof rawNextForced === 'number' ? rawNextForced : null; + boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue); + } + + return { boardTiles }; + } + + /** + * Produce a random pair of values that form a legal match. + * @returns {[number, number]} Two values that either match or sum to ten + */ + static generatePair() { + const complementPairs = [[1, 9], [2, 8], [3, 7], [4, 6], [5, 5]]; + const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + if (Math.random() < 0.5) { + const value = identicalValues[Math.floor(Math.random() * identicalValues.length)]; + return [value, value]; + } + + const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)]; + return [...pair]; + } + + /** + * Create the predetermined matches that appear on the initial board. + * @param {number} target - Total matches to prepare + * @returns {Array<[number, number]>} Ordered list of forced pairs + */ + static getForcedPairs(target) { + const essentialPairs = [[5, 5]]; + const complementPool = shuffle([ + [1, 9], [2, 8], [3, 7], [4, 6], + [9, 1], [8, 2], [7, 3], [6, 4] + ]); + const pairs = []; + + for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) { + pairs.push(essentialPairs[i]); + } + + let complementIndex = 0; + while (pairs.length < target && complementIndex < complementPool.length) { + pairs.push(complementPool[complementIndex]); + complementIndex += 1; + } + + while (pairs.length < target) { + pairs.push(NumberMatchGame.generatePair()); + } + + return shuffle(pairs); + } + + /** + * Pick a filler value that will not immediately create an accessible match. + * @param {Array} tiles - Current partially filled board + * @param {number} width - Board width + * @param {number} index - Index to populate + * @param {number|null} nextForcedValue - Upcoming forced value, if one exists + * @returns {number} Safe filler digit + */ + static pickSafeFillerValue(tiles, width, index, nextForcedValue) { + const attempt = (respectNext) => { + const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null); + const candidates = []; + for (let value = 1; value <= 9; value += 1) { + if (!forbidden.has(value)) { + candidates.push(value); + } + } + return candidates; + }; + + let candidates = attempt(true); + if (candidates.length === 0) { + candidates = attempt(false); + } + + if (candidates.length === 0) { + return Math.floor(Math.random() * 9) + 1; + } + + return candidates[Math.floor(Math.random() * candidates.length)]; + } + + /** + * Collect forbidden values based on already placed neighbors. + * @param {Array} tiles - Current board state + * @param {number} width - Board width + * @param {number} index - Target index + * @param {number|null} nextForcedValue - Forced value to appear next in sequence, if any + * @returns {Set} Values that should not be used at the index + */ + static collectForbiddenValues(tiles, width, index, nextForcedValue) { + const forbidden = new Set(); + const register = NumberMatchGame.registerForbiddenValue; + + if (index > 0) { + register(forbidden, tiles[index - 1]); + } + + const row = Math.floor(index / width); + const col = index % width; + + if (row > 0) { + register(forbidden, tiles[index - width]); + if (col > 0) { + register(forbidden, tiles[index - width - 1]); + } + if (col < width - 1) { + register(forbidden, tiles[index - width + 1]); + } + } + + if (typeof nextForcedValue === 'number') { + register(forbidden, nextForcedValue); + } + + return forbidden; + } + + /** + * Register a value and its complement as forbidden for immediate placement. + * @param {Set} forbidden - Collection of disallowed values + * @param {number|null|undefined} value - Value to mark as forbidden + * @returns {void} + */ + static registerForbiddenValue(forbidden, value) { + if (typeof value !== 'number') { + return; + } + + forbidden.add(value); + const complement = NumberMatchGame.getComplement(value); + forbidden.add(complement); + } + + /** + * Compute the complement digit that forms a sum-to-ten relationship. + * @param {number} value - Value between 1 and 9 + * @returns {number} Complement digit + */ + static getComplement(value) { + if (value === 5) { + return 5; + } + const complement = 10 - value; + return Math.min(9, Math.max(1, complement)); + } +} + +/** + * Game mode state for terminal integration + */ +export const gameMode = { + active: false, + game: null, + selectedIndex: null, + matchesCleared: 0, + term: null, + boardLines: 0, + message: '' +}; + +/** + * Calculate how many lines the board display uses + * @param {NumberMatchGame} game - Game instance + * @returns {number} Number of lines to move up + */ +function getBoardLineCount(game) { + const rows = Math.ceil(game.getTiles().length / game.width); + // header + top border + rows + bottom border + empty + status + message + // (prompt uses write not writeln, so doesn't add a line) + return 1 + 1 + rows + 1 + 1 + 1 + 1; +} + +/** + * Render the game board as ASCII art (initial draw) + * @param {Terminal} term - xterm.js terminal instance + * @param {NumberMatchGame} game - Game instance + * @param {number|null} selectedIndex - Currently selected tile index + * @param {string} message - Status message to display + * @returns {void} + */ +function renderBoard(term, game, selectedIndex, message = '') { + const tiles = game.getTiles(); + const width = game.width; + const rows = Math.ceil(tiles.length / width); + + // Column headers + let header = ' '; + for (let c = 0; c < width; c++) { + header += ` ${c + 1} `; + } + term.writeln(`\x1b[90m${header}\x1b[0m`); + + // Top border + term.writeln(` ┌${'───'.repeat(width)}┐`); + + for (let row = 0; row < rows; row++) { + let line = ` ${String.fromCharCode(65 + row)} │`; + + for (let col = 0; col < width; col++) { + const idx = row * width + col; + if (idx >= tiles.length) { + line += ' '; + continue; + } + + const value = tiles[idx]; + if (value === null) { + line += ' · '; + } else if (idx === selectedIndex) { + // Highlight selected tile + line += `\x1b[7m ${value} \x1b[0m`; + } else { + line += ` ${value} `; + } + } + + line += '│'; + term.writeln(line); + } + + // Bottom border + term.writeln(` └${'───'.repeat(width)}┘`); + + // Status line + term.writeln(''); + const remaining = game.getRemainingCount(); + term.writeln(` \x1b[33mMatches:\x1b[0m ${gameMode.matchesCleared} \x1b[33mRemaining:\x1b[0m ${remaining} `); + + // Message line (padded to clear previous) + const msgText = message || ''; + term.writeln(` ${msgText}`.padEnd(50)); + + // Prompt + term.write('\x1b[33mgame>\x1b[0m '); + + gameMode.boardLines = getBoardLineCount(game); + gameMode.message = message; +} + +/** + * Update the board in place without scrolling + * @param {Terminal} term - xterm.js terminal instance + * @param {NumberMatchGame} game - Game instance + * @param {number|null} selectedIndex - Currently selected tile index + * @param {string} message - Status message to display + * @returns {void} + */ +function updateBoardInPlace(term, game, selectedIndex, message = '') { + const linesToMoveUp = gameMode.boardLines; + + // Move cursor up to start of board, go to column 0, clear to end of screen + term.write(`\x1b[${linesToMoveUp}A\r\x1b[J`); + + // Redraw from current position + renderBoard(term, game, selectedIndex, message); +} + +/** + * Display help text + * @param {Terminal} term - xterm.js terminal instance + * @returns {void} + */ +function renderHelp(term) { + term.writeln(''); + term.writeln(' \x1b[36mCommands:\x1b[0m'); + term.writeln(' A1 B2 - Select two tiles (e.g., A1 A2 or B3 C3)'); + term.writeln(' add - Duplicate remaining numbers to end'); + term.writeln(' hint - Show a valid pair'); + term.writeln(' new - Start a new game'); + term.writeln(' quit - Exit the game'); + term.writeln(''); + term.writeln(' \x1b[36mRules:\x1b[0m Match numbers that are equal or sum to 10'); + term.writeln(' Pairs must be adjacent horizontally or vertically'); + term.writeln(' (with only empty spaces between them)'); +} + +/** + * Parse coordinate input like "A1" to index + * @param {string} coord - Coordinate string (e.g., "A1", "B3") + * @param {number} width - Board width + * @returns {number|null} Tile index or null if invalid + */ +function parseCoordinate(coord, width) { + const match = coord.toUpperCase().match(/^([A-Z])(\d+)$/); + if (!match) return null; + + const row = match[1].charCodeAt(0) - 65; + const col = parseInt(match[2], 10) - 1; + + if (row < 0 || col < 0 || col >= width) return null; + + return row * width + col; +} + +/** + * Process game input + * @param {Terminal} term - xterm.js terminal instance + * @param {string} input - User input + * @returns {boolean} True if game should continue + */ +export function processGameInput(term, input) { + if (!gameMode.active || !gameMode.game) { + return false; + } + + const cmd = input.trim().toLowerCase(); + const game = gameMode.game; + + if (cmd === 'quit' || cmd === 'exit' || cmd === '/quit') { + gameMode.active = false; + gameMode.game = null; + gameMode.selectedIndex = null; + gameMode.matchesCleared = 0; + gameMode.term = null; + gameMode.boardLines = 0; + term.writeln(' Game exited.\r\n'); + return false; + } + + if (cmd === 'new' || cmd === 'reset') { + game.reset(); + gameMode.selectedIndex = null; + gameMode.matchesCleared = 0; + term.writeln(''); + renderBoard(term, game, null, 'New game started!'); + return true; + } + + if (cmd === 'add') { + const added = game.addNumbers(); + term.writeln(''); + if (added) { + renderBoard(term, game, gameMode.selectedIndex, 'Numbers duplicated to end'); + } else { + renderBoard(term, game, gameMode.selectedIndex, '\x1b[31mNo numbers to add\x1b[0m'); + } + return true; + } + + if (cmd === 'hint') { + const hint = game.findHint(); + if (hint) { + const [i, j] = hint; + const width = game.width; + const coord1 = String.fromCharCode(65 + Math.floor(i / width)) + ((i % width) + 1); + const coord2 = String.fromCharCode(65 + Math.floor(j / width)) + ((j % width) + 1); + term.writeln(` \x1b[33mHint:\x1b[0m Try ${coord1} ${coord2}`); + } else { + term.writeln(' \x1b[31mNo matches. Try "add"\x1b[0m'); + } + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + if (cmd === 'help' || cmd === '?') { + renderHelp(term); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + // Try to parse as coordinates + const parts = input.trim().toUpperCase().split(/\s+/); + + if (parts.length === 2) { + const idx1 = parseCoordinate(parts[0], game.width); + const idx2 = parseCoordinate(parts[1], game.width); + + if (idx1 === null || idx2 === null) { + term.writeln(' \x1b[31mInvalid coordinates. Use format: A1 B2\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + const tiles = game.getTiles(); + if (idx1 >= tiles.length || idx2 >= tiles.length) { + term.writeln(' \x1b[31mCoordinates out of range.\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + if (tiles[idx1] === null || tiles[idx2] === null) { + term.writeln(' \x1b[31mOne or both tiles are empty.\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + + const v1 = tiles[idx1]; + const v2 = tiles[idx2]; + const matched = game.selectPair(idx1, idx2); + + term.writeln(''); + if (matched) { + gameMode.matchesCleared++; + + if (game.isComplete()) { + term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m'); + term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m'); + term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m'); + term.writeln(''); + term.writeln(` Total matches: ${gameMode.matchesCleared}`); + term.writeln(' Type "new" for another game or "quit" to exit.'); + term.write('\x1b[33mgame>\x1b[0m '); + gameMode.boardLines = 0; + return true; + } + renderBoard(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`); + } else { + if (v1 !== v2 && v1 + v2 !== 10) { + renderBoard(term, game, null, `\x1b[31m${v1} and ${v2} don't match\x1b[0m`); + } else { + renderBoard(term, game, null, `\x1b[31mNo clear path\x1b[0m`); + } + } + return true; + } + + if (parts.length === 1 && parts[0]) { + const idx = parseCoordinate(parts[0], game.width); + if (idx !== null) { + term.writeln(' \x1b[33mEnter two coordinates (e.g., A1 A2)\x1b[0m'); + term.write('\x1b[33mgame>\x1b[0m '); + return true; + } + } + + if (cmd) { + term.writeln(' \x1b[31mUnknown command. Type "help"\x1b[0m'); + } + term.write('\x1b[33mgame>\x1b[0m '); + return true; +} + +/** + * Start a new number match game session + * @param {Terminal} term - xterm.js terminal instance + * @returns {void} + */ +export function startGame(term) { + gameMode.active = true; + gameMode.game = new NumberMatchGame({ width: 9, rows: 4 }); + gameMode.selectedIndex = null; + gameMode.matchesCleared = 0; + gameMode.term = term; + gameMode.boardLines = 0; + gameMode.message = ''; + + term.writeln(''); + term.writeln(' ╔════════════════════════════════════╗'); + term.writeln(' ║ NUMBER MATCH ║'); + term.writeln(' ╚════════════════════════════════════╝'); + term.writeln(''); + term.writeln(' Match pairs: equal numbers or sum to 10'); + term.writeln(' \x1b[90mClick tiles or type coordinates (e.g., A1 A2)\x1b[0m'); + term.writeln(' \x1b[90mCommands: add, hint, new, quit\x1b[0m'); + term.writeln(''); + + renderBoard(term, gameMode.game, null, ''); +} + +/** + * Handle a tile click from the terminal link provider + * @param {string} coord - Coordinate string (e.g., "A1", "B3") + * @returns {void} + */ +export function handleTileClick(coord) { + if (!gameMode.active || !gameMode.game || !gameMode.term) { + return; + } + + const term = gameMode.term; + const game = gameMode.game; + const clickedIndex = parseCoordinate(coord, game.width); + + if (clickedIndex === null) { + return; + } + + const tiles = game.getTiles(); + if (clickedIndex >= tiles.length || tiles[clickedIndex] === null) { + return; + } + + // If no tile selected, select this one + if (gameMode.selectedIndex === null) { + gameMode.selectedIndex = clickedIndex; + playClickSound(); + updateBoardInPlace(term, game, clickedIndex, `\x1b[33mSelected ${coord}. Click another to match.\x1b[0m`); + return; + } + + // If same tile clicked, deselect + if (gameMode.selectedIndex === clickedIndex) { + gameMode.selectedIndex = null; + playClickSound(); + updateBoardInPlace(term, game, null, ''); + return; + } + + // Try to match the two tiles + const firstIndex = gameMode.selectedIndex; + const prevTiles = game.getTiles(); + const v1 = prevTiles[firstIndex]; + const v2 = prevTiles[clickedIndex]; + + const matched = game.selectPair(firstIndex, clickedIndex); + gameMode.selectedIndex = null; + + if (matched) { + gameMode.matchesCleared++; + playMatchSound(); + + if (game.isComplete()) { + // Game won - need to redraw fresh for victory screen + playMatchSound(); + term.write(`\x1b[${gameMode.boardLines}A`); + for (let i = 0; i < gameMode.boardLines; i++) { + term.writeln('\x1b[2K'); + } + term.writeln(' \x1b[32m╔════════════════════════════════════╗\x1b[0m'); + term.writeln(' \x1b[32m║ CONGRATULATIONS! YOU WON! ║\x1b[0m'); + term.writeln(' \x1b[32m╚════════════════════════════════════╝\x1b[0m'); + term.writeln(''); + term.writeln(` Total matches: ${gameMode.matchesCleared}`); + term.writeln(' Type "new" for another game or "quit" to exit.'); + term.write('\x1b[33mgame>\x1b[0m '); + gameMode.boardLines = 0; + return; + } + + updateBoardInPlace(term, game, null, `\x1b[32mMatch! ${v1}+${v2}\x1b[0m`); + } else { + playErrorSound(); + if (v1 !== v2 && v1 + v2 !== 10) { + updateBoardInPlace(term, game, null, `\x1b[31m${v1} and ${v2} don't match or sum to 10\x1b[0m`); + } else { + updateBoardInPlace(term, game, null, `\x1b[31mNo clear path between tiles\x1b[0m`); + } + } +} + +/** + * Number Match command export + */ +export default { + description: 'Play the Number Match puzzle game', + execute: (term, writeClickable, VERSION, args) => { + startGame(term); + return null; + } +}; diff --git a/website/terminal.js b/website/terminal.js index f1016fb..617b388 100644 --- a/website/terminal.js +++ b/website/terminal.js @@ -12,10 +12,42 @@ import githubCmd from './scripts/commands/github.js'; import contactCmd from './scripts/commands/contact.js'; import privacyCmd from './scripts/commands/privacy.js'; import blueskyCmd from './scripts/commands/bluesky.js'; +import numbermatchCmd, { gameMode, processGameInput, handleTileClick } from './scripts/commands/numbermatch.js'; + +// Import Matrix client +import { + initMatrixClient, + chatMode, + matrixApi, + fetchPublicLastMessage, + fetchPublicPresence, + formatTimeAgo, + isDisplayNameTaken, + hasVisibleMentionSuggestions, + resetMentionAutocomplete, + refreshMentionSuggestionsFromInput, + commitSelectedMentionSuggestion, + applyMentionAutocomplete, + enterChatMode, + exitChatMode, + updateQuickCommands, + runChatCommand, + renderChatPrompt, + sendChatMessage +} from './matrix-client.js'; // Version const VERSION = '1.0.0'; +// Matrix configuration +const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); +const MATRIX_CONFIG = { + homeserver: 'https://chat.ruv.wtf', + bridgeUrl: 'https://lit.ruv.wtf/matrix-bridge.html', + useBridge: IS_NEOCITIES_HOST, + publicReadToken: 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o' +}; + // Create inline input element const inlineInput = document.createElement('input'); inlineInput.type = 'text'; @@ -174,7 +206,7 @@ const term = new Terminal({ cursorStyle: 'underline', cursorInactiveStyle: 'none', fontFamily: '"Courier New", Courier, monospace', - fontSize: 14, + fontSize: 20, theme: { background: '#001800', foreground: '#00ff00', @@ -227,7 +259,42 @@ term.registerLinkProvider({ const links = []; // Find all command names that match our known commands - const commandNames = ['help', 'about', 'clear', 'echo', 'date', 'whoami', 'history', 'color', 'banner', 'bluesky', 'chat', 'github', 'contact', 'privacy']; + const commandNames = ['help', 'about', 'clear', 'echo', 'date', 'whoami', 'history', 'color', 'banner', 'bluesky', 'chat', 'github', 'contact', 'privacy', 'numbermatch']; + + // When in game mode, detect clickable tile numbers on board lines + if (gameMode.active && lineText.includes('│')) { + // Board line format: " A │ 5 3 7 ...│" + // Detect row letter at start + const rowMatch = lineText.match(/^\s*([A-Z])\s*│/); + if (rowMatch) { + const rowLetter = rowMatch[1]; + // Find all digits within the board area (between │ characters) + const boardStart = lineText.indexOf('│') + 1; + const boardEnd = lineText.lastIndexOf('│'); + + if (boardStart > 0 && boardEnd > boardStart) { + let col = 0; + for (let i = boardStart; i < boardEnd; i++) { + const char = lineText[i]; + // Each cell is 3 chars wide: " X " + if ((i - boardStart) % 3 === 1 && /[1-9]/.test(char)) { + col = Math.floor((i - boardStart) / 3) + 1; + const coord = `${rowLetter}${col}`; + links.push({ + range: { + start: { x: i + 1, y: bufferLineNumber }, + end: { x: i + 2, y: bufferLineNumber } + }, + text: char, + activate: () => { + handleTileClick(coord); + } + }); + } + } + } + } + } commandNames.forEach(cmd => { let startIndex = 0; @@ -267,13 +334,13 @@ term.registerLinkProvider({ function adjustFontSize() { const width = window.innerWidth; if (width < 350) { - term.options.fontSize = 8; + term.options.fontSize = 11; } else if (width < 480) { - term.options.fontSize = 10; - } else if (width < 768) { - term.options.fontSize = 12; - } else { term.options.fontSize = 14; + } else if (width < 768) { + term.options.fontSize = 17; + } else { + term.options.fontSize = 20; } fitAddon.fit(); } @@ -356,1179 +423,7 @@ let historyIndex = -1; let currentLine = ''; let cursorPosition = 0; -// Chat mode state -let chatMode = { - active: false, - messages: [], - lastSync: null, - pollInterval: null, - inputLine: '', - displayNames: {}, // Cache for display names - mentionDirectory: [], - mentionLoadedAt: 0, - mentionAutocomplete: { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - } -}; - -const MATRIX_PUBLIC_READ_TOKEN = 'syt_Z2VuZXJhbGNoYXQtcmVhZG9ubHk_sikLltUtfbHlztnanEVm_2icJ1o'; -const IS_NEOCITIES_HOST = window.location.hostname.endsWith('neocities.org'); -const MATRIX_BRIDGE_URL = 'https://lit.ruv.wtf/matrix-bridge.html'; - -/** - * Matrix Bridge - Iframe-based communication for CSP-restricted hosts - */ -const matrixBridge = { - iframe: null, - ready: false, - pendingRequests: new Map(), - requestCounter: 0 -}; - -/** - * Initialize the Matrix iframe bridge - * @returns {Promise} True if bridge loaded successfully - */ -async function initMatrixBridge() { - if (matrixBridge.iframe) { - return matrixBridge.ready; - } - - return new Promise((resolve) => { - const iframe = document.createElement('iframe'); - iframe.src = MATRIX_BRIDGE_URL; - iframe.style.display = 'none'; - iframe.style.position = 'absolute'; - iframe.style.width = '0'; - iframe.style.height = '0'; - iframe.style.border = 'none'; - - const timeout = setTimeout(() => { - console.error('[Matrix Bridge] Failed to load iframe bridge'); - matrixBridge.ready = false; - resolve(false); - }, 10000); - - window.addEventListener('message', (event) => { - if (event.data && event.data.type === 'matrix:bridge:ready') { - clearTimeout(timeout); - matrixBridge.ready = true; - console.log('[Matrix Bridge] Iframe bridge ready'); - resolve(true); - } - }); - - document.body.appendChild(iframe); - matrixBridge.iframe = iframe; - }); -} - -/** - * Send a request to the Matrix bridge via postMessage - * @param {string} type - Request type (e.g. 'matrix:auth', 'matrix:sendMessage') - * @param {object} payload - Request payload - * @returns {Promise} Response from bridge - */ -function matrixBridgeRequest(type, payload) { - return new Promise((resolve, reject) => { - if (!matrixBridge.iframe || !matrixBridge.ready) { - reject(new Error('Matrix bridge not initialized')); - return; - } - - const requestId = `req_${++matrixBridge.requestCounter}`; - const timeout = setTimeout(() => { - matrixBridge.pendingRequests.delete(requestId); - reject(new Error('Matrix bridge request timeout')); - }, 30000); - - matrixBridge.pendingRequests.set(requestId, { resolve, reject, timeout }); - - matrixBridge.iframe.contentWindow.postMessage({ - type, - requestId, - payload - }, MATRIX_BRIDGE_URL); - }); -} - -/** - * Handle responses from the Matrix bridge - */ -window.addEventListener('message', (event) => { - if (event.origin !== new URL(MATRIX_BRIDGE_URL).origin) { - return; - } - - const { type, requestId, payload } = event.data; - - if (!type || !requestId || !type.includes(':response')) { - return; - } - - const pending = matrixBridge.pendingRequests.get(requestId); - if (pending) { - clearTimeout(pending.timeout); - matrixBridge.pendingRequests.delete(requestId); - pending.resolve(payload); - } -}); - -// Matrix API helper -const matrixApi = async (endpoint, method = 'GET', body = null) => { - if (!window.matrixSession) return null; - - // Use iframe bridge on Neocities - if (IS_NEOCITIES_HOST) { - if (!matrixBridge.ready) { - const initialized = await initMatrixBridge(); - if (!initialized) { - return { - errcode: 'M_BRIDGE_UNAVAILABLE', - error: 'Matrix bridge failed to initialize' - }; - } - } - - try { - const result = await matrixBridgeRequest('matrix:api', { - endpoint, - method, - body, - accessToken: window.matrixSession.accessToken - }); - return result; - } catch (error) { - return { - errcode: 'M_BRIDGE_ERROR', - error: error.message - }; - } - } - - // Direct API call for non-CSP-restricted hosts - const homeserver = 'https://chat.ruv.wtf'; - const url = `${homeserver}/_matrix/client/r0${endpoint}`; - const headers = { - 'Content-Type': 'application/json' - }; - - if (window.matrixSession.accessToken) { - headers['Authorization'] = `Bearer ${window.matrixSession.accessToken}`; - } - - const options = { method, headers }; - if (body) options.body = JSON.stringify(body); - - const response = await fetch(url, options); - return response.json(); -}; - -/** - * Fetch the latest public chat message using the read-only public token. - * @param {string} homeserver - Matrix homeserver URL - * @param {string} roomAlias - Room alias (e.g. #generalchat:example.org) - * @returns {Promise<{time: string, sender: string, text: string, timestamp: number} | null>} Last message summary or null - */ -async function fetchPublicLastMessage(homeserver, roomAlias) { - // Use iframe bridge on Neocities - if (IS_NEOCITIES_HOST) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - if (!matrixBridge.ready) { - return null; - } - - try { - // Resolve room alias first - let roomId = null; - try { - const resolveResult = await matrixBridgeRequest('matrix:resolveAlias', { roomAlias }); - if (resolveResult && !resolveResult.error && resolveResult.room_id) { - roomId = resolveResult.room_id; - } - } catch (error) { - // Continue with fallback - } - - if (!roomId) { - roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; - } - - // Fetch last message - const result = await matrixBridgeRequest('matrix:fetchLastMessage', { roomId }); - if (!result || result.error || !Array.isArray(result.chunk)) { - return null; - } - - const lastTextEvent = result.chunk.find((event) => { - return event && event.type === 'm.room.message' && event.content && event.content.msgtype === 'm.text'; - }); - - if (!lastTextEvent) { - return null; - } - - const timestamp = typeof lastTextEvent.origin_server_ts === 'number' - ? new Date(lastTextEvent.origin_server_ts) - : new Date(); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - const sender = typeof lastTextEvent.sender === 'string' - ? lastTextEvent.sender.split(':')[0].replace(/^@/, '') - : 'unknown'; - const text = lastTextEvent.content.body || ''; - const timestampMs = typeof lastTextEvent.origin_server_ts === 'number' - ? lastTextEvent.origin_server_ts - : Date.now(); - - return { time, sender, text, timestamp: timestampMs }; - } catch (error) { - return null; - } - } - - // Direct API call for non-CSP-restricted hosts - const resolvedAlias = encodeURIComponent(roomAlias); - const authHeaders = { - Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` - }; - - let roomId = null; - try { - const roomResponse = await fetch(`${homeserver}/_matrix/client/r0/directory/room/${resolvedAlias}`, { - headers: authHeaders - }); - if (roomResponse.ok) { - const roomData = await roomResponse.json(); - roomId = roomData && roomData.room_id ? roomData.room_id : null; - } - } catch (error) { - roomId = null; - } - - if (!roomId) { - roomId = '!RkOwQGTlDJwZbNxGeS:b.ruv.wtf'; - } - - const resolvedRoomId = encodeURIComponent(roomId); - const messagesResponse = await fetch(`${homeserver}/_matrix/client/r0/rooms/${resolvedRoomId}/messages?dir=b&limit=30`, { - headers: authHeaders - }); - if (!messagesResponse.ok) { - return null; - } - - const messagesData = await messagesResponse.json(); - if (!messagesData || !Array.isArray(messagesData.chunk)) { - return null; - } - - const lastTextEvent = messagesData.chunk.find((event) => { - return event && event.type === 'm.room.message' && event.content && event.content.msgtype === 'm.text'; - }); - - if (!lastTextEvent) { - return null; - } - - const timestamp = typeof lastTextEvent.origin_server_ts === 'number' - ? new Date(lastTextEvent.origin_server_ts) - : new Date(); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - const sender = typeof lastTextEvent.sender === 'string' - ? lastTextEvent.sender.split(':')[0].replace(/^@/, '') - : 'unknown'; - const text = lastTextEvent.content.body || ''; - const timestampMs = typeof lastTextEvent.origin_server_ts === 'number' - ? lastTextEvent.origin_server_ts - : Date.now(); - - return { time, sender, text, timestamp: timestampMs }; -} - -/** - * Fetch Matrix presence for a user using the read-only public token. - * @param {string} homeserver - Matrix homeserver URL - * @param {string} userId - Full Matrix user ID - * @returns {Promise} Presence state (online/offline/unavailable) or null when unavailable - */ -async function fetchPublicPresence(homeserver, userId) { - // Use iframe bridge on Neocities - if (IS_NEOCITIES_HOST) { - if (!matrixBridge.ready) { - await initMatrixBridge(); - } - - if (!matrixBridge.ready) { - return null; - } - - try { - const result = await matrixBridgeRequest('matrix:fetchPresence', { userId }); - if (result && !result.error && typeof result.presence === 'string') { - return result.presence; - } - return null; - } catch (error) { - return null; - } - } - - // Direct API call for non-CSP-restricted hosts - const authHeaders = { - Authorization: `Bearer ${MATRIX_PUBLIC_READ_TOKEN}` - }; - const encodedUserId = encodeURIComponent(userId); - const response = await fetch(`${homeserver}/_matrix/client/r0/presence/${encodedUserId}/status`, { - headers: authHeaders - }); - - if (!response.ok) { - return null; - } - - const data = await response.json(); - return data && typeof data.presence === 'string' ? data.presence : null; -} - -/** - * Format a unix-millisecond timestamp as relative age text. - * @param {number} timestampMs - Epoch timestamp in milliseconds - * @returns {string} Relative time label - */ -function formatTimeAgo(timestampMs) { - if (typeof timestampMs !== 'number') { - return 'unknown'; - } - - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000)); - if (elapsedSeconds < 60) { - return 'just now'; - } - - if (elapsedSeconds < 3600) { - return `${Math.floor(elapsedSeconds / 60)}m ago`; - } - - if (elapsedSeconds < 86400) { - return `${Math.floor(elapsedSeconds / 3600)}h ago`; - } - - return `${Math.floor(elapsedSeconds / 86400)}d ago`; -} - -/** - * Write a startup MOTD line with the latest public chat message. - * Uses a short timeout so startup stays responsive. - * @returns {Promise} - */ -async function writeStartupChatMotd() { - try { - const result = await Promise.race([ - (async () => { - const [latest, litruvPresence] = await Promise.all([ - fetchPublicLastMessage('https://chat.ruv.wtf', '#generalchat:b.ruv.wtf'), - fetchPublicPresence('https://chat.ruv.wtf', '@litruv:b.ruv.wtf') - ]); - return { latest, litruvPresence }; - })(), - new Promise((resolve) => setTimeout(() => resolve({ latest: null, litruvPresence: null }), 2500)) - ]); - - if (!result.latest && !result.litruvPresence) { - return; - } - - const lastMessageAge = result.latest - ? formatTimeAgo(result.latest.timestamp) - : null; - const onlineText = result.litruvPresence === 'online' - ? 'online' - : 'offline'; - - if (lastMessageAge) { - term.writeln(` #generalchat · last message ${lastMessageAge} · @litruv:b.ruv.wtf ${onlineText}`); - } else { - term.writeln(` #generalchat · @litruv:b.ruv.wtf ${onlineText}`); - } - writeClickable(` Run [command=chat] to join in on the conversation`); - term.writeln(''); - } catch (error) { - // Ignore MOTD fetch issues to avoid blocking terminal startup. - } -} - -// Get display name for a user (with caching) -async function getDisplayName(userId) { - // Check cache first - if (chatMode.displayNames[userId]) { - return chatMode.displayNames[userId]; - } - - try { - const data = await matrixApi(`/profile/${encodeURIComponent(userId)}/displayname`, 'GET'); - const displayName = data.displayname || userId.split(':')[0].substring(1); - chatMode.displayNames[userId] = displayName; - return displayName; - } catch (error) { - // Fallback to username part - const fallback = userId.split(':')[0].substring(1); - chatMode.displayNames[userId] = fallback; - return fallback; - } -} - -// Get color for user based on their ID (consistent hashing) -function getUserColor(username) { - // Color palette that works well on dark green background - const colors = [ - '\x1b[91m', // bright red - '\x1b[92m', // bright green - '\x1b[93m', // bright yellow - '\x1b[94m', // bright blue - '\x1b[95m', // bright magenta - '\x1b[96m', // bright cyan - '\x1b[33m', // yellow - '\x1b[35m', // magenta - '\x1b[36m', // cyan - ]; - - // Simple hash function - let hash = 0; - for (let i = 0; i < username.length; i++) { - hash = ((hash << 5) - hash) + username.charCodeAt(i); - hash = hash & hash; // Convert to 32bit integer - } - - return colors[Math.abs(hash) % colors.length]; -} - -// Check if display name is already taken -async function isDisplayNameTaken(newName) { - try { - const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); - if (members && members.joined) { - for (const [userId, member] of Object.entries(members.joined)) { - // Skip our own user - if (userId === window.matrixSession.userId) continue; - - const displayName = member.display_name || userId.split(':')[0].substring(1); - if (displayName.toLowerCase() === newName.toLowerCase()) { - return true; - } - } - } - return false; - } catch (error) { - console.error('Error checking display names:', error); - return false; // Allow on error - } -} - -/** - * Determine if a Matrix display name appears unchanged/default. - * @param {string} displayName - Current display name - * @returns {boolean} True when name looks like an auto-generated default - */ -function isDefaultDisplayName(displayName) { - if (!displayName) { - return true; - } - - const trimmedName = displayName.trim(); - if (!trimmedName) { - return true; - } - - const uuidV4Pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return uuidV4Pattern.test(trimmedName); -} - -/** - * Check whether to show a nickname setup hint for the current user. - * @returns {Promise} True when the user should be prompted to change nickname - */ -async function shouldShowNicknameHint() { - if (!window.matrixSession || !window.matrixSession.userId) { - return false; - } - - try { - const encodedUserId = encodeURIComponent(window.matrixSession.userId); - const profileData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET'); - const displayName = profileData && typeof profileData.displayname === 'string' - ? profileData.displayname - : ''; - - return isDefaultDisplayName(displayName); - } catch (error) { - return false; - } -} - -/** - * Sync mention directory for autocomplete and mention rendering. - * @param {boolean} force - When true, bypass cache time - * @returns {Promise} - */ -async function syncMentionDirectory(force = false) { - if (!window.matrixSession || !window.matrixSession.roomId) { - return; - } - - const now = Date.now(); - if (!force && chatMode.mentionDirectory.length > 0 && (now - chatMode.mentionLoadedAt) < 30000) { - return; - } - - try { - const members = await matrixApi(`/rooms/${window.matrixSession.roomId}/joined_members`, 'GET'); - if (!members || !members.joined) { - return; - } - - const directory = []; - Object.entries(members.joined).forEach(([userId, member]) => { - const fallbackName = userId.split(':')[0].substring(1); - const displayName = member && member.display_name ? member.display_name : fallbackName; - chatMode.displayNames[userId] = displayName; - directory.push({ userId, displayName }); - }); - - chatMode.mentionDirectory = directory; - chatMode.mentionLoadedAt = now; - } catch (error) { - // Ignore mention directory refresh failures. - } -} - -/** - * Get mention autocomplete matches for query. - * @param {string} query - Partial display name text without @ - * @returns {Array<{userId: string, displayName: string}>} Sorted matches - */ -function getMentionMatches(query) { - const queryLower = query.toLowerCase(); - const matches = chatMode.mentionDirectory.filter((entry) => { - const display = entry.displayName.toLowerCase(); - const localPart = entry.userId.split(':')[0].substring(1).toLowerCase(); - return display.startsWith(queryLower) || localPart.startsWith(queryLower); - }); - - matches.sort((left, right) => left.displayName.localeCompare(right.displayName)); - return matches; -} - -/** - * Escape text for HTML output. - * @param {string} value - Raw text - * @returns {string} HTML-escaped text - */ -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Extract current mention token context from the input cursor. - * @returns {{atIndex: number, cursorPosition: number, tokenText: string} | null} Token info or null - */ -function getMentionTokenContext() { - const cursorPosition = inlineInput.selectionStart; - const fullValue = inlineInput.value; - const textBeforeCursor = fullValue.slice(0, cursorPosition); - const tokenStart = textBeforeCursor.search(/(?:^|\s)@[^\s]*$/); - - if (tokenStart === -1) { - return null; - } - - const atIndex = textBeforeCursor.indexOf('@', tokenStart); - if (atIndex === -1) { - return null; - } - - const tokenText = textBeforeCursor.slice(atIndex + 1); - if (!tokenText || tokenText.includes(':')) { - return null; - } - - return { atIndex, cursorPosition, tokenText }; -} - -/** - * Render mention suggestions above the inline input. - * @returns {void} - */ -function renderMentionSuggestions() { - const { matches, index } = chatMode.mentionAutocomplete; - if (!chatMode.active || matches.length === 0) { - mentionSuggestions.style.display = 'none'; - return; - } - - const limitedMatches = matches.slice(0, 5); - mentionSuggestions.innerHTML = limitedMatches.map((entry, entryIndex) => { - const selectedClass = index >= 0 && entryIndex === (index % limitedMatches.length) ? ' selected' : ''; - return `@${escapeHtml(entry.displayName)}`; - }).join(''); - mentionSuggestions.style.display = 'block'; - positionInlineInput(); -} - -/** - * Check whether mention suggestions are currently visible. - * @returns {boolean} True when suggestion strip is active - */ -function hasVisibleMentionSuggestions() { - return chatMode.active - && mentionSuggestions.style.display !== 'none' - && chatMode.mentionAutocomplete.matches.length > 0; -} - -/** - * Hide mention suggestion UI and clear state. - * @returns {void} - */ -function resetMentionAutocomplete() { - chatMode.mentionAutocomplete = { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - }; - mentionSuggestions.style.display = 'none'; - mentionSuggestions.innerHTML = ''; -} - -/** - * Refresh mention suggestions based on current cursor token. - * @returns {Promise} - */ -async function refreshMentionSuggestionsFromInput() { - if (!chatMode.active) { - resetMentionAutocomplete(); - return; - } - - const context = getMentionTokenContext(); - if (!context) { - resetMentionAutocomplete(); - return; - } - - await syncMentionDirectory(); - const matches = getMentionMatches(context.tokenText); - if (matches.length === 0) { - resetMentionAutocomplete(); - return; - } - - chatMode.mentionAutocomplete = { - tokenStart: context.atIndex, - tokenEnd: context.cursorPosition, - matches, - index: -1 - }; - renderMentionSuggestions(); -} - -/** - * Replace selected mention token in input with selected display name. - * @param {{userId: string, displayName: string}} selectedEntry - Selected mention entry - * @returns {void} - */ -function applyMentionReplacement(selectedEntry) { - const rangeStart = chatMode.mentionAutocomplete.tokenStart; - const rangeEnd = chatMode.mentionAutocomplete.tokenEnd; - if (rangeStart < 0 || rangeEnd < 0) { - return; - } - - const fullValue = inlineInput.value; - const valueBeforeMention = fullValue.slice(0, rangeStart); - const valueAfterMention = fullValue.slice(rangeEnd); - const mentionText = `@${selectedEntry.displayName}`; - const hasTrailingSpace = valueAfterMention.startsWith(' '); - const nextValue = `${valueBeforeMention}${mentionText}${hasTrailingSpace ? '' : ' '}${valueAfterMention}`; - const nextCursor = valueBeforeMention.length + mentionText.length + (hasTrailingSpace ? 0 : 1); - - inlineInput.value = nextValue; - inlineInput.setSelectionRange(nextCursor, nextCursor); - chatMode.inputLine = nextValue; - chatMode.mentionAutocomplete.tokenEnd = nextCursor; -} - -/** - * Commit the currently selected mention suggestion into input text. - * @returns {boolean} True when a suggestion was applied - */ -function commitSelectedMentionSuggestion() { - if (!hasVisibleMentionSuggestions()) { - return false; - } - - if (chatMode.mentionAutocomplete.index < 0) { - chatMode.mentionAutocomplete.index = 0; - } - - const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; - applyMentionReplacement(selectedEntry); - resetMentionAutocomplete(); - return true; -} - -/** - * Resolve typed @displayname mentions to Matrix user IDs before sending. - * @param {string} message - Outgoing message text - * @returns {string} Message with mentions converted to @user:server - */ -function transformOutgoingMentions(message) { - if (!message || !chatMode.mentionDirectory.length) { - return message; - } - - return message.replace(/(^|\s)@([^\s:]+)\b/g, (fullMatch, leadingWhitespace, mentionValue) => { - const matchedEntry = chatMode.mentionDirectory.find((entry) => { - return entry.displayName.toLowerCase() === mentionValue.toLowerCase(); - }); - - if (!matchedEntry) { - return fullMatch; - } - - return `${leadingWhitespace}${matchedEntry.userId}`; - }); -} - -/** - * Build a plain Matrix text payload with canonical mention IDs. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string}} Matrix content payload - */ -function buildPlainMentionMessageContent(message) { - return { - msgtype: 'm.text', - body: transformOutgoingMentions(message) - }; -} - -/** - * Check whether canonical Matrix mentions exist in text. - * @param {string} text - Message text - * @returns {boolean} True when one or more @user:server mentions are present - */ -function hasCanonicalMentions(text) { - return /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/.test(text); -} - -/** - * Build formatted HTML body with matrix.to links for mentions. - * @param {string} resolvedBody - Message body with canonical mention IDs - * @returns {string} HTML formatted body - */ -function buildFormattedMentionBody(resolvedBody) { - const mentionRegex = /@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g; - return escapeHtml(resolvedBody).replace(mentionRegex, (matchedValue, userBody) => { - const userId = `@${userBody}`; - const knownDisplayName = chatMode.displayNames[userId]; - const fallbackDisplayName = userId.split(':')[0].substring(1); - const displayName = knownDisplayName || fallbackDisplayName; - const matrixToUrl = `https://matrix.to/#/${userId}`; - return `@${escapeHtml(displayName)}`; - }); -} - -/** - * Build rich mention payload without m.mentions for compatibility fallback. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string, format?: string, formatted_body?: string}} Matrix content payload - */ -function buildRichMentionFallbackContent(message) { - const resolvedBody = transformOutgoingMentions(message); - if (!hasCanonicalMentions(resolvedBody)) { - return { - msgtype: 'm.text', - body: resolvedBody - }; - } - - return { - msgtype: 'm.text', - body: resolvedBody, - format: 'org.matrix.custom.html', - formatted_body: buildFormattedMentionBody(resolvedBody) - }; -} - -/** - * Build Matrix message content with mention metadata and formatted HTML. - * @param {string} message - Outgoing raw message - * @returns {{msgtype: string, body: string, "m.mentions"?: object, format?: string, formatted_body?: string}} Matrix content payload - */ -function buildMentionMessageContent(message) { - const resolvedBody = transformOutgoingMentions(message); - const hasMentions = hasCanonicalMentions(resolvedBody); - - if (!hasMentions) { - return { - msgtype: 'm.text', - body: resolvedBody - }; - } - - return { - msgtype: 'm.text', - body: resolvedBody, - 'm.mentions': {}, - format: 'org.matrix.custom.html', - formatted_body: buildFormattedMentionBody(resolvedBody) - }; -} - -/** - * Render Matrix user mentions as highlighted @displayname values. - * @param {string} text - Chat message body - * @returns {string} Formatted message text for terminal output - */ -function formatChatTextWithMentions(text) { - if (!text) { - return ''; - } - - return text.replace(/@([A-Za-z0-9._\-=\/]+:[A-Za-z0-9.-]+)/g, (matchValue, userBody) => { - const userId = `@${userBody}`; - const knownDisplayName = chatMode.displayNames[userId]; - const fallbackDisplayName = userId.split(':')[0].substring(1); - const displayName = knownDisplayName || fallbackDisplayName; - return `\x1b[1;96m@${displayName}\x1b[0m`; - }); -} - -/** - * Cycle mention suggestions and apply selected display name. - * @returns {Promise} - */ -async function applyMentionAutocomplete() { - if (!chatMode.active) { - return; - } - - if (chatMode.mentionAutocomplete.matches.length === 0) { - await refreshMentionSuggestionsFromInput(); - if (chatMode.mentionAutocomplete.matches.length === 0) { - return; - } - chatMode.mentionAutocomplete.index = 0; - } else { - chatMode.mentionAutocomplete.index = (chatMode.mentionAutocomplete.index + 1) - % chatMode.mentionAutocomplete.matches.length; - } - - const selectedEntry = chatMode.mentionAutocomplete.matches[chatMode.mentionAutocomplete.index]; - applyMentionReplacement(selectedEntry); - renderMentionSuggestions(); -} - -// Enter chat mode -async function enterChatMode() { - if (!window.matrixSession || !window.matrixSession.roomId) { - term.writeln('\r\n Error: Not connected to chat. Use "chat" command first.\r\n'); - return; - } - - chatMode.active = true; - chatMode.messages = []; - chatMode.lastSync = null; - chatMode.inputLine = ''; - chatMode.mentionAutocomplete = { - tokenStart: -1, - tokenEnd: -1, - matches: [], - index: 0 - }; - resetMentionAutocomplete(); - await syncMentionDirectory(true); - - term.clear(); - term.writeln('╔════════════════════════════════════════════════════════════╗'); - term.writeln('║ CHAT - #generalchat ║'); - term.writeln('║ Type /help for commands ║'); - term.writeln('╚════════════════════════════════════════════════════════════╝'); - - // Fetch initial messages - await syncChatMessages(); - - // Show channel presence checker for litruv account - const presenceTime = new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - const litruvPresence = await fetchPublicPresence('https://chat.ruv.wtf', '@litruv:b.ruv.wtf'); - const litruvOnlineText = litruvPresence === 'online' ? 'online' : 'offline'; - term.writeln(`\x1b[90m[${presenceTime}]\x1b[0m \x1b[93mSystem:\x1b[0m @litruv:b.ruv.wtf is ${litruvOnlineText}`); - - // Show nickname setup hint in message history when display name is still default - const showNicknameHint = await shouldShowNicknameHint(); - if (showNicknameHint) { - const hintTime = new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - term.writeln(`\x1b[90m[${hintTime}]\x1b[0m \x1b[93mSystem:\x1b[0m You are using a default name. Use /nick [name] to change it.`); - } - - // Start polling for new messages - chatMode.pollInterval = setInterval(async () => { - await syncChatMessages(true); - }, 3000); - - // Add separator and initial prompt - term.writeln(''); - term.writeln('─'.repeat(term.cols || 60)); - term.write('\x1b[1;32m>\x1b[0m '); - - // Delay showing input to ensure terminal has rendered and cursor is positioned - setTimeout(() => { - showInlineInput(); - }, 50); - - updateQuickCommands('chat'); -} - -// Exit chat mode -function exitChatMode() { - chatMode.active = false; - if (chatMode.pollInterval) { - clearInterval(chatMode.pollInterval); - chatMode.pollInterval = null; - } - chatMode.displayNames = {}; // Clear display name cache - chatMode.mentionDirectory = []; - chatMode.mentionLoadedAt = 0; - resetMentionAutocomplete(); - term.clear(); - term.writeln(' Exited chat mode.'); - writePrompt(); - showInlineInput(); - updateQuickCommands('terminal'); -} - -/** - * Update quick command buttons based on context - * @param {string} mode - 'terminal' or 'chat' - */ -function updateQuickCommands(mode) { - const container = document.querySelector('.quick-commands'); - const statusLeft = document.querySelector('.status-left'); - if (!container) return; - - if (mode === 'chat') { - if (statusLeft) statusLeft.textContent = 'Chat Mode'; - container.innerHTML = ` - - - - `; - } else { - if (statusLeft) statusLeft.textContent = 'Ready'; - container.innerHTML = ` - - - - - `; - } -} - -/** - * Run a chat command from button click - * @param {string} command - The chat command to run - */ -function runChatCommand(command) { - if (!chatMode.active) return; - - if (command === '/nick') { - // For /nick, just fill in the command prefix - inlineInput.value = '/nick '; - chatMode.inputLine = inlineInput.value; - resetMentionAutocomplete(); - inlineInput.focus(); - return; - } - - // Execute the command - inlineInput.value = command; - submitInlineInput(); -} - -window.runChatCommand = runChatCommand; - -// Sync messages from Matrix -async function syncChatMessages(onlyNew = false) { - try { - let endpoint = `/rooms/${window.matrixSession.roomId}/messages?dir=b&limit=50`; - const data = await matrixApi(endpoint); - - if (!data || !data.chunk) return; - - const newMessages = []; - for (const event of data.chunk.reverse()) { - if (event.type === 'm.room.message' && event.content.msgtype === 'm.text') { - const msgId = event.event_id; - const exists = chatMode.messages.find(m => m.id === msgId); - - if (!exists) { - const timestamp = new Date(event.origin_server_ts); - const time = timestamp.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); - - // Get display name for sender - const displayName = await getDisplayName(event.sender); - - newMessages.push({ - id: msgId, - time: time, - sender: displayName, - userId: event.sender, - text: event.content.body - }); - } - } - } - - if (newMessages.length > 0) { - chatMode.messages.push(...newMessages); - - // Only keep last 100 messages in memory - if (chatMode.messages.length > 100) { - chatMode.messages = chatMode.messages.slice(-100); - } - - // Render new messages if in chat mode and only updating - if (onlyNew && chatMode.active) { - newMessages.forEach(msg => { - renderChatMessage(msg); - }); - } else if (!onlyNew && chatMode.active) { - // Initial load - show last 20 messages (simple print, no cursor manipulation) - const recent = chatMode.messages.slice(-20); - recent.forEach(msg => { - const color = getUserColor(msg.sender); - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - }); - } - } - - chatMode.lastSync = Date.now(); - } catch (error) { - console.error('Sync error:', error); - } -} - -// Render a chat message (insert above the prompt area) -function renderChatMessage(msg) { - const color = getUserColor(msg.sender); - - // Move up to separator line and clear it and the prompt line - term.write('\x1b[1A\x1b[2K\r'); // Move up to separator, clear it - - // Write the new message - term.writeln(`\x1b[90m[${msg.time}]\x1b[0m ${color}${msg.sender}:\x1b[0m ${formatChatTextWithMentions(msg.text)}`); - - // Redraw separator - term.writeln('─'.repeat(term.cols || 60)); - - // Redraw prompt with current input - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); - - // Scroll to bottom to show new message - term.scrollToBottom(); - - // Reposition the inline input after terminal updates - setTimeout(() => positionInlineInput(), 10); -} - -// Render chat input prompt (efficiently) -function renderChatPrompt() { - // Move to beginning of line, redraw prompt and input - term.write('\r\x1b[K'); // CR + clear rest of line - term.write(`\x1b[1;32m>\x1b[0m ${chatMode.inputLine}`); -} - -// Send chat message -async function sendChatMessage(message) { - if (!message.trim()) return; - - try { - await syncMentionDirectory(); - const content = buildMentionMessageContent(message); - const hasMentions = hasCanonicalMentions(content.body); - const txnId = Date.now().toString(); - let sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}`, - 'PUT', - content - ); - - if (sendResult && sendResult.errcode && hasMentions) { - const richFallbackContent = buildRichMentionFallbackContent(message); - sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-richfallback`, - 'PUT', - richFallbackContent - ); - } - - if (sendResult && sendResult.errcode && !hasMentions) { - const fallbackContent = buildPlainMentionMessageContent(message); - sendResult = await matrixApi( - `/rooms/${window.matrixSession.roomId}/send/m.room.message/${txnId}-fallback`, - 'PUT', - fallbackContent - ); - } - - if (sendResult && sendResult.errcode) { - throw new Error(sendResult.error || sendResult.errcode || 'Failed to send message'); - } - - // Clear the input - chatMode.inputLine = ''; - resetMentionAutocomplete(); - renderChatPrompt(); - - // Reposition input after render - setTimeout(() => positionInlineInput(), 10); - - // Immediately sync to show our message - setTimeout(() => syncChatMessages(true), 500); - } catch (error) { - // Show error above separator - term.write('\x1b[1A\x1b[2K\r'); - term.writeln(`\x1b[31mError: ${error.message}\x1b[0m`); - term.writeln('─'.repeat(term.cols || 60)); - term.write(`\x1b[1;32m>\x1b[0m `); - setTimeout(() => positionInlineInput(), 10); - } -} +// Matrix client - imported from matrix-client.js // Available commands const commands = { @@ -1584,6 +479,10 @@ const commands = { description: blueskyCmd.description, execute: async (args) => await blueskyCmd.execute(term, writeClickable, VERSION, args, commandHistory) }, + numbermatch: { + description: numbermatchCmd.description, + execute: (args) => numbermatchCmd.execute(term, writeClickable, VERSION, args, commandHistory) + }, chat: { description: 'Connect to chat room', execute: async (args) => { @@ -2058,6 +957,17 @@ async function submitInlineInput() { return; } + // Handle game mode + if (gameMode.active) { + term.write(rawValue + '\r\n'); + const continueGame = processGameInput(term, cmd); + if (!continueGame) { + writePrompt(); + } + setTimeout(() => positionInlineInput(), 10); + return; + } + // Normal command mode term.write(rawValue + '\r\n'); @@ -2065,20 +975,85 @@ async function submitInlineInput() { await executeCommand(cmd); } - // Show prompt if not in chat mode - if (!chatMode.active) { + // Show prompt if not in chat mode or game mode + // Game mode handles its own prompt in renderBoard + if (!chatMode.active && !gameMode.active) { writePrompt(); // Delay to ensure terminal has rendered before positioning setTimeout(() => { positionInlineInput(); }, 10); + } else if (gameMode.active) { + setTimeout(() => positionInlineInput(), 10); } term.scrollToBottom(); } +/** + * Write startup chat MOTD with last message and presence + */ +async function writeStartupChatMotd() { + try { + const result = await Promise.race([ + (async () => { + const [latest, presence] = await Promise.all([ + fetchPublicLastMessage('#generalchat:b.ruv.wtf'), + fetchPublicPresence('@litruv:b.ruv.wtf') + ]); + return { latest, presence }; + })(), + new Promise((resolve) => setTimeout(() => resolve({ latest: null, presence: null }), 2500)) + ]); + + if (!result.latest && !result.presence) { + return; + } + + if (result.latest) { + const sender = result.latest.sender.split(':')[0].substring(1); + const age = formatTimeAgo(result.latest.timestamp); + term.write(` \x1b[36m${sender}\x1b[0m: ${result.latest.body.substring(0, 50)}${result.latest.body.length > 50 ? '...' : ''} \x1b[90m(${age})\x1b[0m\r\n`); + } + + if (result.presence) { + const statusColor = result.presence.presence === 'online' ? '\x1b[32m' : '\x1b[90m'; + const lastActive = result.presence.lastActive > 0 ? formatTimeAgo(Date.now() - result.presence.lastActive) : null; + term.write(` \x1b[36mlitruv\x1b[0m ${statusColor}${result.presence.presence}\x1b[0m`); + if (result.presence.presence !== 'online' && lastActive) { + term.write(` \x1b[90m(${lastActive})\x1b[0m`); + } + term.writeln(''); + } + + term.write(' '); + writeClickable('[command=chat]', 'Run \x1b[32mchat\x1b[0m to join in on the conversation!'); + term.writeln('\r\n'); + } catch (error) { + // Silently fail - not critical for startup + } +} + // Initialize terminal async function init() { + // Initialize Matrix client with config and dependencies + initMatrixClient(MATRIX_CONFIG, { + term, + inlineInput, + mentionSuggestions, + writeClickable, + writePrompt, + showInlineInput, + positionInlineInput, + submitInlineInput, + welcomeBannerFull, + welcomeBannerCompact, + welcomeBannerMinimal + }); + + // Expose chat command for onclick handlers + window.runChatCommand = runChatCommand; + // Display welcome banner with clickable commands const cols = term.cols; if (cols >= 78) { @@ -2199,6 +1174,13 @@ async function runQuickCommand(command) { // Make function available globally for onclick handlers window.runQuickCommand = runQuickCommand; +// Expose sound functions for game modules +window.terminalSounds = { + files: TERMINAL_SOUND_FILES, + play: playSoundFile, + pickRandom: pickRandomValue +}; + /** * Write text to terminal with clickable commands * Parses [command=X] syntax to create clickable command links