// 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(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:';
for (const [card, count] of Object.entries(cardCounts)) {
cardsHtml += `- ${card}: ${count}
`;
}
cardsHtml += '
';
} 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:';
for (const [card, count] of Object.entries(devCardCounts)) {
devCardsHtml += `- ${card}: ${count}
`;
}
devCardsHtml += '
';
}
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 = `
"${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();
}
});