perf: implement fine-grained in-place DOM updates eliminating innerHTML teardowns and layout thrashing

This commit is contained in:
Christian 2026-10-15 21:41:08 +02:00
parent bcf32f3ca7
commit d038cd1879
6 changed files with 449 additions and 119 deletions

View file

@ -1,15 +1,16 @@
// Production - The Idle Game: Main Application Orchestrator // Production - The Idle Game: Main Application Orchestrator
// Fine-grained DOM updates without full innerHTML teardowns
import { GState } from './core/state.js'; import { GState } from './core/state.js';
import { SimulationEngine } from './core/engine.js'; import { SimulationEngine } from './core/engine.js';
import { SyncManager } from './core/sync.js'; import { SyncManager } from './core/sync.js';
import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js'; import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js';
import { PRODUCTS, getProduct } from './data/products.js'; import { PRODUCTS, getProduct } from './data/products.js';
import { renderProductionFloor, calculateMultiCost } from './ui/productionView.js'; import { mountProductionFloor, updateProductionFloor, calculateMultiCost } from './ui/productionView.js';
import { renderContractsBoard } from './ui/contractsView.js'; import { mountContractsBoard, updateContractsBoard } from './ui/contractsView.js';
import { renderBoardroom } from './ui/boardroomView.js'; import { mountBoardroom, updateBoardroom } from './ui/boardroomView.js';
import { renderInspector } from './ui/inspectorView.js'; import { mountInspector, updateInspector } from './ui/inspectorView.js';
import { renderAchievements } from './ui/achievementsView.js'; import { mountAchievements, updateAchievements } from './ui/achievementsView.js';
class ProductionApp { class ProductionApp {
constructor() { constructor() {
@ -18,6 +19,9 @@ class ProductionApp {
this.sync = new SyncManager(this.sm); this.sync = new SyncManager(this.sm);
this.lastFrameTime = performance.now(); this.lastFrameTime = performance.now();
this.lastSaveTime = Date.now(); this.lastSaveTime = Date.now();
this.currentMountedTab = null;
this.currentMountedSector = null;
this.currentMountedProduct = null;
} }
async init() { async init() {
@ -32,27 +36,33 @@ class ProductionApp {
} }
this.setupDomEvents(); this.setupDomEvents();
this.renderAll();
// Initial Mount (builds DOM nodes ONCE)
this.mountActiveView();
this.mountInspectorPane();
this.updateHud();
// Check if logged in and sync cloud state // Check if logged in and sync cloud state
if (this.sync.isLoggedIn()) { if (this.sync.isLoggedIn()) {
this.sync.fetchCloudState().then(cloud => { this.sync.fetchCloudState().then(cloud => {
if (cloud && cloud.lastTick > this.sm.state.lastTick) { if (cloud && cloud.lastTick > this.sm.state.lastTick) {
// Cloud save is newer, merge and save
this.sm.state = { ...this.sm.state, ...cloud }; this.sm.state = { ...this.sm.state, ...cloud };
this.sm.save(); this.sm.save();
this.renderAll(); this.mountActiveView();
this.mountInspectorPane();
} }
}); });
} }
// Main game loop (tick at 30-60fps) // Main game loop (ticks at 60fps, fine-grained updates without innerHTML)
const loop = (currentTime) => { const loop = (currentTime) => {
const dt = Math.min((currentTime - this.lastFrameTime) / 1000, 0.5); // cap at 0.5s per frame const dt = Math.min((currentTime - this.lastFrameTime) / 1000, 0.5);
this.lastFrameTime = currentTime; this.lastFrameTime = currentTime;
this.engine.tick(dt); this.engine.tick(dt);
this.updateHud(); this.updateHud();
this.updateActiveView();
this.updateInspectorPane();
// Auto-save every 5 seconds // Auto-save every 5 seconds
if (Date.now() - this.lastSaveTime > 5000) { if (Date.now() - this.lastSaveTime > 5000) {
@ -63,12 +73,6 @@ class ProductionApp {
requestAnimationFrame(loop); requestAnimationFrame(loop);
}; };
requestAnimationFrame(loop); requestAnimationFrame(loop);
// Periodic view refresh for timers and progress bars (every 250ms)
setInterval(() => {
this.renderActiveView();
this.renderInspectorPane();
}, 250);
} }
setupDomEvents() { setupDomEvents() {
@ -81,7 +85,7 @@ class ProductionApp {
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => { document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab); b.classList.toggle('active', b.dataset.tab === tab);
}); });
this.renderActiveView(); this.mountActiveView();
}; };
}); });
@ -92,7 +96,7 @@ class ProductionApp {
this.sm.state.buyMode = mode; this.sm.state.buyMode = mode;
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active')); document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
this.renderActiveView(); this.mountActiveView(); // Remount button labels for 1x/10x/100x
}; };
}); });
@ -124,13 +128,12 @@ class ProductionApp {
switch (action.type) { switch (action.type) {
case 'SELECT_SECTOR': case 'SELECT_SECTOR':
state.selectedSector = action.sector; state.selectedSector = action.sector;
this.renderActiveView(); this.mountActiveView();
break; break;
case 'SELECT_PRODUCT': case 'SELECT_PRODUCT':
state.selectedProductId = action.productId; state.selectedProductId = action.productId;
this.renderActiveView(); this.mountInspectorPane();
this.renderInspectorPane();
break; break;
case 'UNLOCK_PRODUCT': { case 'UNLOCK_PRODUCT': {
@ -143,7 +146,8 @@ class ProductionApp {
state.selectedProductId = p.id; state.selectedProductId = p.id;
this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success'); this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success');
this.sync.queueAction(action); this.sync.queueAction(action);
this.renderAll(); this.mountActiveView();
this.mountInspectorPane();
} }
break; break;
} }
@ -158,7 +162,8 @@ class ProductionApp {
pState.lines += calc.count; pState.lines += calc.count;
this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info'); this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info');
this.sync.queueAction(action); this.sync.queueAction(action);
this.renderAll(); this.mountActiveView();
this.mountInspectorPane();
} }
} }
break; break;
@ -173,7 +178,7 @@ class ProductionApp {
state.stats.managersHired = (state.stats.managersHired || 0) + 1; state.stats.managersHired = (state.stats.managersHired || 0) + 1;
this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success'); this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success');
this.sync.queueAction(action); this.sync.queueAction(action);
this.renderAll(); this.mountActiveView();
} }
break; break;
} }
@ -188,7 +193,8 @@ class ProductionApp {
pState.logistics = (pState.logistics || 0) + 1; pState.logistics = (pState.logistics || 0) + 1;
this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info'); this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info');
this.sync.queueAction(action); this.sync.queueAction(action);
this.renderAll(); this.mountActiveView();
this.mountInspectorPane();
} }
} }
break; break;
@ -198,7 +204,6 @@ class ProductionApp {
const p = getProduct(action.productId); const p = getProduct(action.productId);
const pState = state.products[action.productId]; const pState = state.products[action.productId];
if (p && pState) { if (p && pState) {
// Check inputs
const isRaw = !p.inputs || p.inputs.length === 0; const isRaw = !p.inputs || p.inputs.length === 0;
let canRun = true; let canRun = true;
if (!isRaw) { if (!isRaw) {
@ -219,7 +224,6 @@ class ProductionApp {
} else { } else {
this.showToast(`Insufficient inputs to cycle batch!`, 'warning'); this.showToast(`Insufficient inputs to cycle batch!`, 'warning');
} }
this.renderAll();
} }
break; break;
} }
@ -231,7 +235,6 @@ class ProductionApp {
const pState = state.products[c.productId]; const pState = state.products[c.productId];
if (pState && pState.stock >= c.targetAmount) { if (pState && pState.stock >= c.targetAmount) {
pState.stock -= c.targetAmount; pState.stock -= c.targetAmount;
// Add subsidy bonus if researched
const subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0); const subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0);
const totalPayout = c.rewardCash * subsidyBonus; const totalPayout = c.rewardCash * subsidyBonus;
state.cash += totalPayout; state.cash += totalPayout;
@ -242,7 +245,7 @@ class ProductionApp {
state.activeContracts.splice(idx, 1); state.activeContracts.splice(idx, 1);
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success'); this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
this.sync.queueAction(action); this.sync.queueAction(action);
this.renderAll(); this.mountActiveView();
} }
} }
break; break;
@ -254,7 +257,8 @@ class ProductionApp {
const success = this.sm.performIPO(); const success = this.sm.performIPO();
if (success) { if (success) {
this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success'); this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success');
this.renderAll(); this.mountActiveView();
this.mountInspectorPane();
} }
} }
break; break;
@ -262,7 +266,6 @@ class ProductionApp {
case 'BUY_PERK': { case 'BUY_PERK': {
const perk = (state.boardroomPerks || {})[action.perkId] || 0; const perk = (state.boardroomPerks || {})[action.perkId] || 0;
// Find perk definition
import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => { import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => {
const def = BOARDROOM_PERKS.find(p => p.id === action.perkId); const def = BOARDROOM_PERKS.find(p => p.id === action.perkId);
if (def && perk < def.maxLevel) { if (def && perk < def.maxLevel) {
@ -271,7 +274,7 @@ class ProductionApp {
state.ventureShares -= cost; state.ventureShares -= cost;
state.boardroomPerks[action.perkId] = perk + 1; state.boardroomPerks[action.perkId] = perk + 1;
this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success'); this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success');
this.renderAll(); this.mountActiveView();
} }
} }
}); });
@ -290,7 +293,6 @@ class ProductionApp {
state.cash += earned; state.cash += earned;
state.lifetimeRevenue += earned; state.lifetimeRevenue += earned;
this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info'); this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info');
this.renderAll();
} }
} }
break; break;
@ -302,16 +304,28 @@ class ProductionApp {
const state = this.sm.state; const state = this.sm.state;
const elCash = document.getElementById('hud-cash'); const elCash = document.getElementById('hud-cash');
if (elCash) elCash.textContent = fmtNum(state.cash); if (elCash) {
const val = fmtNum(state.cash);
if (elCash.textContent !== val) elCash.textContent = val;
}
const elRate = document.getElementById('hud-rate'); const elRate = document.getElementById('hud-rate');
if (elRate) elRate.textContent = fmtRate(this.engine.cashRate).replace('+$', ''); if (elRate) {
const val = fmtRate(this.engine.cashRate).replace('+$', '');
if (elRate.textContent !== val) elRate.textContent = val;
}
const elValuation = document.getElementById('hud-valuation'); const elValuation = document.getElementById('hud-valuation');
if (elValuation) elValuation.textContent = fmtCurrency(state.valuation); if (elValuation) {
const val = fmtCurrency(state.valuation);
if (elValuation.textContent !== val) elValuation.textContent = val;
}
const elShares = document.getElementById('hud-shares'); const elShares = document.getElementById('hud-shares');
if (elShares) elShares.textContent = fmtNum(state.ventureShares, 0); if (elShares) {
const val = fmtNum(state.ventureShares, 0);
if (elShares.textContent !== val) elShares.textContent = val;
}
const elContractsBadge = document.getElementById('contracts-badge'); const elContractsBadge = document.getElementById('contracts-badge');
if (elContractsBadge) { if (elContractsBadge) {
@ -326,34 +340,74 @@ class ProductionApp {
} }
} }
renderActiveView() { /**
* Mounts the main viewport view ONCE when the tab or sector changes
*/
mountActiveView() {
const container = document.getElementById('main-viewport'); const container = document.getElementById('main-viewport');
if (!container) return; if (!container) return;
const tab = this.sm.state.activeTab || 'production'; const tab = this.sm.state.activeTab || 'production';
const sector = this.sm.state.selectedSector || 'all';
this.currentMountedTab = tab;
this.currentMountedSector = sector;
if (tab === 'production') { if (tab === 'production') {
renderProductionFloor(container, this.sm.state, (a) => this.handleAction(a)); mountProductionFloor(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'contracts') { } else if (tab === 'contracts') {
renderContractsBoard(container, this.sm.state, (a) => this.handleAction(a)); mountContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'boardroom') { } else if (tab === 'boardroom') {
renderBoardroom(container, this.sm.state, (a) => this.handleAction(a)); mountBoardroom(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'milestones') { } else if (tab === 'milestones') {
renderAchievements(container, this.sm.state, (a) => this.handleAction(a)); mountAchievements(container, this.sm.state, (a) => this.handleAction(a));
} }
} }
renderInspectorPane() { /**
* Mounts the inspector panel ONCE when selected product changes
*/
mountInspectorPane() {
const inspectorContainer = document.getElementById('desktop-inspector-panel'); const inspectorContainer = document.getElementById('desktop-inspector-panel');
if (inspectorContainer) { if (inspectorContainer) {
renderInspector(inspectorContainer, this.sm.state, (a) => this.handleAction(a)); this.currentMountedProduct = this.sm.state.selectedProductId;
mountInspector(inspectorContainer, this.sm.state, (a) => this.handleAction(a));
} }
} }
renderAll() { /**
this.updateHud(); * Executes fine-grained in-place element updates without innerHTML teardowns
this.renderActiveView(); */
this.renderInspectorPane(); updateActiveView() {
const container = document.getElementById('main-viewport');
if (!container) return;
const tab = this.sm.state.activeTab || 'production';
if (tab !== this.currentMountedTab) {
this.mountActiveView();
return;
}
if (tab === 'production') {
updateProductionFloor(container, this.sm.state);
} else if (tab === 'contracts') {
updateContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'boardroom') {
updateBoardroom(container, this.sm.state);
} else if (tab === 'milestones') {
updateAchievements(container, this.sm.state);
}
}
updateInspectorPane() {
const inspectorContainer = document.getElementById('desktop-inspector-panel');
if (!inspectorContainer) return;
if (this.sm.state.selectedProductId !== this.currentMountedProduct) {
this.mountInspectorPane();
return;
}
updateInspector(inspectorContainer, this.sm.state);
} }
showToast(message, type = 'info') { showToast(message, type = 'info') {
@ -420,7 +474,8 @@ class ProductionApp {
const next = current === 'standard' ? 'scientific' : 'standard'; const next = current === 'standard' ? 'scientific' : 'standard';
setNotation(next); setNotation(next);
this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info'); this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info');
this.renderAll(); this.mountActiveView();
this.mountInspectorPane();
} }
} }

