diff --git a/frontend/css/style.css b/frontend/css/style.css
index 2702ca4..3d40ce8 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -201,6 +201,111 @@ html, body {
transition: width 0.3s ease;
}
+/* Server Sync Countdown Bar */
+#sync-container {
+ background: #0b0e12;
+ padding: 3px 14px 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ border-top: 1px solid rgba(36, 45, 54, 0.4);
+}
+
+.sync-label-row {
+ display: flex;
+ justify-content: space-between;
+ font-family: var(--font-mono);
+ font-size: 0.62rem;
+ color: var(--text-secondary);
+}
+
+.sync-status-text {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ letter-spacing: 0.5px;
+}
+
+.sync-pulse-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--neon-cyan);
+ box-shadow: 0 0 6px var(--neon-cyan);
+ animation: pulse 1.5s infinite;
+}
+
+.sync-bar-track {
+ width: 100%;
+ height: 4px;
+ background: #151b22;
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+#sync-bar-fill {
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, var(--neon-cyan) 0%, #3b82f6 100%);
+ transition: width 0.1s linear;
+}
+
+/* Quick Stash Overview Strip (Sticky Under Sync Bar) */
+#quick-stash-strip {
+ background: #080b0e;
+ border-top: 1px solid rgba(36, 45, 54, 0.4);
+ padding: 6px 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+#quick-stash-strip::-webkit-scrollbar {
+ display: none;
+}
+
+.stash-strip-title {
+ font-family: var(--font-mono);
+ font-size: 0.62rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ letter-spacing: 0.5px;
+ font-weight: 700;
+}
+
+.stash-strip-items {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.stash-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: #111720;
+ border: 1px solid #232e3a;
+ border-radius: 6px;
+ padding: 2px 7px;
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ color: var(--text-primary);
+}
+
+.stash-chip-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.stash-chip-val {
+ font-weight: 700;
+}
+
/* Main Viewport Content */
#main-viewport {
flex: 1;
diff --git a/frontend/index.html b/frontend/index.html
index 8cc3ece..3a84979 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -61,6 +61,29 @@
+
+
+
+
+
+ NEXT SERVER TICK
+
+ 6.0s
+
+
+
+
+
+
+
+ STASH OVERVIEW:
+
+
+
+
+
diff --git a/frontend/js/game.js b/frontend/js/game.js
index 2463668..392a74c 100644
--- a/frontend/js/game.js
+++ b/frontend/js/game.js
@@ -68,7 +68,10 @@ const dom = {
authModal: document.getElementById('auth-modal'),
raidModal: document.getElementById('raid-modal'),
raidBribeCost: document.getElementById('raid-bribe-cost'),
- toastContainer: document.getElementById('toast-container')
+ toastContainer: document.getElementById('toast-container'),
+ syncTimer: document.getElementById('sync-timer-text'),
+ syncBar: document.getElementById('sync-bar-fill'),
+ quickStash: document.getElementById('quick-stash-items')
};
// --- MULTIPLIERS & BONUSES ---
@@ -219,22 +222,22 @@ function renderLabView() {
-
-
+
Hire ${d.workerName}
$${workerCost.toLocaleString()}
@@ -291,11 +294,11 @@ function renderStreetsView() {
-
+
Hire ${d.dealerName}
$${dealerCost.toLocaleString()}
-
+
Sell 10x Stash
+$${(unitPrice * Math.min(10, Math.floor(data.product))).toLocaleString()}
@@ -720,6 +723,24 @@ function clientTick(deltaSec) {
// Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML)
updateLiveInventory();
+ updateSyncCountdown();
+}
+
+const TICK_INTERVAL_MS = 6000;
+let lastSyncTimestamp = Date.now();
+
+function updateSyncCountdown() {
+ const elapsed = Date.now() - lastSyncTimestamp;
+ const remainingMs = Math.max(0, TICK_INTERVAL_MS - elapsed);
+ const remainingSec = (remainingMs / 1000).toFixed(1);
+ const pct = Math.min(100, Math.max(0, (remainingMs / TICK_INTERVAL_MS) * 100));
+
+ if (dom.syncTimer) {
+ dom.syncTimer.textContent = `${remainingSec}s`;
+ }
+ if (dom.syncBar) {
+ dom.syncBar.style.width = `${pct}%`;
+ }
}
function updateLiveInventory() {
@@ -730,27 +751,69 @@ function updateLiveInventory() {
dom.heatPct.textContent = `${Math.round(G.heat)}%`;
dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`;
- // Surgically update raw and product text fields if they exist in DOM
+ const rawCostMulti = getRawCostMultiplier();
+ let stashChipsHtml = '';
+
+ // Surgically update raw, product, buttons, and stash chips
DRUG_CATALOG.forEach(d => {
+ const drugData = G.drugs[d.id];
+ if (!drugData) return;
+
+ // Populate quick stash overview for owned/unlocked products
+ if (drugData.unlocked || drugData.product > 0 || drugData.raw > 0) {
+ stashChipsHtml += `
+
+
+ ${d.name.split(' ')[0]}:
+ ${fmtNum(drugData.product)}g
+
+ `;
+ }
+
+ // Live text update for raw & product
const rawEl = document.getElementById(`val-raw-${d.id}`);
- if (rawEl && G.drugs[d.id]) {
- rawEl.textContent = fmtNum(G.drugs[d.id].raw);
- }
+ if (rawEl) rawEl.textContent = fmtNum(drugData.raw);
+
const prodEl = document.getElementById(`val-prod-${d.id}`);
- if (prodEl && G.drugs[d.id]) {
- prodEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
- }
+ if (prodEl) prodEl.textContent = `${fmtNum(drugData.product)}g`;
+
const streetProdEl = document.getElementById(`val-prod-street-${d.id}`);
- if (streetProdEl && G.drugs[d.id]) {
- streetProdEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
- }
+ if (streetProdEl) streetProdEl.textContent = `${fmtNum(drugData.product)}g`;
+
+ // Live button state updates (enable/disable as resources change)
+ const rawCost = Math.floor(d.rawCost * rawCostMulti);
+ const workerCost = getWorkerCost(d.id);
+ const dealerCost = getDealerCost(d.id);
+
+ const btnBuyRaw = document.getElementById(`btn-buy-raw-${d.id}`);
+ if (btnBuyRaw) btnBuyRaw.disabled = (G.cash < rawCost);
+
+ const btnMake = document.getElementById(`btn-make-${d.id}`);
+ if (btnMake) btnMake.disabled = (drugData.raw < 1);
+
+ const btnSell = document.getElementById(`btn-sell-${d.id}`);
+ if (btnSell) btnSell.disabled = (drugData.product < 1);
+
+ const btnHireWorker = document.getElementById(`btn-hire-worker-${d.id}`);
+ if (btnHireWorker) btnHireWorker.disabled = (G.cash < workerCost);
+
+ const btnHireDealer = document.getElementById(`btn-hire-dealer-${d.id}`);
+ if (btnHireDealer) btnHireDealer.disabled = (G.cash < dealerCost);
+
+ const btnSell10x = document.getElementById(`btn-sell-10x-${d.id}`);
+ if (btnSell10x) btnSell10x.disabled = (drugData.product < 10);
});
+
+ if (dom.quickStash) {
+ dom.quickStash.innerHTML = stashChipsHtml || 'No active stash';
+ }
}
// --- AUTHORITATIVE SERVER TICK (Every 6 seconds) ---
async function authoritativeServerTick() {
if (isSyncing) return;
isSyncing = true;
+ lastSyncTimestamp = Date.now();
// Grab queued actions and reset local queue
const actionsToSend = [...pendingActions];
@@ -788,7 +851,7 @@ function gameLoop(now) {
}
// Server tick: exactly every 6 seconds to prevent flooding server
-setInterval(authoritativeServerTick, 6000);
+setInterval(authoritativeServerTick, TICK_INTERVAL_MS);
// Clock updates
setInterval(() => {