// Main file - Game initialization let catanBoard; let gameState = null; let eventSource = null; let pointMapping = null; // Point mapping - will be loaded from server let playerNames = {}; // Store player names from game window.replayServerEventsConnected = false; // Global functions for control buttons function zoomIn() { if (catanBoard) { catanBoard.zoomIn(); } } function zoomOut() { if (catanBoard) { catanBoard.zoomOut(); } } function resetZoom() { if (catanBoard) { catanBoard.resetZoom(); } } function toggleVertices() { if (catanBoard) { catanBoard.toggleVertices(); } } // Toggle building costs modal function toggleBuildingCosts() { const modal = document.getElementById('buildingCostsModal'); if (modal) { modal.classList.toggle('hidden'); } } // Clear action log function clearActionLog() { const logDiv = document.getElementById('action-log'); if (logDiv) { logDiv.innerHTML = '
Log cleared ✓
'; } } // טעינת מיפוי נקודות מהשרת function loadPointMapping() { return fetch('/api/point_mapping') .then(response => response.json()) .then(data => { pointMapping = data; // console.log('🗺️ Point mapping loaded:', `${data.total_points} points`); // console.log(' Example: point 1 at', data.point_to_coords[1]); // Make mapping global so board.js can access it window.pointMapping = pointMapping; return pointMapping; }) .catch(error => { console.error('❌ Error loading point mapping:', error); // fallback - create basic mapping pointMapping = { point_to_coords: {}, coords_to_point: {}, total_points: 54, all_points: Array.from({length: 54}, (_, i) => i + 1) }; return pointMapping; }); } // חיבור ל-Flask server function connectToServer() { console.log('🔗 Connecting to server...'); // First load point mapping loadPointMapping().then(() => { console.log('✓ Point mapping loaded'); // Now load game state return Promise.all([ fetch('/api/game-state', {timeout: 5000}), fetch('/api/actions'), fetch('/api/chat') ]); }) .then(responses => { return Promise.all(responses.map(r => { if (!r.ok) throw new Error(`Server responded with ${r.status}`); return r.json(); })); }) .then(([gameStateData, actionsData, chatData]) => { console.log('📥 Game state received from server:', gameStateData); // Check if state is empty (no hexes) if (!gameStateData.hexes || gameStateData.hexes.length === 0) { console.log('⚠️ Server state is empty, using GAMEDATA as fallback'); updateGameState(GAMEDATA); } else { updateGameState(gameStateData); } // Store player names for later use if (gameStateData.players) { gameStateData.players.forEach((player, index) => { playerNames[index] = player.name || `Player ${index + 1}`; }); } // Load action history if (actionsData && Array.isArray(actionsData)) { console.log(`📥 Loaded ${actionsData.length} previous actions`); actionsData.forEach(action => logAction(action)); } if (chatData && Array.isArray(chatData)) { console.log(`Loaded ${chatData.length} previous chat messages`); chatData.forEach(chat => handlePlayerChat(chat)); } console.log('✓ Server connection established successfully'); // Connect to real-time updates connectToSSE(); }) .catch(error => { console.error('❌ Error connecting to server:', error); console.log('🔄 Using GAMEDATA as fallback'); updateGameState(GAMEDATA); }); } // Connect to Server-Sent Events for real-time updates function connectToSSE() { try { eventSource = new EventSource('/api/events'); eventSource.onopen = function() { window.replayServerEventsConnected = true; document.dispatchEvent(new Event('server-events-connected')); }; eventSource.onmessage = function(event) { const data = JSON.parse(event.data); // console.log('📡 Update from server:', data); if (data.type === 'game_update' || data.type === 'state_updated') { // Update player names if we get new player data if (data.payload.players) { data.payload.players.forEach((player, index) => { playerNames[index] = player.name || `Player ${index + 1}`; }); } updateGameState(data.payload); } else if (data.type === 'action_executed') { logAction(data.payload); if (typeof handleBoardActionEvent === 'function') { handleBoardActionEvent(data.payload); } // Refresh game state to show any changes refreshGameState(); } else if (data.type === 'dice_roll') { logEvent(data.payload, 'log-dice'); if (typeof showBoardDiceRoll === 'function') { showBoardDiceRoll(data.payload.dice_values, data.payload.player_name); } } else if (data.type === 'resource_distribution') { logResourceDistribution(data.payload); if (typeof showBoardResourceDistribution === 'function') { showBoardResourceDistribution(data.payload.distributions); } // Refresh game state to show updated resources refreshGameState(); } else if (data.type === 'log_event') { logAction(data.payload); if (typeof handleBoardActionEvent === 'function') { handleBoardActionEvent(data.payload); } } else if (data.type === 'turn_start') { logEvent(data.payload, 'log-turn'); } else if (data.type === 'message') { logEvent(data.payload, 'info'); } else if (data.type === 'error') { logEvent(data.payload, 'error'); } else if (data.type === 'player_chat') { // Handle player chat message (say_outloud) handlePlayerChat(data.payload); } else if (data.type === 'ai_status') { // Handle AI thinking status update handleAIStatus(data.payload); } else if (data.type === 'replay_seek') { handleReplaySeek(data.payload); } }; eventSource.onerror = function(error) { console.error('❌ Error in SSE connection:', error); }; console.log('✅ Connected to real-time updates'); } catch (error) { console.warn('⚠️ Unable to connect to SSE:', error); } } function handleReplaySeek(payload) { if (!payload) return; if (payload.game_state) { updateGameState(payload.game_state); } if (typeof handleBoardReplaySnapshot === 'function') { handleBoardReplaySnapshot(payload); } renderActionHistory(payload.action_history || []); renderChatHistory(payload.chat_history || []); if (payload.phase === 'speech' && Array.isArray(payload.chat_history) && payload.chat_history.length) { const latestChat = payload.chat_history[payload.chat_history.length - 1]; const playerName = latestChat.player_name || latestChat.from || latestChat.player; const message = latestChat.message || latestChat.text; if (playerName && message && typeof showPlayerChatBubble === 'function') { showPlayerChatBubble(playerName, message); } } if (window.replayControls && typeof window.replayControls.updateFromPayload === 'function') { window.replayControls.updateFromPayload(payload); } } function renderActionHistory(actions) { const logDiv = document.getElementById('action-log'); if (!logDiv) return; logDiv.innerHTML = ''; if (!actions.length) { logDiv.innerHTML = '
Waiting for updates...
'; return; } actions.forEach(action => logAction(action)); } function renderChatHistory(messages) { const chatLog = document.getElementById('chat-log'); if (!chatLog) return; chatLog.innerHTML = ''; if (!messages.length) { chatLog.innerHTML = '
No chat messages yet...
'; return; } messages.forEach(msg => { const playerName = msg.player_name || msg.from || msg.player || 'Unknown'; const message = msg.message || msg.text || ''; const timestamp = msg.timestamp || ''; const chatElement = document.createElement('div'); chatElement.className = 'chat-log-message'; chatElement.innerHTML = `
נŸ₪– ${escapeHtmlLocal(playerName)} ${escapeHtmlLocal(timestamp)}
"${escapeHtmlLocal(message)}"
`; chatLog.appendChild(chatElement); }); scrollChatLogToBottom(); } function scrollChatLogToBottom() { const chatLog = document.getElementById('chat-log'); const chatPanel = document.getElementById('chat-log-panel'); const scrollTargets = [chatLog, chatPanel].filter(Boolean); if (!scrollTargets.length) return; const scrollToEnd = () => { scrollTargets.forEach(element => { element.scrollTop = element.scrollHeight; }); }; scrollToEnd(); requestAnimationFrame(scrollToEnd); } // Refresh game state from server async function refreshGameState() { try { const response = await fetch('/api/game-state'); if (response.ok) { const newState = await response.json(); updateGameState(newState); } } catch (error) { console.warn('Failed to refresh game state:', error); } } // Update game state function updateGameState(newState) { const previousState = gameState; gameState = newState; window.gameState = gameState; // Make globally accessible for unified UI if (catanBoard) { catanBoard.updateFromGameState(gameState); // Update vertex IDs if we have mapping if (pointMapping) { catanBoard.updateVertexIDsFromMapping(); } } updateGameInfo(gameState); if (typeof handleBoardStateUpdate === 'function') { handleBoardStateUpdate(gameState, previousState); } } // Update player information display function updateGameInfo(state) { const gameInfoDiv = document.getElementById('game-info'); if (!gameInfoDiv) return; // Preserve expanded state const expandedPlayers = new Set(); document.querySelectorAll('.player-info.expanded').forEach(el => { expandedPlayers.add(el.dataset.playerId); }); let html = '

📋 Game Info

'; if (state.players) { state.players.forEach((player, index) => { const activeClass = state.current_player === index ? 'active' : ''; const isExpanded = expandedPlayers.has(String(index)) ? 'expanded' : ''; const playerColors = ['#FF4444', '#4444FF', '#44FF44', '#FFAA00']; const playerColor = playerColors[index % 4]; // Get player name from stored names or use default const playerName = playerNames[index] || player.name || `Player ${index + 1}`; // Format cards lists let cardsHtml = ''; if (player.cards_list && player.cards_list.length > 0) { // Count cards by type const cardCounts = {}; player.cards_list.forEach(card => { cardCounts[card] = (cardCounts[card] || 0) + 1; }); cardsHtml += '
Resources:
'; } else { cardsHtml += '
No resource cards
'; } let devCardsHtml = ''; if (player.dev_cards_list && player.dev_cards_list.length > 0) { // Count dev cards by type const devCardCounts = {}; player.dev_cards_list.forEach(card => { devCardCounts[card] = (devCardCounts[card] || 0) + 1; }); devCardsHtml += '
Development:
'; } html += `