View file

@ -1,9 +1,10 @@
// Production - The Idle Game: Corporate Milestones View // Production - The Idle Game: Corporate Milestones View
// Fine-grained DOM updates without innerHTML teardowns
import { ACHIEVEMENTS } from '../data/achievements.js'; import { ACHIEVEMENTS } from '../data/achievements.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js'; import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function renderAchievements(container, state, onAction) { export function mountAchievements(container, state, onAction) {
const achState = state.achievements || {}; const achState = state.achievements || {};
let html = ` let html = `
@ -35,9 +36,9 @@ export function renderAchievements(container, state, onAction) {
const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100)); const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100));
html += ` html += `
<div class="achievement-card ${currentTier > 0 ? 'achieved' : ''}"> <div class="achievement-card ${currentTier > 0 ? 'achieved' : ''}" id="ach-card-${ach.id}">
<div class="ach-icon-col"> <div class="ach-icon-col">
<div class="ach-icon-circle ${currentTier > 0 ? 'ach-unlocked' : ''}"> <div class="ach-icon-circle ${currentTier > 0 ? 'ach-unlocked' : ''}" id="ach-icon-${ach.id}">
<iconify-icon icon="${ach.icon}"></iconify-icon> <iconify-icon icon="${ach.icon}"></iconify-icon>
</div> </div>
</div> </div>
@ -45,15 +46,15 @@ export function renderAchievements(container, state, onAction) {
<div class="ach-info-col"> <div class="ach-info-col">
<div class="ach-title-row"> <div class="ach-title-row">
<h4 class="ach-title">${ach.title}</h4> <h4 class="ach-title">${ach.title}</h4>
<span class="ach-tier-badge">Tier ${currentTier} / ${maxTier}</span> <span class="ach-tier-badge" id="ach-tier-${ach.id}">Tier ${currentTier} / ${maxTier}</span>
</div> </div>
<p class="ach-desc">${ach.description}</p> <p class="ach-desc">${ach.description}</p>
<div class="ach-progress-bar-track"> <div class="ach-progress-bar-track">
<div class="ach-progress-bar-fill" style="width: ${progressPct}%;"></div> <div class="ach-progress-bar-fill" id="ach-fill-${ach.id}" style="width: ${progressPct}%;"></div>
</div> </div>
<div class="ach-progress-labels font-mono"> <div class="ach-progress-labels font-mono">
<span>${ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0)}</span> <span id="ach-curr-val-${ach.id}">${ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0)}</span>
<span>${isMaxed ? 'Completed!' : (ach.reqType === 'total_revenue' ? fmtCurrency(nextThreshold) : fmtNum(nextThreshold, 0))}</span> <span id="ach-next-val-${ach.id}">${isMaxed ? 'Completed!' : (ach.reqType === 'total_revenue' ? fmtCurrency(nextThreshold) : fmtNum(nextThreshold, 0))}</span>
</div> </div>
</div> </div>
</div> </div>
@ -63,3 +64,34 @@ export function renderAchievements(container, state, onAction) {
html += `</div>`; html += `</div>`;
container.innerHTML = html; container.innerHTML = html;
} }
export function updateAchievements(container, state) {
const achState = state.achievements || {};
for (const ach of ACHIEVEMENTS) {
const currentTier = achState[ach.id] || 0;
let currentVal = 0;
if (ach.reqType === 'total_produced') currentVal = state.stats.totalProduced || 0;
else if (ach.reqType === 'total_revenue') currentVal = state.lifetimeRevenue || 0;
else if (ach.reqType === 'contracts_completed') currentVal = state.stats.contractsCompleted || 0;
else if (ach.reqType === 'ipo_count') currentVal = state.ipoCount || 0;
else if (ach.reqType === 'managers_hired') currentVal = state.stats.managersHired || 0;
const maxTier = ach.thresholds.length;
const isMaxed = currentTier >= maxTier;
const nextThreshold = !isMaxed ? ach.thresholds[currentTier] : ach.thresholds[maxTier - 1];
const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100));
const elFill = document.getElementById(`ach-fill-${ach.id}`);
if (elFill) elFill.style.width = `${progressPct}%`;
const elCurr = document.getElementById(`ach-curr-val-${ach.id}`);
if (elCurr) elCurr.textContent = ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0);
const elTier = document.getElementById(`ach-tier-${ach.id}`);
if (elTier) elTier.textContent = `Tier ${currentTier} / ${maxTier}`;
const elIcon = document.getElementById(`ach-icon-${ach.id}`);
if (elIcon) elIcon.classList.toggle('ach-unlocked', currentTier > 0);
}
}

