diff --git a/public/js/game.js b/public/js/game.js
index 045a043..ac01170 100644
--- a/public/js/game.js
+++ b/public/js/game.js
@@ -17,6 +17,7 @@ function switchTab(tab) {
t.setAttribute('aria-selected', isActive);
});
document.getElementById('hunt-tab').style.display = tab === 'hunt' ? '' : 'none';
+ document.getElementById('expedition-tab').style.display = tab === 'expedition' ? '' : 'none';
document.getElementById('inventory-tab').style.display = tab === 'inventory' ? '' : 'none';
}
@@ -593,6 +594,196 @@ async function handleUnequip(slot) {
}
}
+// ===== Expedition UI =====
+let expeditionInterval = null;
+
+function formatTimeRemaining(ms) {
+ if (ms <= 0) return 'Finished';
+ const totalSec = Math.ceil(ms / 1000);
+ const mins = Math.floor(totalSec / 60);
+ const secs = totalSec % 60;
+ if (mins >= 60) {
+ const hours = Math.floor(mins / 60);
+ const remMins = mins % 60;
+ return `${hours}h ${remMins}m`;
+ }
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+function formatDuration(sec) {
+ if (sec < 3600) return `${sec / 60} min`;
+ const hours = sec / 3600;
+ if (hours < 24) return `${hours}h`;
+ return `${hours / 24}d`;
+}
+
+async function loadExpeditions() {
+ try {
+ const data = await api.get('/expeditions/active');
+ if (!data || !data.ok) return;
+
+ const statusEl = document.getElementById('expedition-status');
+ const defsEl = document.getElementById('expedition-defs');
+
+ if (data.data) {
+ renderActiveExpedition(statusEl, defsEl, data.data);
+ } else {
+ statusEl.innerHTML = '';
+ renderExpeditionDefs(defsEl);
+ }
+ } catch (_) {
+ const statusEl = document.getElementById('expedition-status');
+ statusEl.innerHTML = '
Failed to load expedition data.
';
+ }
+}
+
+function renderActiveExpedition(statusEl, defsEl, expeditionData) {
+ defsEl.innerHTML = '';
+
+ const { expedition, definition, timeRemaining, isFinished } = expeditionData;
+ const elapsed = (definition.durationSec * 1000) - timeRemaining;
+ const pct = Math.min(100, Math.max(0, (elapsed / (definition.durationSec * 1000)) * 100));
+
+ let html = `
`;
+ html += `
${definition.name}
`;
+ html += `
${definition.description}
`;
+
+ // Progress bar
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ html += `
${formatTimeRemaining(timeRemaining)}
`;
+ html += `
`;
+
+ if (isFinished) {
+ html += `
`;
+ html += `
Rewards: ${definition.xpReward} XP, ${definition.goldMin}–${definition.goldMax} Gold
`;
+ } else {
+ html += `
Duration: ${formatDuration(definition.durationSec)} · Cost: ${definition.energyCost}⚡
`;
+ }
+
+ html += `
`;
+ statusEl.innerHTML = html;
+
+ // Attach claim handler
+ const claimBtn = statusEl.querySelector('.btn-claim');
+ if (claimBtn) {
+ claimBtn.addEventListener('click', handleClaimExpedition);
+ }
+}
+
+function renderExpeditionDefs(container) {
+ container.innerHTML = '';
+
+ // Fetch expedition definitions from the server
+ api.get('/expeditions/defs').then(defsData => {
+ if (!defsData || !defsData.ok || !defsData.data) return;
+
+ const defs = defsData.data;
+ for (const def of defs) {
+ const card = document.createElement('div');
+ card.className = 'expedition-def-card';
+
+ const nameEl = document.createElement('div');
+ nameEl.className = 'expedition-name';
+ nameEl.textContent = def.name;
+
+ const descEl = document.createElement('div');
+ descEl.className = 'expedition-desc';
+ descEl.textContent = def.description;
+
+ const infoEl = document.createElement('div');
+ infoEl.className = 'expedition-info';
+ infoEl.textContent = `${formatDuration(def.durationSec)} · ${def.energyCost}⚡ · Lv${def.min_level}+`;
+
+ const rewardsEl = document.createElement('div');
+ rewardsEl.className = 'expedition-rewards';
+ rewardsEl.textContent = `+${def.xpReward} XP, ${def.goldMin}–${def.gold_max} Gold`;
+
+ const btn = document.createElement('button');
+ btn.className = 'btn btn-expedition';
+ btn.textContent = `Start Expedition (${def.energyCost}⚡)`;
+ btn.disabled = window.heroLevel < def.min_level;
+ btn.dataset.defId = def.id;
+
+ if (window.heroLevel < def.min_level) {
+ btn.title = `Requires level ${def.min_level}`;
+ }
+
+ card.appendChild(nameEl);
+ card.appendChild(descEl);
+ card.appendChild(infoEl);
+ card.appendChild(rewardsEl);
+ card.appendChild(btn);
+ container.appendChild(card);
+
+ btn.addEventListener('click', handleStartExpedition);
+ }
+ }).catch(() => {
+ container.innerHTML = '
Failed to load expedition options.
';
+ });
+}
+
+async function handleStartExpedition(e) {
+ const btn = e.target;
+ const defId = Number(btn.dataset.defId);
+ if (isNaN(defId)) return;
+
+ btn.disabled = true;
+ btn.textContent = 'Starting...';
+
+ try {
+ const data = await api.post('/expeditions/start', { defId });
+ if (!data || !data.ok) {
+ showToast(data?.error?.message || 'Failed to start expedition.', 'error');
+ return;
+ }
+ showToast('Expedition started!', 'info');
+ loadExpeditions();
+ loadCharacter(); // refresh energy
+ } catch (err) {
+ showToast('Failed to start expedition.', 'error');
+ }
+}
+
+async function handleClaimExpedition(e) {
+ const btn = e.target;
+ const expeditionId = Number(btn.dataset.expeditionId);
+ if (isNaN(expeditionId)) return;
+
+ btn.disabled = true;
+ btn.textContent = 'Claiming...';
+
+ try {
+ const data = await api.post('/expeditions/claim', { expeditionId });
+ if (!data || !data.ok) {
+ showToast(data?.error?.message || 'Failed to claim expedition.', 'error');
+ return;
+ }
+
+ const { xpGained, goldGained } = data.data;
+ showToast(`Expedition complete! +${xpGained} XP, +${goldGained} Gold`, 'info');
+ loadExpeditions();
+ loadCharacter(); // refresh gold/XP
+ } catch (err) {
+ showToast('Failed to claim expedition.', 'error');
+ }
+}
+
+/**
+ * Periodically refresh expedition progress while on the expedition tab.
+ * Updates the progress bar and time remaining every 5 seconds.
+ */
+function startExpeditionSync() {
+ if (expeditionInterval) clearInterval(expeditionInterval);
+ expeditionInterval = setInterval(() => {
+ if (currentTab === 'expedition') {
+ loadExpeditions();
+ }
+ }, 5000);
+}
+
// ===== Toast notifications =====
function showToast(message, type = 'info') {
// Remove existing toasts