2026-01-19 21:03:30 +03:00

1984 lines
82 KiB
JavaScript

/**
* 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 `<div class="profession-item">
<span class="profession-name">
<span class="profession-icon">${profData.icon}</span>
${profData.name}
</span>
<span class="profession-count">${count}</span>
</div>`;
}).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 `<div class="market-price-item">
<span class="market-resource">${resData.icon} ${resData.name}</span>
<span><span class="market-price">${price}</span>
<span class="market-available">(${available})</span></span>
</div>`;
}).join('');
}
}
updateAgentDetails(agentData) {
const { agentDetails } = this.domCache;
if (!agentDetails) return;
if (!agentData) {
agentDetails.innerHTML = '<p class="no-selection">Click an agent to view details</p>';
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 `<div class="agent-stat-bar">
<div class="stat-header"><span>${label}</span><span>${current}/${max}</span></div>
<div class="stat-bar"><div class="stat-bar-fill ${type}" style="width: ${pct}%"></div></div>
</div>`;
};
// 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 `<div class="agent-skills">
${skillNames.map(s => {
const val = skills[s] || 1.0;
if (val <= 1.0) return ''; // Skip base skills
return `<span class="skill-badge">
<span class="skill-name">${skillEmojis[s]}</span>
<span class="skill-value">${val.toFixed(2)}</span>
</span>`;
}).filter(Boolean).join('') || '<span style="color: var(--text-muted); font-size: 0.7rem;">No skills yet</span>'}
</div>`;
};
// Render personal action log
const renderActionLog = () => {
const history = agentData.action_history || [];
if (history.length === 0) {
return '<div style="color: var(--text-muted); font-size: 0.7rem; padding: 4px;">No actions yet</div>';
}
// Show most recent first
return history.slice().reverse().slice(0, 12).map(entry => {
const actionIcon = ACTIONS[entry.action]?.icon || '❓';
return `<div class="agent-log-entry ${entry.success ? 'success' : 'failure'}">
<span class="agent-log-turn">T${entry.turn}</span>
<span class="agent-log-action">${actionIcon}</span>
<span class="agent-log-result">${entry.result}</span>
</div>`;
}).join('');
};
agentDetails.innerHTML = `<div class="agent-card">
<div class="agent-header">
<div class="agent-avatar">${profData.icon}</div>
<div class="agent-info">
<h4>${agentData.name}</h4>
<span class="profession" style="color: #${profData.color.toString(16)}">${profData.name}</span>
</div>
</div>
<div class="agent-money">
<span class="money-icon">💰</span>
<span class="money-value">${agentData.money}g</span>
<span class="money-label">Gold</span>
</div>
<div class="agent-stats">
${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')}
</div>
<div class="agent-inventory">
<h5>Inventory</h5>
<div class="inventory-grid">
${agentData.inventory.length === 0 ? '<span style="color: var(--text-muted); font-size: 0.75rem;">Empty</span>' :
agentData.inventory.map(item => {
const resData = RESOURCES[item.type] || { icon: '📦' };
return `<span class="inventory-item">${resData.icon} ${item.quantity}</span>`;
}).join('')}
</div>
</div>
<h5 class="subsection-title" style="margin-top: var(--space-sm); border-top: none; padding-top: 0;">Skills</h5>
${renderSkills()}
<div class="agent-action">
<span class="action-label">Current Action</span>
<div>${actionData.icon} ${action.message || actionData.verb}</div>
</div>
<div class="agent-goap-info" id="agent-goap-section" data-agent-id="${agentData.id}">
<h5 class="subsection-title" style="display: flex; align-items: center; gap: 6px;">
🧠 GOAP Plan
<button class="btn-mini" onclick="window.villsimGame.scene.scenes[1].loadAgentGOAP('${agentData.id}')" style="font-size: 0.6rem; padding: 2px 6px;"></button>
</h5>
<div id="agent-goap-content" style="font-size: 0.75rem; color: var(--text-muted);">
Click to load GOAP info
</div>
</div>
<h5 class="subsection-title">Personal Log</h5>
<div class="agent-log">
${renderActionLog()}
</div>
</div>`;
}
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 = `<span class="log-agent">${agentId}</span> <span class="log-action">died</span>`;
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 = `<span class="log-agent">${action.agent_name}</span> <span class="log-action">${actionData.icon} ${actionData.verb}</span>`;
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 = '<span style="color: var(--text-muted);">Loading...</span>';
try {
const data = await api.getAgentGOAPDebug(agentId);
const plan = data.current_plan;
if (plan && plan.actions.length > 0) {
contentEl.innerHTML = `
<div style="margin-bottom: 4px;">
<strong style="color: var(--accent-sapphire);">Goal:</strong> ${plan.goal_name}
</div>
<div style="font-family: var(--font-mono); font-size: 0.7rem;">
${plan.actions.map((a, i) =>
`<span style="${i === 0 ? 'color: var(--accent-emerald);' : ''}">${a}</span>`
).join(' → ')}
</div>
<div style="margin-top: 4px; color: var(--text-muted); font-size: 0.65rem;">
Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
`;
} else {
contentEl.innerHTML = `
<div style="color: var(--text-muted);">
No plan (reactive mode)<br>
<span style="color: var(--text-primary);">${data.selected_action || 'No action'}</span>
</div>
`;
}
} catch (error) {
console.error('Failed to load GOAP info:', error);
contentEl.innerHTML = '<span style="color: var(--accent-ruby);">Failed to load</span>';
}
}
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 = '<p class="loading-text">Failed to load GOAP data. Make sure the server is running.</p>';
}
}
}
renderGOAPAgentList() {
const { goapAgentList } = this.domCache;
if (!goapAgentList || !this.goapData) return;
if (this.goapData.agents.length === 0) {
goapAgentList.innerHTML = '<p class="loading-text">No agents found</p>';
return;
}
goapAgentList.innerHTML = this.goapData.agents.map(agent => `
<div class="goap-agent-item ${agent.agent_id === this.selectedGoapAgentId ? 'selected' : ''}"
data-agent-id="${agent.agent_id}">
<div class="agent-name">${agent.agent_name}</div>
<div class="agent-action">${agent.selected_action || 'No action'}</div>
<div class="agent-goal">${agent.current_plan ? '🎯 ' + agent.current_plan.goal_name : '(reactive)'}</div>
</div>
`).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 = `
<div class="goap-world-state">
<div class="goap-stat-card thirst">
<div class="label">Thirst</div>
<div class="value">${Math.round(ws.vitals.thirst * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.thirst)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.thirst * 100}%"></div></div>
</div>
<div class="goap-stat-card hunger">
<div class="label">Hunger</div>
<div class="value">${Math.round(ws.vitals.hunger * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.hunger)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.hunger * 100}%"></div></div>
</div>
<div class="goap-stat-card heat">
<div class="label">Heat</div>
<div class="value">${Math.round(ws.vitals.heat * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.heat)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.heat * 100}%"></div></div>
</div>
<div class="goap-stat-card energy">
<div class="label">Energy</div>
<div class="value">${Math.round(ws.vitals.energy * 100)}%<span class="goap-urgency ${this.getUrgencyClass(ws.urgencies.energy)}"></span></div>
<div class="bar"><div class="bar-fill" style="width: ${ws.vitals.energy * 100}%"></div></div>
</div>
</div>
<div class="goap-plan-steps">
<h5>Current Plan</h5>
${plan && plan.actions.length > 0 ? `
<div class="goap-plan-flow">
${plan.actions.map((action, i) => `
<span class="goap-step-node ${i === 0 ? 'current' : ''}">${action}</span>
${i < plan.actions.length - 1 ? '<span class="goap-step-arrow">→</span>' : ''}
`).join('')}
<span class="goap-step-arrow">→</span>
<span class="goap-goal-result">✓ ${plan.goal_name}</span>
</div>
<div style="margin-top: 8px; font-size: 0.75rem; color: var(--text-muted);">
Total Cost: ${plan.total_cost.toFixed(1)} | Steps: ${plan.plan_length}
</div>
` : `
<div style="color: var(--text-muted); font-size: 0.85rem;">
No plan - using reactive selection<br>
Selected: <strong>${agent.selected_action || 'None'}</strong>
</div>
`}
</div>
<div style="margin-top: 12px;">
<h5 style="font-size: 0.7rem; color: var(--text-muted); margin-bottom: 8px;">INVENTORY</h5>
<div class="goap-inventory">
<div class="goap-inv-item">💧<span class="count">${ws.inventory.water}</span></div>
<div class="goap-inv-item">🍖<span class="count">${ws.inventory.meat}</span></div>
<div class="goap-inv-item">🫐<span class="count">${ws.inventory.berries}</span></div>
<div class="goap-inv-item">🪵<span class="count">${ws.inventory.wood}</span></div>
<div class="goap-inv-item">🥩<span class="count">${ws.inventory.hide}</span></div>
<div class="goap-inv-item">📦<span class="count">${ws.inventory.space}</span></div>
</div>
</div>
<div style="margin-top: 12px; display: flex; gap: 16px; font-size: 0.8rem;">
<span style="color: var(--accent-gold);">💰 ${ws.economy.money}c</span>
<span style="color: var(--text-muted);">Wealthy: ${ws.economy.is_wealthy ? '✓' : '✗'}</span>
</div>
`;
}
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 => `
<div class="goap-action-item ${action.is_valid ? 'valid' : 'invalid'} ${action.is_in_plan ? 'in-plan' : ''}">
${action.is_in_plan ? `<span class="action-order">${action.plan_order + 1}</span>` : ''}
<span class="action-name">${action.name}</span>
<span class="action-cost">${action.cost >= 0 ? action.cost.toFixed(1) : '∞'}</span>
</div>
`).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
}
}