View file

@ -1,9 +1,10 @@
// Production - The Idle Game: Corporate IPO & Boardroom Perks View // Production - The Idle Game: Corporate IPO & Boardroom Perks View
// Fine-grained DOM updates without innerHTML teardowns
import { BOARDROOM_PERKS, calculateSharesEarned, IPO_MIN_VALUATION } from '../data/boardroom.js'; import { BOARDROOM_PERKS, calculateSharesEarned, IPO_MIN_VALUATION } from '../data/boardroom.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js'; import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function renderBoardroom(container, state, onAction) { export function mountBoardroom(container, state, onAction) {
const earnedShares = calculateSharesEarned(state.lifetimeRevenue); const earnedShares = calculateSharesEarned(state.lifetimeRevenue);
const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION; const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION;
const perks = state.boardroomPerks || {}; const perks = state.boardroomPerks || {};
@ -23,27 +24,27 @@ export function renderBoardroom(container, state, onAction) {
</div> </div>
<div class="hero-valuation-card"> <div class="hero-valuation-card">
<div class="val-label">Corporate Enterprise Valuation</div> <div class="val-label">Corporate Enterprise Valuation</div>
<div class="val-number font-mono">${fmtCurrency(state.valuation)}</div> <div class="val-number font-mono" id="boardroom-val-number">${fmtCurrency(state.valuation)}</div>
<div class="val-lifetime">Lifetime Gross Revenue: <strong>${fmtCurrency(state.lifetimeRevenue)}</strong></div> <div class="val-lifetime">Lifetime Gross Revenue: <strong id="boardroom-val-lifetime">${fmtCurrency(state.lifetimeRevenue)}</strong></div>
</div> </div>
</div> </div>
<div class="ipo-action-banner ${canIPO ? 'ready-ipo' : 'locked-ipo'}"> <div class="ipo-action-banner ${canIPO ? 'ready-ipo' : 'locked-ipo'}" id="boardroom-ipo-banner">
<div class="ipo-banner-info"> <div class="ipo-banner-info">
<div class="ipo-shares-badge font-mono"> <div class="ipo-shares-badge font-mono">
<iconify-icon icon="lucide:gem"></iconify-icon> <iconify-icon icon="lucide:gem"></iconify-icon>
<span>+${fmtNum(earnedShares, 0)} $V-Shares</span> <span id="boardroom-earned-shares">+${fmtNum(earnedShares, 0)} $V-Shares</span>
</div> </div>
<div> <div>
<h4 class="ipo-banner-title">Initial Public Offering (IPO) Ready</h4> <h4 class="ipo-banner-title">Initial Public Offering (IPO) Ready</h4>
<p class="ipo-banner-sub"> <p class="ipo-banner-sub" id="boardroom-ipo-sub">
${canIPO ${canIPO
? `Execute corporate IPO now to claim ${fmtNum(earnedShares, 0)} Venture Shares and restart with permanent bonuses.` ? `Execute corporate IPO now to claim ${fmtNum(earnedShares, 0)} Venture Shares and restart with permanent bonuses.`
: `Requires minimum ${fmtCurrency(IPO_MIN_VALUATION)} in lifetime revenue to file for IPO.`} : `Requires minimum ${fmtCurrency(IPO_MIN_VALUATION)} in lifetime revenue to file for IPO.`}
</p> </p>
</div> </div>
</div> </div>
<button class="btn-primary-corp btn-ipo-trigger" ${canIPO ? '' : 'disabled'}> <button class="btn-primary-corp btn-ipo-trigger" id="boardroom-btn-ipo" ${canIPO ? '' : 'disabled'}>
<iconify-icon icon="lucide:trending-up"></iconify-icon> <iconify-icon icon="lucide:trending-up"></iconify-icon>
<span>Launch IPO</span> <span>Launch IPO</span>
</button> </button>
@ -53,11 +54,11 @@ export function renderBoardroom(container, state, onAction) {
<div class="boardroom-shares-summary"> <div class="boardroom-shares-summary">
<div class="shares-pill"> <div class="shares-pill">
<span class="shares-label">Available $V-Shares:</span> <span class="shares-label">Available $V-Shares:</span>
<strong class="shares-val font-mono"><iconify-icon icon="lucide:gem"></iconify-icon> ${fmtNum(state.ventureShares, 0)}</strong> <strong class="shares-val font-mono"><iconify-icon icon="lucide:gem"></iconify-icon> <span id="boardroom-avail-shares">${fmtNum(state.ventureShares, 0)}</span></strong>
</div> </div>
<div class="shares-pill"> <div class="shares-pill">
<span class="shares-label">IPOs Completed:</span> <span class="shares-label">IPOs Completed:</span>
<strong class="shares-val font-mono">${state.ipoCount || 0}</strong> <strong class="shares-val font-mono" id="boardroom-ipos-completed">${state.ipoCount || 0}</strong>
</div> </div>
</div> </div>
@ -76,13 +77,13 @@ export function renderBoardroom(container, state, onAction) {
const canAfford = state.ventureShares >= nextCost && !isMaxed; const canAfford = state.ventureShares >= nextCost && !isMaxed;
html += ` html += `
<div class="perk-card ${isMaxed ? 'perk-maxed' : ''}"> <div class="perk-card ${isMaxed ? 'perk-maxed' : ''}" id="perk-card-${perk.id}">
<div class="perk-header"> <div class="perk-header">
<div class="perk-icon-wrap" style="background: ${perk.color}15; color: ${perk.color};"> <div class="perk-icon-wrap" style="background: ${perk.color}15; color: ${perk.color};">
<iconify-icon icon="${perk.icon}" class="icon-lg"></iconify-icon> <iconify-icon icon="${perk.icon}" class="icon-lg"></iconify-icon>
</div> </div>
<div> <div>
<div class="perk-tier-badge">Level ${currentLvl} / ${perk.maxLevel}</div> <div class="perk-tier-badge" id="perk-tier-${perk.id}">Level ${currentLvl} / ${perk.maxLevel}</div>
<h4 class="perk-name">${perk.name}</h4> <h4 class="perk-name">${perk.name}</h4>
</div> </div>
</div> </div>
@ -94,9 +95,9 @@ export function renderBoardroom(container, state, onAction) {
</div> </div>
<div class="perk-footer"> <div class="perk-footer">
<button class="btn-corp-perk btn-buy-perk" data-perk="${perk.id}" ${canAfford ? '' : 'disabled'}> <button class="btn-corp-perk btn-buy-perk" id="btn-perk-${perk.id}" data-perk="${perk.id}" ${canAfford ? '' : 'disabled'}>
<iconify-icon icon="lucide:gem"></iconify-icon> <iconify-icon icon="lucide:gem"></iconify-icon>
<span>${isMaxed ? 'Max Tier Acquired' : `Upgrade (${fmtNum(nextCost, 0)} $V-Shares)`}</span> <span id="btn-perk-text-${perk.id}">${isMaxed ? 'Max Tier Acquired' : `Upgrade (${fmtNum(nextCost, 0)} $V-Shares)`}</span>
</button> </button>
</div> </div>
</div> </div>
@ -115,3 +116,34 @@ export function renderBoardroom(container, state, onAction) {
btn.onclick = () => onAction({ type: 'BUY_PERK', perkId: btn.dataset.perk }); btn.onclick = () => onAction({ type: 'BUY_PERK', perkId: btn.dataset.perk });
}); });
} }
export function updateBoardroom(container, state) {
const earnedShares = calculateSharesEarned(state.lifetimeRevenue);
const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION;
const perks = state.boardroomPerks || {};
const elVal = document.getElementById('boardroom-val-number');
if (elVal) elVal.textContent = fmtCurrency(state.valuation);
const elLifetime = document.getElementById('boardroom-val-lifetime');
if (elLifetime) elLifetime.textContent = fmtCurrency(state.lifetimeRevenue);
const elEarned = document.getElementById('boardroom-earned-shares');
if (elEarned) elEarned.textContent = `+${fmtNum(earnedShares, 0)} $V-Shares`;
const btnIPO = document.getElementById('boardroom-btn-ipo');
if (btnIPO) btnIPO.disabled = !canIPO;
const elAvail = document.getElementById('boardroom-avail-shares');
if (elAvail) elAvail.textContent = fmtNum(state.ventureShares, 0);
for (const perk of BOARDROOM_PERKS) {
const currentLvl = perks[perk.id] || 0;
const isMaxed = currentLvl >= perk.maxLevel;
const nextCost = !isMaxed ? perk.costShares[currentLvl] : 0;
const canAfford = state.ventureShares >= nextCost && !isMaxed;
const elBtn = document.getElementById(`btn-perk-${perk.id}`);
if (elBtn) elBtn.disabled = !canAfford;
}
}

View file

@ -1,11 +1,15 @@
// Production - The Idle Game: B2B Procurement Contracts Board // Production - The Idle Game: B2B Procurement Contracts Board
// Fine-grained DOM updates without innerHTML teardowns
import { getProduct } from '../data/products.js'; import { getProduct } from '../data/products.js';
import { getClient } from '../data/contracts.js'; import { getClient } from '../data/contracts.js';
import { fmtCurrency, fmtNum, fmtTime } from '../core/formatters.js'; import { fmtCurrency, fmtNum, fmtTime } from '../core/formatters.js';
export function renderContractsBoard(container, state, onAction) { let currentMountedContractUids = '';
export function mountContractsBoard(container, state, onAction) {
const contracts = state.activeContracts || []; const contracts = state.activeContracts || [];
currentMountedContractUids = contracts.map(c => c.uid).join(',');
let html = ` let html = `
<div class="contracts-header-pane"> <div class="contracts-header-pane">
@ -20,7 +24,7 @@ export function renderContractsBoard(container, state, onAction) {
</div> </div>
<div class="contracts-meta-badge"> <div class="contracts-meta-badge">
<span>Completed Tenders:</span> <span>Completed Tenders:</span>
<strong>${state.stats.contractsCompleted || 0}</strong> <strong id="contract-stats-completed">${state.stats.contractsCompleted || 0}</strong>
</div> </div>
</div> </div>
@ -45,7 +49,7 @@ export function renderContractsBoard(container, state, onAction) {
const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100)); const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100));
html += ` html += `
<div class="contract-card ${canFulfill ? 'ready-to-fulfill' : ''}"> <div class="contract-card ${canFulfill ? 'ready-to-fulfill' : ''}" id="contract-${c.uid}">
<div class="contract-client-row"> <div class="contract-client-row">
<div class="client-brand"> <div class="client-brand">
<div class="client-avatar"> <div class="client-avatar">
@ -56,9 +60,9 @@ export function renderContractsBoard(container, state, onAction) {
<span class="client-badge">${client.badge}</span> <span class="client-badge">${client.badge}</span>
</div> </div>
</div> </div>
<div class="contract-timer font-mono ${c.remainingSeconds < 60 ? 'timer-urgent' : ''}"> <div class="contract-timer font-mono ${c.remainingSeconds < 60 ? 'timer-urgent' : ''}" id="timer-box-${c.uid}">
<iconify-icon icon="lucide:timer"></iconify-icon> <iconify-icon icon="lucide:timer"></iconify-icon>
<span>${fmtTime(c.remainingSeconds)}</span> <span id="timer-val-${c.uid}">${fmtTime(c.remainingSeconds)}</span>
</div> </div>
</div> </div>
@ -73,12 +77,12 @@ export function renderContractsBoard(container, state, onAction) {
</div> </div>
</div> </div>
<div class="contract-req-progress font-mono"> <div class="contract-req-progress font-mono">
${fmtNum(currentStock, 0)} / <strong>${fmtNum(c.targetAmount, 0)}</strong> ${product?.unit || 'units'} <span id="c-curr-stock-${c.uid}">${fmtNum(currentStock, 0)}</span> / <strong>${fmtNum(c.targetAmount, 0)}</strong> ${product?.unit || 'units'}
</div> </div>
</div> </div>
<div class="contract-progress-track"> <div class="contract-progress-track">
<div class="contract-progress-fill" style="width: ${progressPct}%;"></div> <div class="contract-progress-fill" id="c-fill-${c.uid}" style="width: ${progressPct}%;"></div>
</div> </div>
<div class="contract-payout-box"> <div class="contract-payout-box">
@ -94,9 +98,9 @@ export function renderContractsBoard(container, state, onAction) {
</div> </div>
<div class="contract-footer"> <div class="contract-footer">
<button class="btn-primary-corp btn-fulfill-contract" data-uid="${c.uid}" ${canFulfill ? '' : 'disabled'}> <button class="btn-primary-corp btn-fulfill-contract" id="c-btn-${c.uid}" data-uid="${c.uid}" ${canFulfill ? '' : 'disabled'}>
<iconify-icon icon="lucide:check-check"></iconify-icon> <iconify-icon icon="lucide:check-check"></iconify-icon>
<span>${canFulfill ? 'Deliver Shipment & Collect Payout' : `Fulfill Tender (Need ${fmtNum(c.targetAmount - currentStock, 0)} more)`}</span> <span id="c-btn-text-${c.uid}">${canFulfill ? 'Deliver Shipment & Collect Payout' : `Fulfill Tender (Need ${fmtNum(c.targetAmount - currentStock, 0)} more)`}</span>
</button> </button>
</div> </div>
</div> </div>
@ -110,3 +114,53 @@ export function renderContractsBoard(container, state, onAction) {
btn.onclick = () => onAction({ type: 'FULFILL_CONTRACT', uid: btn.dataset.uid }); btn.onclick = () => onAction({ type: 'FULFILL_CONTRACT', uid: btn.dataset.uid });
}); });
} }
export function updateContractsBoard(container, state, onAction) {
const contracts = state.activeContracts || [];
const currentUids = contracts.map(c => c.uid).join(',');
// If active contracts changed (added, completed, expired), remount cleanly
if (currentUids !== currentMountedContractUids) {
mountContractsBoard(container, state, onAction);
return;
}
for (const c of contracts) {
const currentStock = state.products[c.productId]?.stock || 0;
const canFulfill = currentStock >= c.targetAmount;
const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100));
// Timer
const elTimerVal = document.getElementById(`timer-val-${c.uid}`);
if (elTimerVal) elTimerVal.textContent = fmtTime(c.remainingSeconds);
const elTimerBox = document.getElementById(`timer-box-${c.uid}`);
if (elTimerBox) {
elTimerBox.classList.toggle('timer-urgent', c.remainingSeconds < 60);
}
// Current Stock Text
const elCurrStock = document.getElementById(`c-curr-stock-${c.uid}`);
if (elCurrStock) elCurrStock.textContent = fmtNum(currentStock, 0);
// Progress bar
const elFill = document.getElementById(`c-fill-${c.uid}`);
if (elFill) elFill.style.width = `${progressPct}%`;
// Card fulfill class
const elCard = document.getElementById(`contract-${c.uid}`);
if (elCard) elCard.classList.toggle('ready-to-fulfill', canFulfill);
// Button disabled & text
const elBtn = document.getElementById(`c-btn-${c.uid}`);
if (elBtn) {
elBtn.disabled = !canFulfill;
const elBtnText = document.getElementById(`c-btn-text-${c.uid}`);
if (elBtnText) {
elBtnText.textContent = canFulfill
? 'Deliver Shipment & Collect Payout'
: `Fulfill Tender (Need ${fmtNum(c.targetAmount - currentStock, 0)} more)`;
}
}
}
}

