/** * GameScene - Main game rendering * Optimized to prevent memory leaks and CPU accumulation */ import { api } from '../api.js'; import { PROFESSIONS, RESOURCES, ACTIONS, WORLD_ZONES, DISPLAY } from '../constants.js'; export default class GameScene extends Phaser.Scene { constructor() { super({ key: 'GameScene' }); this.agents = new Map(); this.selectedAgent = null; this.worldWidth = 20; this.worldHeight = 20; this.tileSize = DISPLAY.TILE_SIZE; this.isAutoMode = false; this.isStepPending = false; this.autoInterval = null; this.autoSpeed = 150; this.pollingInterval = null; // Cache DOM elements to avoid repeated queries this.domCache = {}; // Bound handlers for cleanup this.boundHandlers = {}; // Statistics history for charts this.statsHistory = { turns: [], population: [], deaths: [], money: [], avgWealth: [], giniCoefficient: [], professions: {}, resourcePrices: {}, tradeVolume: [], // Resource tracking (per turn) resourcesProduced: {}, resourcesConsumed: {}, resourcesSpoiled: {}, // Resource tracking (cumulative) resourcesProducedCumulative: {}, resourcesConsumedCumulative: {}, resourcesSpoiledCumulative: {}, resourcesTraded: {}, // from market trades }; this.maxHistoryPoints = 200; // Chart instances this.charts = {}; // Current state cache for stats this.currentState = null; // Stats view state this.statsViewActive = false; } create() { const state = this.registry.get('simulationState'); if (state) { this.worldWidth = state.world_size?.width || 20; this.worldHeight = state.world_size?.height || 20; } const gameWidth = this.worldWidth * this.tileSize; const gameHeight = this.worldHeight * this.tileSize; this.createWorld(gameWidth, gameHeight); this.agentContainer = this.add.container(0, 0); this.cameras.main.setBounds(0, 0, gameWidth, gameHeight); this.cameras.main.setBackgroundColor(0x151921); this.fitWorldToView(gameWidth, gameHeight); this.setupCameraControls(); // Cache DOM elements once this.cacheDOMElements(); // Setup UI with proper cleanup tracking this.setupUIControls(); if (state) { this.updateFromState(state); } this.startStatePolling(); this.updateConnectionStatus(true); // Clean up on scene shutdown this.events.on('shutdown', this.cleanup, this); this.events.on('destroy', this.cleanup, this); } cacheDOMElements() { this.domCache = { dayDisplay: document.getElementById('day-display'), timeDisplay: document.getElementById('time-display'), turnDisplay: document.getElementById('turn-display'), statAlive: document.getElementById('stat-alive'), statDead: document.getElementById('stat-dead'), statMoney: document.getElementById('stat-money'), professionList: document.getElementById('profession-list'), marketPrices: document.getElementById('market-prices'), agentDetails: document.getElementById('agent-details'), activityLog: document.getElementById('activity-log'), connectionStatus: document.getElementById('connection-status'), btnStep: document.getElementById('btn-step'), btnAuto: document.getElementById('btn-auto'), btnInitialize: document.getElementById('btn-initialize'), btnStats: document.getElementById('btn-stats'), speedSlider: document.getElementById('speed-slider'), speedDisplay: document.getElementById('speed-display'), // Stats screen elements statsScreen: document.getElementById('stats-screen'), btnCloseStats: document.getElementById('btn-close-stats'), tabButtons: document.querySelectorAll('.tab-btn'), tabPanels: document.querySelectorAll('.tab-panel'), // Stats summary elements statsTurn: document.getElementById('stats-turn'), statsLiving: document.getElementById('stats-living'), statsDeaths: document.getElementById('stats-deaths'), statsGold: document.getElementById('stats-gold'), statsAvgWealth: document.getElementById('stats-avg-wealth'), statsGini: document.getElementById('stats-gini'), // GOAP elements goapAgentList: document.getElementById('goap-agent-list'), goapPlanView: document.getElementById('goap-plan-view'), goapActionsList: document.getElementById('goap-actions-list'), chartGoapGoals: document.getElementById('chart-goap-goals'), // Stats screen controls (duplicated for stats page) btnStepStats: document.getElementById('btn-step-stats'), btnAutoStats: document.getElementById('btn-auto-stats'), btnInitializeStats: document.getElementById('btn-initialize-stats'), speedSliderStats: document.getElementById('speed-slider-stats'), speedDisplayStats: document.getElementById('speed-display-stats'), }; // GOAP state this.goapData = null; this.selectedGoapAgentId = null; } cleanup() { // Clear intervals if (this.autoInterval) { clearInterval(this.autoInterval); this.autoInterval = null; } if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } // Remove event listeners const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider } = this.domCache; if (btnStep && this.boundHandlers.step) { btnStep.removeEventListener('click', this.boundHandlers.step); } if (btnAuto && this.boundHandlers.auto) { btnAuto.removeEventListener('click', this.boundHandlers.auto); } if (btnInitialize && this.boundHandlers.init) { btnInitialize.removeEventListener('click', this.boundHandlers.init); } if (speedSlider && this.boundHandlers.speed) { speedSlider.removeEventListener('input', this.boundHandlers.speed); } if (btnStats && this.boundHandlers.openStats) { btnStats.removeEventListener('click', this.boundHandlers.openStats); } if (btnCloseStats && this.boundHandlers.closeStats) { btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats); } // Stats screen controls cleanup const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats } = this.domCache; if (btnStepStats && this.boundHandlers.step) { btnStepStats.removeEventListener('click', this.boundHandlers.step); } if (btnAutoStats && this.boundHandlers.auto) { btnAutoStats.removeEventListener('click', this.boundHandlers.auto); } if (btnInitializeStats && this.boundHandlers.init) { btnInitializeStats.removeEventListener('click', this.boundHandlers.init); } if (speedSliderStats && this.boundHandlers.speedStats) { speedSliderStats.removeEventListener('input', this.boundHandlers.speedStats); } // Destroy charts Object.values(this.charts).forEach(chart => chart?.destroy()); this.charts = {}; // Clear agents this.agents.forEach(sprite => sprite.destroy()); this.agents.clear(); } createWorld(gameWidth, gameHeight) { const zones = Object.values(WORLD_ZONES); zones.forEach(zone => { const x = gameWidth * zone.start; const width = gameWidth * (zone.end - zone.start); this.add.rectangle( x + width / 2, gameHeight / 2, width, gameHeight, zone.color, 0.3 ); }); zones.forEach(zone => { const x = gameWidth * ((zone.start + zone.end) / 2); this.add.text(x, 10, zone.name, { fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', color: '#6b6560', }).setOrigin(0.5, 0); }); const gridGraphics = this.add.graphics(); gridGraphics.lineStyle(1, 0x3a4359, 0.15); for (let x = 0; x <= gameWidth; x += this.tileSize) { gridGraphics.lineBetween(x, 0, x, gameHeight); } for (let y = 0; y <= gameHeight; y += this.tileSize) { gridGraphics.lineBetween(0, y, gameWidth, y); } this.addZoneDecorations(gameWidth, gameHeight); } addZoneDecorations(gameWidth, gameHeight) { const decoGraphics = this.add.graphics(); const riverEnd = gameWidth * WORLD_ZONES.river.end; decoGraphics.lineStyle(2, 0x5a8cc8, 0.3); for (let y = 20; y < gameHeight; y += 30) { const waveY = y + Math.sin(y * 0.1) * 5; decoGraphics.lineBetween(5, waveY, riverEnd - 5, waveY + 5); } const bushStart = gameWidth * WORLD_ZONES.bushes.start; const bushEnd = gameWidth * WORLD_ZONES.bushes.end; decoGraphics.fillStyle(0x6bab5e, 0.2); for (let i = 0; i < 15; i++) { const x = bushStart + Math.random() * (bushEnd - bushStart); const y = 30 + Math.random() * (gameHeight - 60); decoGraphics.fillCircle(x, y, 8 + Math.random() * 8); } const forestStart = gameWidth * WORLD_ZONES.forest.start; decoGraphics.fillStyle(0x2d5016, 0.25); for (let i = 0; i < 20; i++) { const x = forestStart + 20 + Math.random() * (gameWidth - forestStart - 40); const y = 30 + Math.random() * (gameHeight - 60); decoGraphics.fillRect(x - 2, y, 4, 12); decoGraphics.fillTriangle(x, y - 15, x - 10, y + 5, x + 10, y + 5); } const villageStart = gameWidth * WORLD_ZONES.village.start; const villageEnd = gameWidth * WORLD_ZONES.village.end; decoGraphics.fillStyle(0x8b7355, 0.15); decoGraphics.lineStyle(1, 0x8b7355, 0.3); for (let i = 0; i < 5; i++) { const x = villageStart + 30 + (i * ((villageEnd - villageStart - 60) / 4)); const y = gameHeight / 2 + (Math.random() - 0.5) * 100; decoGraphics.fillRect(x - 10, y - 5, 20, 15); decoGraphics.strokeRect(x - 10, y - 5, 20, 15); decoGraphics.fillTriangle(x, y - 15, x - 12, y - 5, x + 12, y - 5); } } fitWorldToView(gameWidth, gameHeight) { const cam = this.cameras.main; const padding = 40; const scaleX = (cam.width - padding * 2) / gameWidth; const scaleY = (cam.height - padding * 2) / gameHeight; const scale = Math.min(scaleX, scaleY, DISPLAY.MAX_ZOOM); cam.setZoom(Math.max(scale, DISPLAY.MIN_ZOOM)); cam.centerOn(gameWidth / 2, gameHeight / 2); } setupCameraControls() { const cam = this.cameras.main; this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY) => { const zoom = cam.zoom - deltaY * 0.001; cam.setZoom(Phaser.Math.Clamp(zoom, DISPLAY.MIN_ZOOM, DISPLAY.MAX_ZOOM)); }); this.input.on('pointermove', (pointer) => { if (pointer.isDown && pointer.button === 1) { cam.scrollX -= (pointer.x - pointer.prevPosition.x) / cam.zoom; cam.scrollY -= (pointer.y - pointer.prevPosition.y) / cam.zoom; } }); } setupUIControls() { const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache; const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats, speedDisplayStats } = this.domCache; // Create bound handlers for later cleanup this.boundHandlers.step = () => this.handleStep(); this.boundHandlers.auto = () => this.toggleAutoMode(); this.boundHandlers.init = () => this.handleInitialize(); // Speed handler that syncs both sliders this.boundHandlers.speed = (e) => { this.autoSpeed = parseInt(e.target.value); if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`; if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`; if (speedSliderStats) speedSliderStats.value = this.autoSpeed; if (this.isAutoMode) this.restartAutoMode(); }; this.boundHandlers.speedStats = (e) => { this.autoSpeed = parseInt(e.target.value); if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`; if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`; if (speedSlider) speedSlider.value = this.autoSpeed; if (this.isAutoMode) this.restartAutoMode(); }; this.boundHandlers.openStats = () => this.showStatsScreen(); this.boundHandlers.closeStats = () => this.hideStatsScreen(); // Main controls if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step); if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto); if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init); if (speedSlider) speedSlider.addEventListener('input', this.boundHandlers.speed); if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats); if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats); // Stats screen controls (same handlers) if (btnStepStats) btnStepStats.addEventListener('click', this.boundHandlers.step); if (btnAutoStats) btnAutoStats.addEventListener('click', this.boundHandlers.auto); if (btnInitializeStats) btnInitializeStats.addEventListener('click', this.boundHandlers.init); if (speedSliderStats) speedSliderStats.addEventListener('input', this.boundHandlers.speedStats); // Tab switching tabButtons?.forEach(btn => { btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab)); }); } async handleStep() { if (this.isAutoMode || this.isStepPending) return; this.isStepPending = true; try { await api.nextStep(); const state = await api.getState(); this.updateFromState(state); } catch (error) { console.error('Step failed:', error); this.updateConnectionStatus(false); } finally { this.isStepPending = false; } } async handleInitialize() { try { if (this.isAutoMode) this.toggleAutoMode(); await api.initialize(); const state = await api.getState(); // Clear all agents this.agents.forEach(sprite => sprite.destroy()); this.agents.clear(); this.selectedAgent = null; // Clear activity log const logEl = this.domCache.activityLog; if (logEl) logEl.innerHTML = ''; // Reset statistics history this.statsHistory = { turns: [], population: [], deaths: [], money: [], avgWealth: [], giniCoefficient: [], professions: {}, resourcePrices: {}, tradeVolume: [], resourcesProduced: {}, resourcesConsumed: {}, resourcesSpoiled: {}, resourcesProducedCumulative: {}, resourcesConsumedCumulative: {}, resourcesSpoiledCumulative: {}, resourcesTraded: {}, }; this.currentState = null; // Destroy existing charts Object.values(this.charts).forEach(chart => chart?.destroy()); this.charts = {}; this.updateFromState(state); this.updateAgentDetails(null); } catch (error) { console.error('Initialize failed:', error); this.updateConnectionStatus(false); } } toggleAutoMode() { this.isAutoMode = !this.isAutoMode; const { btnAuto, btnStep, btnAutoStats, btnStepStats } = this.domCache; if (this.isAutoMode) { btnAuto?.classList.add('active'); btnAutoStats?.classList.add('active'); btnStep?.setAttribute('disabled', 'true'); btnStepStats?.setAttribute('disabled', 'true'); this.startAutoMode(); } else { btnAuto?.classList.remove('active'); btnAutoStats?.classList.remove('active'); btnStep?.removeAttribute('disabled'); btnStepStats?.removeAttribute('disabled'); this.stopAutoMode(); } } startAutoMode() { this.stopAutoMode(); this.autoInterval = setInterval(async () => { if (this.isStepPending) return; this.isStepPending = true; try { await api.nextStep(); const state = await api.getState(); this.updateFromState(state); } catch (error) { console.error('Auto step failed:', error); this.toggleAutoMode(); } finally { this.isStepPending = false; } }, this.autoSpeed); } stopAutoMode() { if (this.autoInterval) { clearInterval(this.autoInterval); this.autoInterval = null; } } restartAutoMode() { if (this.isAutoMode) this.startAutoMode(); } startStatePolling() { if (this.pollingInterval) { clearInterval(this.pollingInterval); } this.pollingInterval = setInterval(async () => { if (!this.isAutoMode && !this.isStepPending) { try { await api.getStatus(); this.updateConnectionStatus(true); } catch { this.updateConnectionStatus(false); } } }, 5000); } updateFromState(state) { if (!state) return; this.currentState = state; this.registry.set('simulationState', state); this.updateHeaderDisplay(state); this.updateAgents(state.agents || []); this.updateLeftPanel(state); this.updateRightPanel(state); this.updateActivityLog(state.recent_logs || []); this.recordStatsHistory(state); // Update selected agent if still exists if (this.selectedAgent) { const agentData = (state.agents || []).find(a => a.id === this.selectedAgent); if (agentData) { this.updateAgentDetails(agentData); } } // Update stats screen if visible if (this.statsViewActive) { this.updateStatsScreen(); } } updateHeaderDisplay(state) { const { dayDisplay, timeDisplay, turnDisplay } = this.domCache; if (dayDisplay) dayDisplay.textContent = `Day ${state.day || 1}`; if (timeDisplay) { timeDisplay.textContent = state.time_of_day === 'night' ? '๐ŸŒ™ Night' : 'โ˜€๏ธ Day'; } if (turnDisplay) turnDisplay.textContent = `Turn ${state.turn || 0}`; } updateAgents(agentsData) { const seenIds = new Set(); agentsData.forEach(agentData => { seenIds.add(agentData.id); let agentSprite = this.agents.get(agentData.id); if (!agentSprite) { agentSprite = this.createAgentSprite(agentData); this.agents.set(agentData.id, agentSprite); } this.updateAgentSprite(agentSprite, agentData); }); // Remove agents that no longer exist this.agents.forEach((sprite, id) => { if (!seenIds.has(id)) { sprite.destroy(); this.agents.delete(id); } }); } createAgentSprite(agentData) { const container = this.add.container(0, 0); const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager; const body = this.add.circle(0, 0, DISPLAY.AGENT_SIZE / 2, profData.color); body.setStrokeStyle(2, 0xe8e4dc); container.add(body); const icon = this.add.text(0, 0, profData.icon, { fontSize: '14px' }).setOrigin(0.5); container.add(icon); const nameLabel = this.add.text(0, -DISPLAY.AGENT_SIZE / 2 - 8, agentData.name, { fontSize: '10px', fontFamily: 'JetBrains Mono, monospace', color: '#e8e4dc', backgroundColor: '#151921', padding: { x: 3, y: 1 }, }).setOrigin(0.5); container.add(nameLabel); const healthBg = this.add.rectangle(0, DISPLAY.AGENT_SIZE / 2 + 6, 24, 4, 0x151921); container.add(healthBg); const healthBar = this.add.rectangle(-12, DISPLAY.AGENT_SIZE / 2 + 6, 24, 4, 0x4a9c6d); healthBar.setOrigin(0, 0.5); container.add(healthBar); container.setData('body', body); container.setData('icon', icon); container.setData('nameLabel', nameLabel); container.setData('healthBar', healthBar); container.setData('agentId', agentData.id); body.setInteractive({ useHandCursor: true }); body.on('pointerdown', () => this.selectAgent(agentData.id)); body.on('pointerover', () => { body.setStrokeStyle(3, 0xd4a84b); container.setScale(1.1); }); body.on('pointerout', () => { const isSelected = this.selectedAgent === agentData.id; body.setStrokeStyle(isSelected ? 3 : 2, isSelected ? 0xd4a84b : 0xe8e4dc); container.setScale(1); }); this.agentContainer.add(container); return container; } updateAgentSprite(sprite, agentData) { // Direct position update - NO TWEENS to prevent memory accumulation const targetX = agentData.position.x * this.tileSize; const targetY = agentData.position.y * this.tileSize; sprite.x = targetX; sprite.y = targetY; const body = sprite.getData('body'); const icon = sprite.getData('icon'); const healthBar = sprite.getData('healthBar'); if (!agentData.is_alive) { body.setFillStyle(0x4a4a4a); body.setStrokeStyle(2, 0x6b6560); icon.setText('๐Ÿ’€'); healthBar.setFillStyle(0x4a4a4a); healthBar.setScale(0, 1); } else { const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager; body.setFillStyle(profData.color); icon.setText(profData.icon); const stats = agentData.stats; const minStat = Math.min( stats.hunger / stats.max_hunger, stats.thirst / stats.max_thirst, stats.heat / stats.max_heat ); healthBar.setScale(minStat, 1); if (minStat < 0.25) { healthBar.setFillStyle(0xc45c5c); } else if (minStat < 0.5) { healthBar.setFillStyle(0xd4a84b); } else { healthBar.setFillStyle(0x4a9c6d); } } sprite.setData('agentData', agentData); } selectAgent(agentId) { if (this.selectedAgent) { const prevSprite = this.agents.get(this.selectedAgent); if (prevSprite) { const prevBody = prevSprite.getData('body'); prevBody.setStrokeStyle(2, 0xe8e4dc); } } this.selectedAgent = agentId; const sprite = this.agents.get(agentId); if (sprite) { const body = sprite.getData('body'); body.setStrokeStyle(3, 0xd4a84b); this.updateAgentDetails(sprite.getData('agentData')); } } updateLeftPanel(state) { const { statAlive, statDead, statMoney, professionList } = this.domCache; const stats = state.statistics || {}; if (statAlive) statAlive.textContent = stats.living_agents || 0; if (statDead) statDead.textContent = stats.total_agents_died || 0; if (statMoney) statMoney.textContent = `${stats.total_money_in_circulation || 0}g`; if (professionList) { const professions = stats.professions || {}; professionList.innerHTML = Object.entries(professions) .filter(([_, count]) => count > 0) .map(([prof, count]) => { const profData = PROFESSIONS[prof] || PROFESSIONS.villager; return `
${profData.icon} ${profData.name} ${count}
`; }).join(''); } } updateRightPanel(state) { const { marketPrices } = this.domCache; if (marketPrices && state.market?.prices) { const prices = state.market.prices; marketPrices.innerHTML = Object.entries(prices) .map(([resource, priceData]) => { const resData = RESOURCES[resource] || { icon: '๐Ÿ“ฆ', name: resource }; const price = priceData.lowest_price !== null ? `${priceData.lowest_price}g` : '-'; const available = priceData.total_available || 0; return `
${resData.icon} ${resData.name} ${price} (${available})
`; }).join(''); } } updateAgentDetails(agentData) { const { agentDetails } = this.domCache; if (!agentDetails) return; if (!agentData) { agentDetails.innerHTML = '

Click an agent to view details

'; return; } const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager; const stats = agentData.stats; const action = agentData.current_action; const actionData = ACTIONS[action.action_type] || ACTIONS.idle; const renderBar = (label, current, max, type) => { const pct = Math.round((current / max) * 100); return `
${label}${current}/${max}
`; }; // Render skills if available const renderSkills = () => { if (!agentData.skills) return ''; const skills = agentData.skills; const skillNames = ['hunting', 'gathering', 'woodcutting', 'crafting', 'trading']; const skillEmojis = { hunting: '๐Ÿน', gathering: '๐ŸŒฟ', woodcutting: '๐Ÿช“', crafting: '๐Ÿงต', trading: '๐Ÿ’ฐ' }; return `
${skillNames.map(s => { const val = skills[s] || 1.0; if (val <= 1.0) return ''; // Skip base skills return ` ${skillEmojis[s]} ${val.toFixed(2)} `; }).filter(Boolean).join('') || 'No skills yet'}
`; }; // Render personal action log const renderActionLog = () => { const history = agentData.action_history || []; if (history.length === 0) { return '
No actions yet
'; } // Show most recent first return history.slice().reverse().slice(0, 12).map(entry => { const actionIcon = ACTIONS[entry.action]?.icon || 'โ“'; return `
T${entry.turn} ${actionIcon} ${entry.result}
`; }).join(''); }; agentDetails.innerHTML = `
${profData.icon}

${agentData.name}

${profData.name}
๐Ÿ’ฐ ${agentData.money}g Gold
${renderBar('Energy', stats.energy, stats.max_energy, 'energy')} ${renderBar('Hunger', stats.hunger, stats.max_hunger, 'hunger')} ${renderBar('Thirst', stats.thirst, stats.max_thirst, 'thirst')} ${renderBar('Heat', stats.heat, stats.max_heat, 'heat')}
Inventory
${agentData.inventory.length === 0 ? 'Empty' : agentData.inventory.map(item => { const resData = RESOURCES[item.type] || { icon: '๐Ÿ“ฆ' }; return `${resData.icon} ${item.quantity}`; }).join('')}
Skills
${renderSkills()}
Current Action
${actionData.icon} ${action.message || actionData.verb}
๐Ÿง  GOAP Plan
Click โ†ป to load GOAP info
Personal Log
${renderActionLog()}
`; } updateActivityLog(logs) { const logEl = this.domCache.activityLog; if (!logEl || !logs.length) return; const recentLog = logs[logs.length - 1]; if (!recentLog) return; // Build new entries const fragment = document.createDocumentFragment(); // Add deaths first (recentLog.deaths || []).slice(0, 3).forEach(agentId => { const div = document.createElement('div'); div.className = 'log-entry action-death'; div.innerHTML = `${agentId} died`; fragment.appendChild(div); }); // Add recent actions (limit to 5) (recentLog.agent_actions || []).slice(-5).forEach(action => { const actionType = action.decision?.action || 'idle'; const actionData = ACTIONS[actionType] || ACTIONS.idle; const div = document.createElement('div'); div.className = `log-entry action-${actionType}`; div.innerHTML = `${action.agent_name} ${actionData.icon} ${actionData.verb}`; fragment.appendChild(div); }); // Insert at the beginning if (logEl.firstChild) { logEl.insertBefore(fragment, logEl.firstChild); } else { logEl.appendChild(fragment); } // Remove excess entries - keep max 12 while (logEl.children.length > 12) { logEl.removeChild(logEl.lastChild); } } updateConnectionStatus(connected) { const statusEl = this.domCache.connectionStatus; if (!statusEl) return; const dot = statusEl.querySelector('.status-dot'); const text = statusEl.querySelector('.status-text'); if (connected) { dot?.classList.remove('disconnected'); dot?.classList.add('connected'); if (text) text.textContent = 'Connected'; } else { dot?.classList.remove('connected'); dot?.classList.add('disconnected'); if (text) text.textContent = 'Disconnected'; } } // ============== Stats History & Charts ============== recordStatsHistory(state) { const stats = state.statistics || {}; const turn = state.turn || 0; // Avoid duplicate entries if (this.statsHistory.turns.includes(turn)) return; this.statsHistory.turns.push(turn); this.statsHistory.population.push(stats.living_agents || 0); this.statsHistory.deaths.push(stats.total_agents_died || 0); this.statsHistory.money.push(stats.total_money_in_circulation || 0); this.statsHistory.avgWealth.push(stats.avg_money || 0); this.statsHistory.giniCoefficient.push(stats.gini_coefficient || 0); this.statsHistory.tradeVolume.push(state.market?.orders?.length || 0); // Track professions const professions = stats.professions || {}; for (const [prof, count] of Object.entries(professions)) { if (!this.statsHistory.professions[prof]) { this.statsHistory.professions[prof] = []; } // Pad with zeros if needed while (this.statsHistory.professions[prof].length < this.statsHistory.turns.length - 1) { this.statsHistory.professions[prof].push(0); } this.statsHistory.professions[prof].push(count); } // Track resource prices if (state.market?.prices) { for (const [resource, priceData] of Object.entries(state.market.prices)) { if (!this.statsHistory.resourcePrices[resource]) { this.statsHistory.resourcePrices[resource] = []; } while (this.statsHistory.resourcePrices[resource].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcePrices[resource].push(null); } this.statsHistory.resourcePrices[resource].push(priceData.lowest_price); } } // Track resource production/consumption/spoilage from recent logs and cumulative stats const recentLog = state.recent_logs?.[state.recent_logs?.length - 1]; const resourceStats = state.resource_stats || {}; const resourceTypes = ['meat', 'berries', 'water', 'wood', 'hide', 'clothes']; for (const resType of resourceTypes) { // Per-turn produced if (!this.statsHistory.resourcesProduced[resType]) { this.statsHistory.resourcesProduced[resType] = []; } while (this.statsHistory.resourcesProduced[resType].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcesProduced[resType].push(0); } this.statsHistory.resourcesProduced[resType].push(recentLog?.resources_produced?.[resType] || 0); // Per-turn consumed if (!this.statsHistory.resourcesConsumed[resType]) { this.statsHistory.resourcesConsumed[resType] = []; } while (this.statsHistory.resourcesConsumed[resType].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcesConsumed[resType].push(0); } this.statsHistory.resourcesConsumed[resType].push(recentLog?.resources_consumed?.[resType] || 0); // Per-turn spoiled if (!this.statsHistory.resourcesSpoiled[resType]) { this.statsHistory.resourcesSpoiled[resType] = []; } while (this.statsHistory.resourcesSpoiled[resType].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcesSpoiled[resType].push(0); } this.statsHistory.resourcesSpoiled[resType].push(recentLog?.resources_spoiled?.[resType] || 0); // Cumulative produced (from backend totals) if (!this.statsHistory.resourcesProducedCumulative[resType]) { this.statsHistory.resourcesProducedCumulative[resType] = []; } while (this.statsHistory.resourcesProducedCumulative[resType].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcesProducedCumulative[resType].push(0); } this.statsHistory.resourcesProducedCumulative[resType].push(resourceStats.produced?.[resType] || 0); // Cumulative consumed if (!this.statsHistory.resourcesConsumedCumulative[resType]) { this.statsHistory.resourcesConsumedCumulative[resType] = []; } while (this.statsHistory.resourcesConsumedCumulative[resType].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcesConsumedCumulative[resType].push(0); } this.statsHistory.resourcesConsumedCumulative[resType].push(resourceStats.consumed?.[resType] || 0); // Cumulative spoiled if (!this.statsHistory.resourcesSpoiledCumulative[resType]) { this.statsHistory.resourcesSpoiledCumulative[resType] = []; } while (this.statsHistory.resourcesSpoiledCumulative[resType].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcesSpoiledCumulative[resType].push(0); } this.statsHistory.resourcesSpoiledCumulative[resType].push(resourceStats.spoiled?.[resType] || 0); // Cumulative traded (from backend) if (!this.statsHistory.resourcesTraded[resType]) { this.statsHistory.resourcesTraded[resType] = []; } while (this.statsHistory.resourcesTraded[resType].length < this.statsHistory.turns.length - 1) { this.statsHistory.resourcesTraded[resType].push(0); } this.statsHistory.resourcesTraded[resType].push(resourceStats.traded?.[resType] || 0); } // Trim history if too long if (this.statsHistory.turns.length > this.maxHistoryPoints) { this.statsHistory.turns.shift(); this.statsHistory.population.shift(); this.statsHistory.deaths.shift(); this.statsHistory.money.shift(); this.statsHistory.avgWealth.shift(); this.statsHistory.giniCoefficient.shift(); this.statsHistory.tradeVolume.shift(); for (const prof in this.statsHistory.professions) { this.statsHistory.professions[prof].shift(); } for (const res in this.statsHistory.resourcePrices) { this.statsHistory.resourcePrices[res].shift(); } for (const res in this.statsHistory.resourcesProduced) { this.statsHistory.resourcesProduced[res].shift(); } for (const res in this.statsHistory.resourcesConsumed) { this.statsHistory.resourcesConsumed[res].shift(); } for (const res in this.statsHistory.resourcesSpoiled) { this.statsHistory.resourcesSpoiled[res].shift(); } for (const res in this.statsHistory.resourcesProducedCumulative) { this.statsHistory.resourcesProducedCumulative[res].shift(); } for (const res in this.statsHistory.resourcesConsumedCumulative) { this.statsHistory.resourcesConsumedCumulative[res].shift(); } for (const res in this.statsHistory.resourcesSpoiledCumulative) { this.statsHistory.resourcesSpoiledCumulative[res].shift(); } for (const res in this.statsHistory.resourcesTraded) { this.statsHistory.resourcesTraded[res].shift(); } } } showStatsScreen() { const { statsScreen } = this.domCache; if (statsScreen) { statsScreen.classList.remove('hidden'); this.statsViewActive = true; this.updateStatsScreen(); } } hideStatsScreen() { const { statsScreen } = this.domCache; if (statsScreen) { statsScreen.classList.add('hidden'); this.statsViewActive = false; } } switchTab(tabName) { const { tabButtons, tabPanels } = this.domCache; tabButtons?.forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabName); }); tabPanels?.forEach(panel => { panel.classList.toggle('active', panel.id === `tab-${tabName}`); }); // Update charts for the active tab this.updateActiveTabCharts(tabName); } updateStatsScreen() { this.updateStatsSummary(); // Find active tab const activeTab = document.querySelector('.tab-btn.active'); if (activeTab) { this.updateActiveTabCharts(activeTab.dataset.tab); } } updateStatsSummary() { const { statsTurn, statsLiving, statsDeaths, statsGold, statsAvgWealth, statsGini } = this.domCache; const state = this.currentState; if (!state) return; const stats = state.statistics || {}; if (statsTurn) statsTurn.textContent = state.turn || 0; if (statsLiving) statsLiving.textContent = stats.living_agents || 0; if (statsDeaths) statsDeaths.textContent = stats.total_agents_died || 0; if (statsGold) statsGold.textContent = `${stats.total_money_in_circulation || 0}g`; if (statsAvgWealth) statsAvgWealth.textContent = `${Math.round(stats.avg_money || 0)}g`; if (statsGini) statsGini.textContent = (stats.gini_coefficient || 0).toFixed(2); } updateActiveTabCharts(tabName) { switch (tabName) { case 'prices': this.renderPricesChart(); break; case 'wealth': this.renderWealthCharts(); break; case 'population': this.renderPopulationChart(); break; case 'professions': this.renderProfessionCharts(); break; case 'resources': this.renderResourceCharts(); break; case 'market': this.renderMarketCharts(); break; case 'agents': this.renderAgentStatsCharts(); break; case 'goap': this.fetchAndRenderGOAP(); break; } } renderPricesChart() { const canvas = document.getElementById('chart-prices'); if (!canvas) return; if (this.charts.prices) this.charts.prices.destroy(); const resColors = { meat: '#c45c5c', berries: '#a855a8', water: '#5a8cc8', wood: '#a67c52', hide: '#8b7355', clothes: '#6b6560', }; const datasets = []; for (const [resource, data] of Object.entries(this.statsHistory.resourcePrices)) { if (data.some(v => v !== null)) { datasets.push({ label: resource.charAt(0).toUpperCase() + resource.slice(1), data: data, borderColor: resColors[resource] || '#888', backgroundColor: 'transparent', tension: 0.3, spanGaps: true, borderWidth: 2, }); } } this.charts.prices = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Market Prices Over Time'), }); } renderWealthCharts() { // Wealth distribution bar chart (per agent) const distCanvas = document.getElementById('chart-wealth-dist'); if (distCanvas && this.currentState) { if (this.charts.wealthDist) this.charts.wealthDist.destroy(); const agents = (this.currentState.agents || []) .filter(a => a.is_alive) .sort((a, b) => b.money - a.money); const labels = agents.map(a => a.name.substring(0, 8)); const data = agents.map(a => a.money); const colors = agents.map((_, i) => { const ratio = i / Math.max(1, agents.length - 1); return `hsl(${180 - ratio * 180}, 70%, 55%)`; }); this.charts.wealthDist = new Chart(distCanvas.getContext('2d'), { type: 'bar', data: { labels, datasets: [{ label: 'Gold', data, backgroundColor: colors, borderWidth: 0 }], }, options: { responsive: true, maintainAspectRatio: false, animation: false, indexAxis: 'y', plugins: { legend: { display: false }, title: { display: true, text: 'Wealth by Agent', color: '#e8e4dc', font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, }, tooltip: { callbacks: { label: (ctx) => `${ctx.raw}g`, }, }, }, scales: { x: { beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, y: { ticks: { color: '#6b6560', font: { size: 9 } }, grid: { display: false } }, }, interaction: { intersect: true, mode: 'nearest' }, }, }); } // Wealth by profession chart const profCanvas = document.getElementById('chart-wealth-prof'); if (profCanvas && this.currentState) { if (this.charts.wealthProf) this.charts.wealthProf.destroy(); const agents = (this.currentState.agents || []).filter(a => a.is_alive); const profWealth = {}; const profCount = {}; agents.forEach(a => { const prof = a.profession || 'villager'; profWealth[prof] = (profWealth[prof] || 0) + a.money; profCount[prof] = (profCount[prof] || 0) + 1; }); const profColors = { hunter: '#c45c5c', gatherer: '#6bab5e', woodcutter: '#a67c52', trader: '#d4a84b', crafter: '#8b6fc0', villager: '#7a8899', }; const labels = Object.keys(profWealth); const totalData = labels.map(p => profWealth[p]); const avgData = labels.map(p => Math.round(profWealth[p] / profCount[p])); const colors = labels.map(p => profColors[p] || '#888'); this.charts.wealthProf = new Chart(profCanvas.getContext('2d'), { type: 'bar', data: { labels: labels.map(l => l.charAt(0).toUpperCase() + l.slice(1)), datasets: [ { label: 'Total Gold', data: totalData, backgroundColor: colors, borderWidth: 0 }, { label: 'Avg per Agent', data: avgData, backgroundColor: colors.map(c => c + '80'), borderWidth: 0 }, ], }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { position: 'bottom', labels: { color: '#a8a095', boxWidth: 12 } }, title: { display: true, text: 'Wealth by Profession', color: '#e8e4dc', font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, }, }, scales: { x: { ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, y: { beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, }, }, }); } // Wealth over time chart const timeCanvas = document.getElementById('chart-wealth-time'); if (timeCanvas) { if (this.charts.wealthTime) this.charts.wealthTime.destroy(); this.charts.wealthTime = new Chart(timeCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets: [ { label: 'Total Gold', data: this.statsHistory.money, borderColor: '#00d4ff', backgroundColor: 'rgba(0, 212, 255, 0.1)', fill: true, yAxisID: 'y', }, { label: 'Avg Wealth', data: this.statsHistory.avgWealth, borderColor: '#39ff14', borderDash: [5, 5], yAxisID: 'y1', }, { label: 'Gini Index', data: this.statsHistory.giniCoefficient, borderColor: '#ff0099', yAxisID: 'y2', }, ], }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { position: 'bottom', labels: { color: '#a8a095', boxWidth: 12, padding: 10 } }, title: { display: true, text: 'Wealth Over Time', color: '#e8e4dc', font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, }, }, scales: { x: { ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, y: { type: 'linear', position: 'left', beginAtZero: true, ticks: { color: '#00d4ff' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, y1: { type: 'linear', position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, ticks: { color: '#39ff14' } }, y2: { type: 'linear', position: 'right', min: 0, max: 1, grid: { drawOnChartArea: false }, ticks: { color: '#ff0099' } }, }, interaction: { intersect: false, mode: 'index' }, }, }); } } renderPopulationChart() { const canvas = document.getElementById('chart-population'); if (!canvas) return; if (this.charts.population) this.charts.population.destroy(); this.charts.population = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets: [ { label: 'Living', data: this.statsHistory.population, borderColor: '#00d4ff', backgroundColor: 'rgba(0, 212, 255, 0.15)', fill: true, tension: 0.3, }, { label: 'Deaths (Cumulative)', data: this.statsHistory.deaths, borderColor: '#ff0099', borderDash: [5, 5], tension: 0.3, }, ], }, options: this.getChartOptions('Population Over Time'), }); } renderProfessionCharts() { // Pie chart const pieCanvas = document.getElementById('chart-prof-pie'); if (pieCanvas && this.currentState) { if (this.charts.profPie) this.charts.profPie.destroy(); const professions = this.currentState.statistics?.professions || {}; const labels = Object.keys(professions); const data = Object.values(professions); const colors = ['#00d4ff', '#ff0099', '#39ff14', '#ff6600', '#9d4edd', '#ffcc00']; this.charts.profPie = new Chart(pieCanvas.getContext('2d'), { type: 'doughnut', data: { labels, datasets: [{ data, backgroundColor: colors.slice(0, labels.length), borderWidth: 0 }], }, options: { ...this.getChartOptions('Current Distribution'), animation: false, cutout: '50%', }, }); } // Stacked area chart over time const timeCanvas = document.getElementById('chart-prof-time'); if (timeCanvas) { if (this.charts.profTime) this.charts.profTime.destroy(); const colors = { hunter: '#c45c5c', gatherer: '#6bab5e', woodcutter: '#a67c52', trader: '#d4a84b', crafter: '#8b6fc0', villager: '#7a8899' }; const datasets = []; for (const [prof, data] of Object.entries(this.statsHistory.professions)) { datasets.push({ label: prof.charAt(0).toUpperCase() + prof.slice(1), data, backgroundColor: colors[prof] || '#888', fill: true, tension: 0.3, }); } this.charts.profTime = new Chart(timeCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: { ...this.getChartOptions('Professions Over Time'), animation: false, scales: { ...this.getChartOptions('').scales, y: { stacked: true, beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } } }, plugins: { ...this.getChartOptions('').plugins, filler: { propagate: true } }, }, }); } } renderResourceCharts() { const resColors = { meat: '#c45c5c', berries: '#a855a8', water: '#5a8cc8', wood: '#a67c52', hide: '#8b7355', clothes: '#6b6560', }; const resourceTypes = ['meat', 'berries', 'water', 'wood', 'hide', 'clothes']; // Resources Produced chart const producedCanvas = document.getElementById('chart-res-produced'); if (producedCanvas) { if (this.charts.resProduced) this.charts.resProduced.destroy(); const datasets = resourceTypes.map(res => ({ label: res.charAt(0).toUpperCase() + res.slice(1), data: this.statsHistory.resourcesProduced[res] || [], borderColor: resColors[res], backgroundColor: 'transparent', tension: 0.3, })).filter(ds => ds.data.some(v => v > 0)); this.charts.resProduced = new Chart(producedCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Resources Produced (per turn)'), }); } // Resources Consumed chart const consumedCanvas = document.getElementById('chart-res-consumed'); if (consumedCanvas) { if (this.charts.resConsumed) this.charts.resConsumed.destroy(); const datasets = resourceTypes.map(res => ({ label: res.charAt(0).toUpperCase() + res.slice(1), data: this.statsHistory.resourcesConsumed[res] || [], borderColor: resColors[res], backgroundColor: 'transparent', tension: 0.3, })).filter(ds => ds.data.some(v => v > 0)); this.charts.resConsumed = new Chart(consumedCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Resources Consumed (per turn)'), }); } // Resources Spoiled chart const spoiledCanvas = document.getElementById('chart-res-spoiled'); if (spoiledCanvas) { if (this.charts.resSpoiled) this.charts.resSpoiled.destroy(); const datasets = resourceTypes.map(res => ({ label: res.charAt(0).toUpperCase() + res.slice(1), data: this.statsHistory.resourcesSpoiled[res] || [], borderColor: resColors[res], backgroundColor: 'transparent', tension: 0.3, })).filter(ds => ds.data.some(v => v > 0)); this.charts.resSpoiled = new Chart(spoiledCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Resources Spoiled (per turn)'), }); } // Current Stock chart (bar chart showing inventory + market) const stockCanvas = document.getElementById('chart-res-stock'); if (stockCanvas && this.currentState) { if (this.charts.resStock) this.charts.resStock.destroy(); const resStats = this.currentState.resource_stats || {}; const inInventory = resStats.in_inventory || {}; const inMarket = resStats.in_market || {}; const labels = resourceTypes.map(r => r.charAt(0).toUpperCase() + r.slice(1)); const invData = resourceTypes.map(r => inInventory[r] || 0); const marketData = resourceTypes.map(r => inMarket[r] || 0); const colors = resourceTypes.map(r => resColors[r]); this.charts.resStock = new Chart(stockCanvas.getContext('2d'), { type: 'bar', data: { labels, datasets: [ { label: 'In Inventory', data: invData, backgroundColor: colors }, { label: 'In Market', data: marketData, backgroundColor: colors.map(c => c + '80') }, ], }, options: { ...this.getChartOptions('Current Resource Stock'), animation: false, scales: { x: { stacked: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, y: { stacked: true, beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, }, }, }); } // Cumulative Produced chart const cumProducedCanvas = document.getElementById('chart-res-cum-produced'); if (cumProducedCanvas) { if (this.charts.resCumProduced) this.charts.resCumProduced.destroy(); const datasets = resourceTypes.map(res => ({ label: res.charAt(0).toUpperCase() + res.slice(1), data: this.statsHistory.resourcesProducedCumulative[res] || [], borderColor: resColors[res], backgroundColor: 'transparent', tension: 0.3, })).filter(ds => ds.data.some(v => v > 0)); this.charts.resCumProduced = new Chart(cumProducedCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Cumulative Produced (total)'), }); } // Cumulative Consumed chart const cumConsumedCanvas = document.getElementById('chart-res-cum-consumed'); if (cumConsumedCanvas) { if (this.charts.resCumConsumed) this.charts.resCumConsumed.destroy(); const datasets = resourceTypes.map(res => ({ label: res.charAt(0).toUpperCase() + res.slice(1), data: this.statsHistory.resourcesConsumedCumulative[res] || [], borderColor: resColors[res], backgroundColor: 'transparent', tension: 0.3, })).filter(ds => ds.data.some(v => v > 0)); this.charts.resCumConsumed = new Chart(cumConsumedCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Cumulative Consumed (total)'), }); } // Cumulative Spoiled chart const cumSpoiledCanvas = document.getElementById('chart-res-cum-spoiled'); if (cumSpoiledCanvas) { if (this.charts.resCumSpoiled) this.charts.resCumSpoiled.destroy(); const datasets = resourceTypes.map(res => ({ label: res.charAt(0).toUpperCase() + res.slice(1), data: this.statsHistory.resourcesSpoiledCumulative[res] || [], borderColor: resColors[res], backgroundColor: 'transparent', tension: 0.3, })).filter(ds => ds.data.some(v => v > 0)); this.charts.resCumSpoiled = new Chart(cumSpoiledCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Cumulative Spoiled (total)'), }); } // Cumulative Traded chart const cumTradedCanvas = document.getElementById('chart-res-cum-traded'); if (cumTradedCanvas) { if (this.charts.resCumTraded) this.charts.resCumTraded.destroy(); const datasets = resourceTypes.map(res => ({ label: res.charAt(0).toUpperCase() + res.slice(1), data: this.statsHistory.resourcesTraded[res] || [], borderColor: resColors[res], backgroundColor: 'transparent', tension: 0.3, })).filter(ds => ds.data.some(v => v > 0)); this.charts.resCumTraded = new Chart(cumTradedCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets }, options: this.getChartOptions('Cumulative Traded (total)'), }); } } renderMarketCharts() { // Market supply bar chart const supplyCanvas = document.getElementById('chart-market-supply'); if (supplyCanvas && this.currentState) { if (this.charts.marketSupply) this.charts.marketSupply.destroy(); const prices = this.currentState.market?.prices || {}; const labels = Object.keys(prices); const data = labels.map(r => prices[r].total_available || 0); const colors = ['#c45c5c', '#a855a8', '#5a8cc8', '#a67c52', '#8b7355', '#6b6560']; this.charts.marketSupply = new Chart(supplyCanvas.getContext('2d'), { type: 'bar', data: { labels: labels.map(l => l.charAt(0).toUpperCase() + l.slice(1)), datasets: [{ label: 'Available', data, backgroundColor: colors.slice(0, labels.length) }], }, options: this.getChartOptions('Market Supply'), }); } // Trade activity over time const activityCanvas = document.getElementById('chart-market-activity'); if (activityCanvas) { if (this.charts.marketActivity) this.charts.marketActivity.destroy(); this.charts.marketActivity = new Chart(activityCanvas.getContext('2d'), { type: 'line', data: { labels: this.statsHistory.turns, datasets: [{ label: 'Active Orders', data: this.statsHistory.tradeVolume, borderColor: '#00ffa3', backgroundColor: 'rgba(0, 255, 163, 0.1)', fill: true, tension: 0.3, }], }, options: this.getChartOptions('Market Activity'), }); } } renderAgentStatsCharts() { if (!this.currentState) return; const agents = (this.currentState.agents || []).filter(a => a.is_alive); const statTypes = [ { id: 'energy', label: 'Energy', max: 'max_energy', color: '#39ff14' }, { id: 'hunger', label: 'Hunger', max: 'max_hunger', color: '#ff6600' }, { id: 'thirst', label: 'Thirst', max: 'max_thirst', color: '#00d4ff' }, { id: 'heat', label: 'Heat', max: 'max_heat', color: '#ff0099' }, ]; statTypes.forEach(stat => { const canvas = document.getElementById(`chart-stat-${stat.id}`); if (!canvas) return; if (this.charts[`stat${stat.id}`]) this.charts[`stat${stat.id}`].destroy(); const values = agents.map(a => a.stats?.[stat.id] || 0); const maxVal = agents[0]?.stats?.[stat.max] || 100; // Create histogram bins const bins = 10; const binSize = maxVal / bins; const histogram = new Array(bins).fill(0); values.forEach(v => { const binIndex = Math.min(Math.floor(v / binSize), bins - 1); histogram[binIndex]++; }); const binLabels = histogram.map((_, i) => `${Math.round(i * binSize)}-${Math.round((i + 1) * binSize)}`); this.charts[`stat${stat.id}`] = new Chart(canvas.getContext('2d'), { type: 'bar', data: { labels: binLabels, datasets: [{ label: 'Agents', data: histogram, backgroundColor: stat.color, borderWidth: 0, }], }, options: { ...this.getChartOptions(`${stat.label} Distribution`), animation: false, plugins: { ...this.getChartOptions('').plugins, legend: { display: false } }, }, }); }); } getChartOptions(title) { return { responsive: true, maintainAspectRatio: false, animation: false, // Disable animations for smooth real-time updates plugins: { legend: { position: 'bottom', labels: { color: '#a8a095', font: { family: "'JetBrains Mono', monospace", size: 11 }, boxWidth: 12, padding: 15, }, }, title: { display: true, text: title, color: '#e8e4dc', font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, padding: { bottom: 15 }, }, }, scales: { x: { ticks: { color: '#6b6560', font: { family: "'JetBrains Mono', monospace", size: 10 } }, grid: { color: 'rgba(58, 67, 89, 0.3)' }, }, y: { beginAtZero: true, ticks: { color: '#6b6560', font: { family: "'JetBrains Mono', monospace", size: 10 } }, grid: { color: 'rgba(58, 67, 89, 0.3)' }, }, }, interaction: { intersect: false, mode: 'index', }, }; } // ================================= // GOAP Visualization Methods // ================================= async loadAgentGOAP(agentId) { const contentEl = document.getElementById('agent-goap-content'); if (!contentEl) return; contentEl.innerHTML = 'Loading...'; try { const data = await api.getAgentGOAPDebug(agentId); const plan = data.current_plan; if (plan && plan.actions.length > 0) { contentEl.innerHTML = `
Goal: ${plan.goal_name}
${plan.actions.map((a, i) => `${a}` ).join(' โ†’ ')}
Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
`; } else { contentEl.innerHTML = `
No plan (reactive mode)
${data.selected_action || 'No action'}
`; } } catch (error) { console.error('Failed to load GOAP info:', error); contentEl.innerHTML = 'Failed to load'; } } async fetchAndRenderGOAP() { try { const response = await api.get('/goap/debug'); this.goapData = response; this.renderGOAPAgentList(); // If we have a selected agent, render their details if (this.selectedGoapAgentId) { const agent = this.goapData.agents.find(a => a.agent_id === this.selectedGoapAgentId); if (agent) { this.renderGOAPAgentDetails(agent); } } } catch (error) { console.error('Failed to fetch GOAP data:', error); const { goapAgentList } = this.domCache; if (goapAgentList) { goapAgentList.innerHTML = '

Failed to load GOAP data. Make sure the server is running.

'; } } } renderGOAPAgentList() { const { goapAgentList } = this.domCache; if (!goapAgentList || !this.goapData) return; if (this.goapData.agents.length === 0) { goapAgentList.innerHTML = '

No agents found

'; return; } goapAgentList.innerHTML = this.goapData.agents.map(agent => `
${agent.agent_name}
${agent.selected_action || 'No action'}
${agent.current_plan ? '๐ŸŽฏ ' + agent.current_plan.goal_name : '(reactive)'}
`).join(''); // Add click handlers goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => { item.addEventListener('click', () => { this.selectGoapAgent(item.dataset.agentId); }); }); } selectGoapAgent(agentId) { this.selectedGoapAgentId = agentId; // Update selection styling const { goapAgentList } = this.domCache; if (goapAgentList) { goapAgentList.querySelectorAll('.goap-agent-item').forEach(item => { item.classList.toggle('selected', item.dataset.agentId === agentId); }); } // Render details if (this.goapData) { const agent = this.goapData.agents.find(a => a.agent_id === agentId); if (agent) { this.renderGOAPAgentDetails(agent); } } } renderGOAPAgentDetails(agent) { this.renderGOAPPlanView(agent); this.renderGOAPActionsList(agent); this.renderGOAPGoalsChart(agent); } getUrgencyClass(urgency) { if (urgency <= 0) return 'none'; if (urgency <= 1) return 'low'; return 'high'; } renderGOAPPlanView(agent) { const { goapPlanView } = this.domCache; if (!goapPlanView) return; const ws = agent.world_state; const plan = agent.current_plan; goapPlanView.innerHTML = `
Thirst
${Math.round(ws.vitals.thirst * 100)}%
Hunger
${Math.round(ws.vitals.hunger * 100)}%
Heat
${Math.round(ws.vitals.heat * 100)}%
Energy
${Math.round(ws.vitals.energy * 100)}%
Current Plan
${plan && plan.actions.length > 0 ? `
${plan.actions.map((action, i) => ` ${action} ${i < plan.actions.length - 1 ? 'โ†’' : ''} `).join('')} โ†’ โœ“ ${plan.goal_name}
Total Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
` : `
No plan - using reactive selection
Selected: ${agent.selected_action || 'None'}
`}
INVENTORY
๐Ÿ’ง${ws.inventory.water}
๐Ÿ–${ws.inventory.meat}
๐Ÿซ${ws.inventory.berries}
๐Ÿชต${ws.inventory.wood}
๐Ÿฅฉ${ws.inventory.hide}
๐Ÿ“ฆ${ws.inventory.space}
๐Ÿ’ฐ ${ws.economy.money}c Wealthy: ${ws.economy.is_wealthy ? 'โœ“' : 'โœ—'}
`; } renderGOAPActionsList(agent) { const { goapActionsList } = this.domCache; if (!goapActionsList) return; // Sort: plan actions first, then valid, then invalid const sortedActions = [...agent.actions].sort((a, b) => { if (a.is_in_plan && !b.is_in_plan) return -1; if (!a.is_in_plan && b.is_in_plan) return 1; if (a.is_in_plan && b.is_in_plan) return a.plan_order - b.plan_order; if (a.is_valid && !b.is_valid) return -1; if (!a.is_valid && b.is_valid) return 1; return (a.cost || 999) - (b.cost || 999); }); goapActionsList.innerHTML = sortedActions.map(action => `
${action.is_in_plan ? `${action.plan_order + 1}` : ''} ${action.name} ${action.cost >= 0 ? action.cost.toFixed(1) : 'โˆž'}
`).join(''); } renderGOAPGoalsChart(agent) { const { chartGoapGoals } = this.domCache; if (!chartGoapGoals) return; // Sort goals by priority and take top 10 const sortedGoals = [...agent.goals] .sort((a, b) => b.priority - a.priority) .slice(0, 10); // Destroy existing chart if (this.charts.goapGoals) { this.charts.goapGoals.destroy(); } this.charts.goapGoals = new Chart(chartGoapGoals, { type: 'bar', data: { labels: sortedGoals.map(g => g.name), datasets: [{ label: 'Priority', data: sortedGoals.map(g => g.priority), backgroundColor: sortedGoals.map(g => { if (g.is_selected) return 'rgba(139, 111, 192, 0.8)'; if (g.is_satisfied) return 'rgba(74, 156, 109, 0.5)'; if (g.priority > 0) return 'rgba(90, 140, 200, 0.7)'; return 'rgba(107, 101, 96, 0.3)'; }), borderColor: sortedGoals.map(g => { if (g.is_selected) return '#8b6fc0'; if (g.is_satisfied) return '#4a9c6d'; if (g.priority > 0) return '#5a8cc8'; return '#6b6560'; }), borderWidth: 2, }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false }, title: { display: true, text: 'Goal Priorities', color: '#e8e4dc', font: { family: "'Crimson Pro', serif", size: 14 }, }, }, scales: { x: { beginAtZero: true, grid: { color: 'rgba(58, 67, 89, 0.3)' }, ticks: { color: '#6b6560' }, }, y: { grid: { display: false }, ticks: { color: '#e8e4dc', font: { family: "'JetBrains Mono', monospace", size: 10 } }, } } } }); } update(time, delta) { // Minimal update loop - no heavy operations here } }