👤 ${playerName}

🏆 VP: ${player.victory_points || 0} | 🎴 Cards: ${player.total_cards || 0}
🏘️: ${player.settlements || 0} | 🏛️: ${player.cities || 0} | 🛣️: ${player.roads || 0}
${cardsHtml} ${devCardsHtml}
`; }); } if (state.current_phase) { html += `
📍 Current Phase: ${state.current_phase}
`; } gameInfoDiv.innerHTML = html; // Update unified UI if available (for unified view) if (window.unifiedUI) { window.unifiedUI.renderPlayerHub(state.players); window.unifiedUI.updateGameDetails(state); window.unifiedUI.updateGameStats(state); } } // Toggle player info visibility window.togglePlayerInfo = function(element) { element.classList.toggle('expanded'); } // Log action function logAction(actionData) { const logDiv = document.getElementById('action-log'); if (!logDiv) return; const actionElement = document.createElement('div'); // Determine class based on action type if needed, or just success/error let className = actionData.success ? 'success' : 'error'; let prefix = actionData.success ? '✓' : '✗'; // Add specific classes for certain actions if (actionData.success) { if (actionData.action_type && actionData.action_type.includes('BUILD')) { className = 'log-build'; prefix = '🔨'; } } actionElement.className = className; actionElement.classList.add('event-log-entry'); actionElement.innerHTML = renderEventLogEntry(prefix, actionData); appendToLog(logDiv, actionElement); } function renderEventLogEntry(prefix, actionData) { const timestamp = actionData.timestamp || new Date().toLocaleTimeString('en-GB', { hour12: false }); const formatted = formatActionEventForDisplay(actionData); const detailHtml = formatted.details.length ? `
${formatted.details.map(detail => `${escapeHtmlLocal(detail)}`).join('')}
` : ''; return `
${escapeHtmlLocal(formatted.icon || prefix)} ${escapeHtmlLocal(formatted.message)} ${escapeHtmlLocal(timestamp)}
${detailHtml} `; } function formatActionEventForDisplay(actionData) { const data = actionData.data || {}; const eventType = String(actionData.event_type || actionData.action_type || '').toUpperCase(); const player = actionData.player_name || data.player_name || 'Player'; const fallback = actionData.message || `${player} performed ${eventType || 'action'}`; const details = []; if (eventType === 'TRADE_BANK') { const give = formatResourceBundleForLog(data.give || data.offer); const receive = formatResourceBundleForLog(data.receive || data.request); return { icon: '🏦', message: `${player} traded with bank`, details: [`Gave: ${give}`, `Received: ${receive}`] }; } if (eventType === 'TRADE_EXECUTE') { const toPlayer = data.to_player || data.target_player || 'other player'; return { icon: '✅', message: `${player} traded with ${toPlayer}`, details: [ `${player} gave: ${formatResourceBundleForLog(data.offer || data.give)}`, `${player} received: ${formatResourceBundleForLog(data.request || data.receive)}` ] }; } if (eventType === 'TRADE_RESPONSE') { const response = data.response || data.trade_status || 'RESPONDED'; const toPlayer = data.to_player || data.target_player; return { icon: response === 'ACCEPT' ? '✅' : '✕', message: toPlayer ? `${toPlayer} ${response.toLowerCase()} ${player}'s trade` : fallback, details }; } if (eventType === 'ROBBER_STEAL') { const victim = data.victim || data.victim_name || 'another player'; const card = formatCardNameForLog(data.card || data.stolen_card || 'card'); return { icon: '🎯', message: `${player} stole ${card} from ${victim}`, details }; } if (eventType === 'ROBBER_MOVE' && data.victim) { const card = formatCardNameForLog(data.card || data.stolen_card || 'card'); return { icon: '🦹', message: `${player} moved robber to tile ${data.tile || '?'} and stole ${card} from ${data.victim}`, details }; } if (eventType === 'DISCARD_CARDS') { return { icon: '🗑️', message: `${player} discarded cards`, details: [formatResourceBundleForLog(data.discarded || data.cards)] }; } if (eventType === 'USE_DEV_CARD') { const card = formatCardNameForLog(data.card || data.card_type || 'development card'); if (String(card).toLowerCase().includes('monopoly') && data.total_stolen) { details.push(`Took ${data.total_stolen} ${formatCardNameForLog(data.resource || data.resource_type || '')}`); } if (String(card).toLowerCase().includes('year') && (data.gained || data.resources)) { details.push(`From bank: ${formatResourceBundleForLog(data.gained || data.resources)}`); } return { icon: '✨', message: `${player} used ${card}`, details }; } return { icon: '', message: fallback, details }; } function formatResourceBundleForLog(bundle) { const counts = normalizeResourceBundleForLog(bundle); const parts = Object.entries(counts) .filter(([, count]) => Number(count || 0) > 0) .map(([resource, count]) => `${count}x ${formatCardNameForLog(resource)}`); return parts.length ? parts.join(', ') : 'nothing'; } function normalizeResourceBundleForLog(bundle) { const counts = {}; if (!bundle) return counts; if (Array.isArray(bundle)) { bundle.forEach(card => { const key = normalizeCardKeyForLog(card); if (key) counts[key] = (counts[key] || 0) + 1; }); return counts; } if (typeof bundle === 'object') { Object.entries(bundle).forEach(([card, count]) => { const key = normalizeCardKeyForLog(card); const amount = Number(count || 0); if (key && amount > 0) counts[key] = (counts[key] || 0) + amount; }); return counts; } const key = normalizeCardKeyForLog(bundle); if (key) counts[key] = 1; return counts; } function normalizeCardKeyForLog(card) { return String(card || '').trim().toLowerCase().replace(/^(rescard|devcard)\./, '').replace(/[\s-]/g, '_'); } function formatCardNameForLog(card) { const key = normalizeCardKeyForLog(card); const labels = { wood: 'Wood', brick: 'Brick', sheep: 'Sheep', wool: 'Sheep', wheat: 'Wheat', grain: 'Wheat', ore: 'Ore', knight: 'Knight', road: 'Road Building', road_building: 'Road Building', monopoly: 'Monopoly', yearofplenty: 'Year of Plenty', year_of_plenty: 'Year of Plenty', victorypoint: 'Victory Point', victory_point: 'Victory Point' }; return labels[key] || String(card || 'Card').replace(/_/g, ' '); } // Log generic event function logEvent(data, className) { const logDiv = document.getElementById('action-log'); if (!logDiv) return; const element = document.createElement('div'); element.className = className; const timestamp = data.timestamp || new Date().toLocaleTimeString('en-GB', { hour12: false }); // Add emoji prefix based on type let prefix = ''; if (className === 'log-dice') prefix = '🎲'; else if (className === 'log-turn') prefix = '➤'; else if (className === 'info') prefix = 'ℹ️'; else if (className === 'error') prefix = '⚠️'; element.textContent = `${prefix} ${data.message}`; appendToLog(logDiv, element); } // Log resource distribution specifically function logResourceDistribution(data) { const logDiv = document.getElementById('action-log'); if (!logDiv) return; const timestamp = data.timestamp || new Date().toLocaleTimeString('en-GB', { hour12: false }); // If we have detailed distributions, log them if (data.distributions) { for (const [player, resources] of Object.entries(data.distributions)) { if (resources && resources.length > 0) { const element = document.createElement('div'); element.className = 'log-resource'; // Count resources const counts = {}; resources.forEach(r => counts[r] = (counts[r] || 0) + 1); const resourceStr = Object.entries(counts) .map(([res, count]) => `${count}×${res}`) .join(' '); element.textContent = `📦 ${player}: ${resourceStr}`; appendToLog(logDiv, element); } } } else { // Fallback to generic message const element = document.createElement('div'); element.className = 'log-resource'; element.textContent = `📦 ${data.message}`; appendToLog(logDiv, element); } } // Handle player chat message (say_outloud) function handlePlayerChat(data) { const playerName = data.player_name || data.from || 'Unknown'; const message = data.message || ''; const timestamp = data.timestamp || new Date().toLocaleTimeString(); // Store chat message for player if (!window.playerChatMessages) { window.playerChatMessages = {}; } window.playerChatMessages[playerName] = message; // Add to chat log panel only (not action log) const chatLog = document.getElementById('chat-log'); if (chatLog) { // Remove "no messages" placeholder if exists const placeholder = chatLog.querySelector('.info'); if (placeholder) { placeholder.remove(); } const chatElement = document.createElement('div'); chatElement.className = 'chat-log-message'; chatElement.innerHTML = `
🤖 ${playerName} ${timestamp}
"${message}"
`; chatLog.appendChild(chatElement); // Keep only last 50 messages while (chatLog.children.length > 50) { chatLog.removeChild(chatLog.firstChild); } scrollChatLogToBottom(); } // Update unified UI if available - show chat bubble if (window.showPlayerChatBubble) { window.showPlayerChatBubble(playerName, message); } } // Track current active player for thinking log let currentThinkingPlayer = null; // Handle AI thinking status update function handleAIStatus(data) { const playerName = data.player_name; const status = data.status; // 'thinking', 'tool_call', 'processing', 'done' const details = data.details || ''; console.log(`[AI_STATUS] ${playerName}: ${status} - ${details.substring(0, 30)}...`); // Find the thinking log element for this player const logElement = document.getElementById(`thinking-log-${playerName}`); if (!logElement) { console.warn(`[AI_STATUS] Could not find thinking-log-${playerName}`); return; } if (status === 'done' || status === 'idle') { // Don't hide immediately - let the reasoning stay visible // It will be cleared when a different player starts thinking return; } else if (status === 'thinking') { // Check if this is a NEW player starting to think if (currentThinkingPlayer !== playerName) { // Different player - clear ALL player logs first document.querySelectorAll('.player-thinking-log').forEach(log => { log.innerHTML = ''; log.style.display = 'none'; }); currentThinkingPlayer = playerName; } // Don't clear - just show and add to the log logElement.style.display = 'block'; } else { // Show the log container for other statuses logElement.style.display = 'block'; } // Create status entry let entry; let icon = ''; let text = ''; // Handle text_stream specially - replace content in a box if (status === 'text_stream') { // Find or create the streaming text box let textBox = logElement.querySelector('.streaming-text-box'); if (!textBox) { textBox = document.createElement('div'); textBox.className = 'thinking-entry streaming-text-box'; textBox.innerHTML = '📝'; logElement.appendChild(textBox); } // Update the content (REPLACE, not append) const textSpan = textBox.querySelector('.streaming-text'); textSpan.textContent = details; // Scroll to show latest logElement.scrollTop = logElement.scrollHeight; return; // Don't create new entry } // Handle stream_done - remove the streaming box if (status === 'stream_done') { const textBox = logElement.querySelector('.streaming-text-box'); if (textBox) { textBox.remove(); } // Add completion icon entry = document.createElement('div'); entry.className = 'thinking-entry thinking-done'; entry.innerHTML = 'Ready'; logElement.appendChild(entry); return; } // Regular status entries entry = document.createElement('div'); entry.className = `thinking-entry thinking-${status}`; if (status === 'thinking') { icon = '💭'; text = details || 'Thinking...'; } else if (status === 'tool_call') { icon = '🔧'; // Handle multiline tool calls (reasoning on separate line) text = (details || 'Using tools...').replace(/\n/g, '
'); } else if (status === 'executing_tools') { icon = '⚙️'; text = details || 'Executing tools...'; } else if (status === 'processing') { icon = '⚙️'; text = details || 'Processing...'; } else if (status === 'reasoning') { icon = '💡'; text = details; } else { icon = '•'; text = details || status; } entry.innerHTML = `${icon}${text}`; logElement.appendChild(entry); // Scroll to show latest logElement.scrollTop = logElement.scrollHeight; // Keep only last 15 entries to show full chain while (logElement.children.length > 15) { logElement.removeChild(logElement.firstChild); } } // Helper to append to log and scroll function appendToLog(container, element) { container.appendChild(element); // Keep only last 100 items while (container.children.length > 100) { container.removeChild(container.firstChild); } // Scroll to bottom container.scrollTop = container.scrollHeight; } function escapeHtmlLocal(text) { const div = document.createElement('div'); div.textContent = text == null ? '' : String(text); return div.innerHTML; } // Game initialization document.addEventListener('DOMContentLoaded', async function() { console.log('🎲 Starting Catan board with server connection...'); try { // Create board instance (async) catanBoard = new CatanBoard(); window.catanBoard = catanBoard; // Expose to window for console access // Wait for board initialization if (catanBoard.init && typeof catanBoard.init === 'function') { console.log('⏳ Waiting for board initialization...'); await catanBoard.init(); console.log('✓ Board initialized successfully'); } // Connect to server connectToServer(); console.log('✅ Catan board created successfully!'); console.log('🎮 Usage instructions:'); console.log(' - Click on hex to move robber'); console.log(' - Use mouse wheel to zoom'); console.log(' - Drag mouse to pan'); console.log(' - Click 📍 to see vertex numbers'); } catch (error) { console.error('❌ Error initializing board:', error); // Try to create simple board anyway catanBoard = new CatanBoard(); connectToServer(); } }); // Cleanup on close window.addEventListener('beforeunload', function() { if (eventSource) { eventSource.close(); } });