View file

@ -1,18 +1,21 @@
// Production - The Idle Game: Desktop Inspector & Supply Chain Analytics Panel // Production - The Idle Game: Desktop Inspector & Supply Chain Analytics Panel
// Fine-grained DOM updates without innerHTML teardowns
import { PRODUCTS, SECTORS, getProduct } from '../data/products.js'; import { PRODUCTS, SECTORS, getProduct } from '../data/products.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js'; import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function renderInspector(container, state, onAction) { let currentMountedProductId = null;
export function mountInspector(container, state, onAction) {
const selectedId = state.selectedProductId || 'wheat'; const selectedId = state.selectedProductId || 'wheat';
const product = getProduct(selectedId) || PRODUCTS[0]; const product = getProduct(selectedId) || PRODUCTS[0];
const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0, unlocked: false }; const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0, unlocked: false };
const sector = SECTORS.find(s => s.id === product.sector); const sector = SECTORS.find(s => s.id === product.sector);
currentMountedProductId = product.id;
// Find downstream products that consume this product // Downstream products that consume this product
const downstream = PRODUCTS.filter(p => p.inputs && p.inputs.some(inp => inp.id === product.id)); const downstream = PRODUCTS.filter(p => p.inputs && p.inputs.some(inp => inp.id === product.id));
// Calculate liquidation value
const totalLiquidationValue = (pState.stock || 0) * product.basePrice; const totalLiquidationValue = (pState.stock || 0) * product.basePrice;
const halfLiquidationValue = totalLiquidationValue * 0.5; const halfLiquidationValue = totalLiquidationValue * 0.5;
@ -33,7 +36,7 @@ export function renderInspector(container, state, onAction) {
<div class="inspector-metrics-grid"> <div class="inspector-metrics-grid">
<div class="metric-box"> <div class="metric-box">
<span class="metric-label">Warehouse Stock:</span> <span class="metric-label">Warehouse Stock:</span>
<strong class="metric-val font-mono">${fmtNum(pState.stock, 0)} ${product.unit}</strong> <strong class="metric-val font-mono" id="insp-stock">${fmtNum(pState.stock, 0)} ${product.unit}</strong>
</div> </div>
<div class="metric-box"> <div class="metric-box">
<span class="metric-label">Wholesale Price:</span> <span class="metric-label">Wholesale Price:</span>
@ -41,11 +44,11 @@ export function renderInspector(container, state, onAction) {
</div> </div>
<div class="metric-box"> <div class="metric-box">
<span class="metric-label">Production Lines:</span> <span class="metric-label">Production Lines:</span>
<strong class="metric-val font-mono">${pState.lines} active</strong> <strong class="metric-val font-mono" id="insp-lines">${pState.lines} active</strong>
</div> </div>
<div class="metric-box"> <div class="metric-box">
<span class="metric-label">Logistics Fleets:</span> <span class="metric-label">Logistics Fleets:</span>
<strong class="metric-val font-mono">${pState.logistics || 0} active</strong> <strong class="metric-val font-mono" id="insp-logistics">${pState.logistics || 0} active</strong>
</div> </div>
</div> </div>
@ -56,11 +59,11 @@ export function renderInspector(container, state, onAction) {
</h4> </h4>
<p class="section-subtext">Liquidate stockpiles immediately onto the wholesale spot market.</p> <p class="section-subtext">Liquidate stockpiles immediately onto the wholesale spot market.</p>
<div class="liquidation-buttons"> <div class="liquidation-buttons">
<button class="btn-corp-secondary btn-liquidate-half" data-product="${product.id}" ${pState.stock >= 2 ? '' : 'disabled'}> <button class="btn-corp-secondary" id="insp-btn-half" data-product="${product.id}" ${pState.stock >= 2 ? '' : 'disabled'}>
Sell 50% (${fmtCurrency(halfLiquidationValue)}) Sell 50% (<span id="insp-val-half">${fmtCurrency(halfLiquidationValue)}</span>)
</button> </button>
<button class="btn-primary-corp btn-liquidate-all" data-product="${product.id}" ${pState.stock >= 1 ? '' : 'disabled'}> <button class="btn-primary-corp" id="insp-btn-all" data-product="${product.id}" ${pState.stock >= 1 ? '' : 'disabled'}>
Sell 100% (${fmtCurrency(totalLiquidationValue)}) Sell 100% (<span id="insp-val-all">${fmtCurrency(totalLiquidationValue)}</span>)
</button> </button>
</div> </div>
</div> </div>
@ -87,7 +90,7 @@ export function renderInspector(container, state, onAction) {
<iconify-icon icon="${parent.icon}" style="color: ${parent.color};"></iconify-icon> <iconify-icon icon="${parent.icon}" style="color: ${parent.color};"></iconify-icon>
<span>${parent.name}</span> <span>${parent.name}</span>
</div> </div>
<span class="chain-item-ratio font-mono">${inp.amount} per unit (${fmtNum(parentStock, 0)} on hand)</span> <span class="chain-item-ratio font-mono">${inp.amount} per unit (<strong id="insp-inp-val-${inp.id}">${fmtNum(parentStock, 0)}</strong> on hand)</span>
</div> </div>
`; `;
}).join('')} }).join('')}
@ -110,7 +113,7 @@ export function renderInspector(container, state, onAction) {
<iconify-icon icon="${child.icon}" style="color: ${child.color};"></iconify-icon> <iconify-icon icon="${child.icon}" style="color: ${child.color};"></iconify-icon>
<span>${child.name}</span> <span>${child.name}</span>
</div> </div>
<span class="chain-badge ${isUnlocked ? 'badge-open' : 'badge-locked'}"> <span class="chain-badge ${isUnlocked ? 'badge-open' : 'badge-locked'}" id="insp-child-${child.id}">
${isUnlocked ? 'Active' : 'Locked'} ${isUnlocked ? 'Active' : 'Locked'}
</span> </span>
</div> </div>
@ -125,12 +128,12 @@ export function renderInspector(container, state, onAction) {
container.innerHTML = html; container.innerHTML = html;
const halfBtn = container.querySelector('.btn-liquidate-half'); const halfBtn = container.querySelector('#insp-btn-half');
if (halfBtn) { if (halfBtn) {
halfBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 0.5 }); halfBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 0.5 });
} }
const allBtn = container.querySelector('.btn-liquidate-all'); const allBtn = container.querySelector('#insp-btn-all');
if (allBtn) { if (allBtn) {
allBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 1.0 }); allBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 1.0 });
} }
@ -139,3 +142,46 @@ export function renderInspector(container, state, onAction) {
row.onclick = () => onAction({ type: 'SELECT_PRODUCT', productId: row.dataset.product }); row.onclick = () => onAction({ type: 'SELECT_PRODUCT', productId: row.dataset.product });
}); });
} }
export function updateInspector(container, state) {
const selectedId = state.selectedProductId || 'wheat';
if (selectedId !== currentMountedProductId) {
return; // Caller should remount if product changed
}
const product = getProduct(selectedId);
if (!product) return;
const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0 };
// Update stock text
const elStock = document.getElementById('insp-stock');
if (elStock) {
elStock.textContent = `${fmtNum(pState.stock, 0)} ${product.unit}`;
}
// Update liquidation values & buttons
const totalLiquidationValue = (pState.stock || 0) * product.basePrice;
const halfLiquidationValue = totalLiquidationValue * 0.5;
const elHalfVal = document.getElementById('insp-val-half');
if (elHalfVal) elHalfVal.textContent = fmtCurrency(halfLiquidationValue);
const elAllVal = document.getElementById('insp-val-all');
if (elAllVal) elAllVal.textContent = fmtCurrency(totalLiquidationValue);
const btnHalf = document.getElementById('insp-btn-half');
if (btnHalf) btnHalf.disabled = pState.stock < 2;
const btnAll = document.getElementById('insp-btn-all');
if (btnAll) btnAll.disabled = pState.stock < 1;
// Update input values
if (product.inputs) {
for (const inp of product.inputs) {
const elInp = document.getElementById(`insp-inp-val-${inp.id}`);
if (elInp) {
elInp.textContent = fmtNum(state.products[inp.id]?.stock || 0, 0);
}
}
}
}

View file

@ -1,11 +1,10 @@
// Production - The Idle Game: Production Floor View // Production - The Idle Game: Production Floor View
// High-performance fine-grained DOM mounting & updating without innerHTML teardowns
import { PRODUCTS, SECTORS, getProduct, getNextMilestone, getUnitMilestoneMultiplier } from '../data/products.js'; import { PRODUCTS, SECTORS, getProduct, getNextMilestone, getUnitMilestoneMultiplier } from '../data/products.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js'; import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function calculateMultiCost(baseCost, currentCount, multiplier) { export function calculateMultiCost(baseCost, currentCount, multiplier) {
// Standard incremental scaling: cost = baseCost * 1.15^count
// For n purchases: sum(baseCost * 1.15^(count + i))
const growth = 1.15; const growth = 1.15;
let countToBuy = 1; let countToBuy = 1;
@ -16,7 +15,7 @@ export function calculateMultiCost(baseCost, currentCount, multiplier) {
const nextTarget = getNextMilestone(currentCount); const nextTarget = getNextMilestone(currentCount);
countToBuy = Math.max(1, nextTarget - currentCount); countToBuy = Math.max(1, nextTarget - currentCount);
} else if (multiplier === 'max') { } else if (multiplier === 'max') {
countToBuy = 1; // Will be handled dynamically in loop countToBuy = 1;
} }
let totalCost = 0; let totalCost = 0;
@ -26,7 +25,10 @@ export function calculateMultiCost(baseCost, currentCount, multiplier) {
return { count: countToBuy, cost: totalCost }; return { count: countToBuy, cost: totalCost };
} }
export function renderProductionFloor(container, state, onAction) { /**
* Mounts the production floor DOM structure ONCE
*/
export function mountProductionFloor(container, state, onAction) {
const selectedSector = state.selectedSector || 'all'; const selectedSector = state.selectedSector || 'all';
const buyMode = state.buyMode || '1'; const buyMode = state.buyMode || '1';
@ -60,10 +62,9 @@ export function renderProductionFloor(container, state, onAction) {
const isSelected = state.selectedProductId === p.id; const isSelected = state.selectedProductId === p.id;
if (!pState.unlocked) { if (!pState.unlocked) {
// Locked Card
const canAffordUnlock = state.cash >= p.unlockCost; const canAffordUnlock = state.cash >= p.unlockCost;
html += ` html += `
<div class="facility-card locked-card ${isSelected ? 'selected' : ''}" data-product="${p.id}"> <div class="facility-card locked-card ${isSelected ? 'selected' : ''}" id="card-${p.id}" data-product="${p.id}">
<div class="facility-header"> <div class="facility-header">
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};"> <div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon> <iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
@ -77,7 +78,7 @@ export function renderProductionFloor(container, state, onAction) {
</div> </div>
<p class="facility-desc">${p.description}</p> <p class="facility-desc">${p.description}</p>
<div class="locked-footer"> <div class="locked-footer">
<button class="btn-primary-corp btn-unlock" data-product="${p.id}" ${canAffordUnlock ? '' : 'disabled'}> <button class="btn-primary-corp btn-unlock" id="btn-unlock-${p.id}" data-product="${p.id}" ${canAffordUnlock ? '' : 'disabled'}>
<iconify-icon icon="lucide:lock-open"></iconify-icon> <iconify-icon icon="lucide:lock-open"></iconify-icon>
<span>Acquire Permit (${fmtCurrency(p.unlockCost)})</span> <span>Acquire Permit (${fmtCurrency(p.unlockCost)})</span>
</button> </button>
@ -87,7 +88,6 @@ export function renderProductionFloor(container, state, onAction) {
continue; continue;
} }
// Unlocked Active Card
const nextM = getNextMilestone(pState.lines); const nextM = getNextMilestone(pState.lines);
const milestoneMult = getUnitMilestoneMultiplier(pState.lines); const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode); const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
@ -97,7 +97,6 @@ export function renderProductionFloor(container, state, onAction) {
const logisticsCost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0); const logisticsCost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
const canAffordLogistics = state.cash >= logisticsCost; const canAffordLogistics = state.cash >= logisticsCost;
// Input Requirements status
const isRaw = !p.inputs || p.inputs.length === 0; const isRaw = !p.inputs || p.inputs.length === 0;
let inputsHtml = ''; let inputsHtml = '';
if (!isRaw) { if (!isRaw) {
@ -111,9 +110,9 @@ export function renderProductionFloor(container, state, onAction) {
const neededPerLine = inp.amount * pState.lines; const neededPerLine = inp.amount * pState.lines;
const isLow = currentStock < neededPerLine; const isLow = currentStock < neededPerLine;
return ` return `
<span class="input-chip ${isLow ? 'low-stock' : ''}" title="${inpProduct?.name}"> <span class="input-chip ${isLow ? 'low-stock' : ''}" id="chip-${p.id}-${inp.id}" title="${inpProduct?.name}">
<iconify-icon icon="${inpProduct?.icon || 'lucide:box'}"></iconify-icon> <iconify-icon icon="${inpProduct?.icon || 'lucide:box'}"></iconify-icon>
${inpProduct?.name}: <strong>${fmtNum(currentStock, 0)}</strong> / ${fmtNum(neededPerLine, 0)} ${inpProduct?.name}: <strong id="chip-val-${p.id}-${inp.id}">${fmtNum(currentStock, 0)}</strong> / ${fmtNum(neededPerLine, 0)}
</span> </span>
`; `;
}).join('')} }).join('')}
@ -124,17 +123,17 @@ export function renderProductionFloor(container, state, onAction) {
let statusBadge = ''; let statusBadge = '';
if (pState.isBottlenecked) { if (pState.isBottlenecked) {
statusBadge = `<span class="status-badge badge-warning"><iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs</span>`; statusBadge = `<span class="status-badge badge-warning" id="badge-${p.id}"><iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs</span>`;
} else if (pState.lines > 0) { } else if (pState.lines > 0) {
statusBadge = `<span class="status-badge badge-success"><iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)</span>`; statusBadge = `<span class="status-badge badge-success" id="badge-${p.id}"><iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)</span>`;
} else { } else {
statusBadge = `<span class="status-badge badge-idle">Idle (0 Lines)</span>`; statusBadge = `<span class="status-badge badge-idle" id="badge-${p.id}">Idle (0 Lines)</span>`;
} }
const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100)); const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100));
html += ` html += `
<div class="facility-card active-card ${isSelected ? 'selected' : ''}" data-product="${p.id}"> <div class="facility-card active-card ${isSelected ? 'selected' : ''}" id="card-${p.id}" data-product="${p.id}">
<div class="facility-header"> <div class="facility-header">
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};"> <div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon> <iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
@ -144,23 +143,23 @@ export function renderProductionFloor(container, state, onAction) {
<span class="sector-badge" style="color: ${sector?.color || '#64748b'};"> <span class="sector-badge" style="color: ${sector?.color || '#64748b'};">
${sector?.name} &bull; Tier ${p.tier} ${sector?.name} &bull; Tier ${p.tier}
</span> </span>
${statusBadge} <div id="badge-wrap-${p.id}">${statusBadge}</div>
</div> </div>
<h3 class="facility-name">${p.name}</h3> <h3 class="facility-name">${p.name}</h3>
<span class="facility-type-sub">${p.facilityName} &bull; <strong>${pState.lines}</strong> lines (${nextM - pState.lines} to 2x)</span> <span class="facility-type-sub" id="type-sub-${p.id}">${p.facilityName} &bull; <strong>${pState.lines}</strong> lines (${nextM - pState.lines} to 2x)</span>
</div> </div>
</div> </div>
<!-- Cycle Progress Bar --> <!-- Cycle Progress Bar -->
<div class="cycle-bar-track"> <div class="cycle-bar-track">
<div class="cycle-bar-fill" style="width: ${progressPct}%; background-color: ${p.color};"></div> <div class="cycle-bar-fill" id="progress-${p.id}" style="width: ${progressPct}%; background-color: ${p.color};"></div>
</div> </div>
<!-- Stock and Inventory Summary --> <!-- Stock and Inventory Summary -->
<div class="facility-stats-grid"> <div class="facility-stats-grid">
<div class="stat-cell"> <div class="stat-cell">
<span class="stat-label">Stockpile:</span> <span class="stat-label">Stockpile:</span>
<span class="stat-value font-mono"><strong>${fmtNum(pState.stock, 0)}</strong> ${p.unit}</span> <span class="stat-value font-mono"><strong id="stock-${p.id}">${fmtNum(pState.stock, 0)}</strong> ${p.unit}</span>
</div> </div>
<div class="stat-cell"> <div class="stat-cell">
<span class="stat-label">Wholesale Price:</span> <span class="stat-label">Wholesale Price:</span>
@ -168,7 +167,7 @@ export function renderProductionFloor(container, state, onAction) {
</div> </div>
<div class="stat-cell"> <div class="stat-cell">
<span class="stat-label">Logistics Fleet:</span> <span class="stat-label">Logistics Fleet:</span>
<span class="stat-value font-mono">${pState.logistics || 0} fleets</span> <span class="stat-value font-mono" id="logistics-${p.id}">${pState.logistics || 0} fleets</span>
</div> </div>
</div> </div>
@ -181,13 +180,13 @@ export function renderProductionFloor(container, state, onAction) {
<span>Run Batch</span> <span>Run Batch</span>
</button> </button>
<button class="btn-primary-corp btn-expand" data-product="${p.id}" ${canAffordBuy ? '' : 'disabled'}> <button class="btn-primary-corp btn-expand" id="btn-expand-${p.id}" data-product="${p.id}" ${canAffordBuy ? '' : 'disabled'}>
<iconify-icon icon="lucide:plus-circle"></iconify-icon> <iconify-icon icon="lucide:plus-circle"></iconify-icon>
<span>+${calc.count} Lines (${fmtCurrency(calc.cost)})</span> <span id="btn-expand-text-${p.id}">+${calc.count} Lines (${fmtCurrency(calc.cost)})</span>
</button> </button>
${!pState.manager ? ` ${!pState.manager ? `
<button class="btn-corp-secondary btn-hire-mgr" data-product="${p.id}" ${canAffordManager ? '' : 'disabled'} title="Automates production continuously"> <button class="btn-corp-secondary btn-hire-mgr" id="btn-hire-${p.id}" data-product="${p.id}" ${canAffordManager ? '' : 'disabled'} title="Automates production continuously">
<iconify-icon icon="lucide:user-check"></iconify-icon> <iconify-icon icon="lucide:user-check"></iconify-icon>
<span>Hire Lead (${fmtCurrency(managerCost)})</span> <span>Hire Lead (${fmtCurrency(managerCost)})</span>
</button> </button>
@ -197,9 +196,9 @@ export function renderProductionFloor(container, state, onAction) {
</span> </span>
`} `}
<button class="btn-corp-secondary btn-expand-logistics" data-product="${p.id}" ${canAffordLogistics ? '' : 'disabled'} title="Increase passive sales capacity"> <button class="btn-corp-secondary btn-expand-logistics" id="btn-logistics-${p.id}" data-product="${p.id}" ${canAffordLogistics ? '' : 'disabled'} title="Increase passive sales capacity">
<iconify-icon icon="lucide:truck"></iconify-icon> <iconify-icon icon="lucide:truck"></iconify-icon>
<span>+Logistics (${fmtCurrency(logisticsCost)})</span> <span id="btn-logistics-text-${p.id}">+Logistics (${fmtCurrency(logisticsCost)})</span>
</button> </button>
</div> </div>
</div> </div>
@ -209,14 +208,14 @@ export function renderProductionFloor(container, state, onAction) {
html += `</div>`; html += `</div>`;
container.innerHTML = html; container.innerHTML = html;
// Attach delegated events // Attach event listeners ONCE
container.querySelectorAll('.sector-pill').forEach(btn => { container.querySelectorAll('.sector-pill').forEach(btn => {
btn.onclick = () => onAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector }); btn.onclick = () => onAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector });
}); });
container.querySelectorAll('.facility-card').forEach(card => { container.querySelectorAll('.facility-card').forEach(card => {
card.onclick = (e) => { card.onclick = (e) => {
if (e.target.closest('button')) return; // Ignore clicks on action buttons if (e.target.closest('button')) return;
onAction({ type: 'SELECT_PRODUCT', productId: card.dataset.product }); onAction({ type: 'SELECT_PRODUCT', productId: card.dataset.product });
}; };
}); });
@ -241,3 +240,115 @@ export function renderProductionFloor(container, state, onAction) {
btn.onclick = () => onAction({ type: 'BUY_LOGISTICS', productId: btn.dataset.product }); btn.onclick = () => onAction({ type: 'BUY_LOGISTICS', productId: btn.dataset.product });
}); });
} }
/**
* Fine-grained in-place DOM updates (0% layout thrashing, 0% DOM teardown)
*/
export function updateProductionFloor(container, state) {
const buyMode = state.buyMode || '1';
const cash = state.cash;
for (const p of PRODUCTS) {
const pState = state.products[p.id];
if (!pState) continue;
// If card is locked: only update unlock button disabled state
if (!pState.unlocked) {
const btnUnlock = document.getElementById(`btn-unlock-${p.id}`);
if (btnUnlock) {
const canAfford = cash >= p.unlockCost;
if (btnUnlock.disabled !== !canAfford) {
btnUnlock.disabled = !canAfford;
}
}
continue;
}
// 1. Progress Bar (smooth width update)
const elProgress = document.getElementById(`progress-${p.id}`);
if (elProgress) {
const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100));
elProgress.style.width = `${progressPct}%`;
}
// 2. Stockpile Text
const elStock = document.getElementById(`stock-${p.id}`);
if (elStock) {
const stockText = fmtNum(pState.stock, 0);
if (elStock.textContent !== stockText) {
elStock.textContent = stockText;
}
}
// 3. Status Badge (only update if bottlenecked state changed)
const elBadge = document.getElementById(`badge-${p.id}`);
if (elBadge) {
const isBottlenecked = pState.isBottlenecked;
const hasWarning = elBadge.classList.contains('badge-warning');
if (isBottlenecked && !hasWarning) {
elBadge.className = 'status-badge badge-warning';
elBadge.innerHTML = `<iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs`;
} else if (!isBottlenecked && hasWarning) {
const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
elBadge.className = 'status-badge badge-success';
elBadge.innerHTML = `<iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)`;
}
}
// 4. Input Chips Stock
if (p.inputs && p.inputs.length > 0) {
for (const inp of p.inputs) {
const currentStock = state.products[inp.id]?.stock || 0;
const elChipVal = document.getElementById(`chip-val-${p.id}-${inp.id}`);
if (elChipVal) {
const textVal = fmtNum(currentStock, 0);
if (elChipVal.textContent !== textVal) {
elChipVal.textContent = textVal;
}
}
const elChip = document.getElementById(`chip-${p.id}-${inp.id}`);
if (elChip) {
const needed = inp.amount * pState.lines;
const isLow = currentStock < needed;
elChip.classList.toggle('low-stock', isLow);
}
}
}
// 5. Expand Button disabled state
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
const elBtnExpand = document.getElementById(`btn-expand-${p.id}`);
if (elBtnExpand) {
const canAfford = cash >= calc.cost;
if (elBtnExpand.disabled !== !canAfford) {
elBtnExpand.disabled = !canAfford;
}
}
// 6. Hire Manager disabled state
const elBtnHire = document.getElementById(`btn-hire-${p.id}`);
if (elBtnHire) {
const canAfford = cash >= p.baseManagerCost;
if (elBtnHire.disabled !== !canAfford) {
elBtnHire.disabled = !canAfford;
}
}
// 7. Logistics Button disabled state
const logisticsCost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
const elBtnLogistics = document.getElementById(`btn-logistics-${p.id}`);
if (elBtnLogistics) {
const canAfford = cash >= logisticsCost;
if (elBtnLogistics.disabled !== !canAfford) {
elBtnLogistics.disabled = !canAfford;
}
}
// 8. Selected Card class
const elCard = document.getElementById(`card-${p.id}`);
if (elCard) {
const isSelected = state.selectedProductId === p.id;
elCard.classList.toggle('selected', isSelected);
}
}
}