Files
Emberfall/public/js/game.js
T

874 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Game dashboard: load character profile, zones, hunt, combat log, and inventory.
let energyInterval = null;
let energyData = null; // store latest server energy data for sync
let isHunting = false;
let currentTab = 'hunt';
let inventoryCache = null;
/**
* Switch active tab in the action panel.
*/
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach((t) => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive);
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';
if (tab === 'expedition') {
loadExpeditions();
startExpeditionSync();
}
}
/**
* Sync mobile bottom nav active state with desktop tabs.
*/
function syncMobileNav() {
const mobileNav = document.querySelector('.mobile-bottom-nav');
if (!mobileNav) return;
mobileNav.querySelectorAll('.tab').forEach((t) => {
const isActive = t.dataset.tab === currentTab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive);
});
}
/**
* Attach mobile bottom-nav click handlers.
*/
function initMobileNav() {
const mobileNav = document.querySelector('.mobile-bottom-nav');
if (!mobileNav) return;
mobileNav.querySelectorAll('.tab').forEach((btn) => {
btn.addEventListener('click', () => {
switchTab(btn.dataset.tab);
});
});
}
document.addEventListener('DOMContentLoaded', () => {
initMobileNav();
});
/**
* Refresh energy state from the server at a fixed interval.
* Keeps client-side countdown in sync with the authoritative server.
*/
function startEnergySync() {
if (energyInterval) clearInterval(energyInterval);
// Sync every 60s (2/3 of a regen tick) to stay in sync with server
energyInterval = setInterval(() => {
loadCharacter();
}, 60000);
}
/**
* Update the energy bar and countdown display.
* @param {number} current - current energy value
* @param {number} max - max energy
* @param {number} nextTickSec - seconds until next +1 energy tick (server-derived)
*/
function updateEnergyBar(current, max, nextTickSec) {
energyData = { current, max, nextTickSec };
// Update text
const energyEl = document.getElementById('char-energy');
if (energyEl) energyEl.textContent = `${current} / ${max}`;
// Update bar fill
const barEl = document.getElementById('energy-bar');
if (barEl) {
const pct = Math.round((current / max) * 100);
barEl.style.width = `${pct}%`;
barEl.title = `${current}/${max} energy`;
}
// Update countdown
const timerEl = document.getElementById('energy-timer');
if (timerEl) {
if (current >= max) {
timerEl.textContent = 'Energy full';
} else {
const mins = Math.floor(nextTickSec / 60);
const secs = nextTickSec % 60;
timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`;
}
}
}
/**
* Start a client-side countdown that decrements until the next energy tick.
* When it reaches zero, refetch from server to get authoritative state.
*/
function startCountdown() {
if (!energyData || energyData.current >= energyData.max || energyData.nextTickSec <= 0) {
return;
}
let remaining = energyData.nextTickSec;
if (energyInterval) clearInterval(energyInterval);
energyInterval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(energyInterval);
energyInterval = null;
loadCharacter();
return;
}
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
const timerEl = document.getElementById('energy-timer');
if (timerEl) {
timerEl.textContent = `Next +1 in ${mins}:${secs.toString().padStart(2, '0')}`;
}
}, 1000);
}
async function loadCharacter() {
try {
const data = await api.get('/character');
if (!data) {
// No character yet — redirect to create-hero
window.location.href = '/create-hero.html';
return;
}
const { character, derived, energy } = data;
window.heroLevel = character.level;
// Show stats, hide loading
document.getElementById('hero-loading').style.display = 'none';
document.getElementById('hero-stats').style.display = 'block';
// Basic info
document.getElementById('player-name').textContent = character.name;
document.getElementById('char-name').textContent = character.name;
document.getElementById('char-class').textContent = character.class.charAt(0).toUpperCase() + character.class.slice(1);
document.getElementById('char-level').textContent = character.level;
document.getElementById('char-hp').textContent = `${character.hp_current} / ${character.maxHp}`;
document.getElementById('char-energy').textContent = `${energy.current} / ${energy.max}`;
document.getElementById('char-gold').textContent = character.gold;
document.getElementById('char-xp').textContent = `${character.xp} / ${xpToNext(character.level)}`;
document.getElementById('char-str').textContent = character.str;
document.getElementById('char-agi').textContent = character.agi;
document.getElementById('char-int').textContent = character.int;
document.getElementById('char-vit').textContent = character.vit;
document.getElementById('derived-atk').textContent = derived.attack;
document.getElementById('derived-def').textContent = derived.defense;
document.getElementById('derived-spd').textContent = derived.speed;
document.getElementById('derived-crit').textContent = derived.critChance;
// Update energy bar and start countdown
updateEnergyBar(energy.current, energy.max, energy.nextTickInSec);
startCountdown();
} catch (err) {
// Not authenticated — redirect to login
window.location.href = '/login.html';
}
}
function xpToNext(level) {
return Math.floor(100 * Math.pow(level, 1.5));
}
// ===== Zone list =====
async function loadZones() {
const zoneList = document.getElementById('zone-list');
try {
const data = await api.get('/zones');
if (!Array.isArray(data)) {
zoneList.innerHTML = '';
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'Failed to load zones.';
zoneList.appendChild(p);
return;
}
const zones = data;
zoneList.innerHTML = '';
for (const zone of zones) {
const card = document.createElement('div');
card.className = 'zone-card';
const zoneName = document.createElement('div');
zoneName.className = 'zone-name';
zoneName.textContent = zone.name;
const zoneDesc = document.createElement('div');
zoneDesc.className = 'zone-desc';
zoneDesc.textContent = zone.description;
const zoneMeta = document.createElement('div');
zoneMeta.className = 'zone-meta';
zoneMeta.textContent = `Lv ${zone.min_level}+ · ${zone.energy_cost}⚡`;
const btn = document.createElement('button');
btn.className = 'btn btn-hunt';
btn.textContent = `Hunt (${zone.energy_cost}⚡)`;
btn.disabled = !zone.unlocked;
btn.dataset.zoneId = zone.id;
if (!zone.unlocked) {
const lockMsg = document.createElement('div');
lockMsg.className = 'zone-locked-msg';
lockMsg.textContent = `Reach level ${zone.min_level}`;
card.appendChild(zoneName);
card.appendChild(lockMsg);
} else {
card.appendChild(zoneName);
card.appendChild(zoneDesc);
card.appendChild(zoneMeta);
card.appendChild(btn);
}
zoneList.appendChild(card);
}
// Attach hunt handlers
zoneList.querySelectorAll('.btn-hunt').forEach((btn) => {
btn.addEventListener('click', handleHunt);
});
} catch (_) {
zoneList.innerHTML = '';
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'Failed to load zones.';
zoneList.appendChild(p);
}
}
// ===== Hunt =====
async function handleHunt(e) {
if (isHunting) return;
const btn = e.target;
const zoneId = Number(btn.dataset.zoneId);
if (isNaN(zoneId)) return;
isHunting = true;
btn.disabled = true;
btn.textContent = 'Hunting...';
// Hide previous result
document.getElementById('result-panel').style.display = 'none';
try {
const data = await api.post('/hunt', { zoneId });
if (!data) {
showToast(data?.error?.message || 'Hunt failed.', 'error');
return;
}
const result = data;
displayHuntResult(result);
loadCharacter();
loadCombatLog();
loadZones(); // refresh energy cost buttons
} catch (err) {
showToast(err.message || 'Hunt failed.', 'error');
} finally {
isHunting = false;
// Re-enable all hunt buttons
document.querySelectorAll('.btn-hunt').forEach((b) => {
b.disabled = false;
// Restore text from zone card labels
const zoneCard = b.closest('.zone-card');
if (zoneCard) {
const meta = zoneCard.querySelector('.zone-meta');
if (meta) {
const cost = meta.textContent.match(/(\d+)⚡/);
if (cost) b.textContent = `Hunt (${cost[1]}⚡)`;
}
}
});
}
}
// ===== Display hunt result =====
function displayHuntResult(result) {
const panel = document.getElementById('result-panel');
const title = document.getElementById('result-title');
const body = document.getElementById('result-body');
panel.style.display = 'block';
const won = result.winner === 'hero';
title.textContent = won ? 'Victory!' : 'Defeat';
title.style.color = won ? '' : '';
body.innerHTML = '';
// Monster info
const monsterLine = document.createElement('p');
monsterLine.textContent = `Fought: ${result.monster.name} (Lv.${result.monster.level})`;
body.appendChild(monsterLine);
// Rewards
const rewards = document.createElement('p');
rewards.textContent = [];
const parts = [];
if (result.xpGained) parts.push(`+${result.xpGained} XP`);
if (result.goldGained) parts.push(`+${result.goldGained} Gold`);
if (parts.length) rewards.textContent = parts.join(', ');
if (result.loot && result.loot.length > 0) {
const lootParts = result.loot.map((l) => `${l.name}${l.quantity > 1 ? `x${l.quantity}` : ''}`);
const lootLine = document.createElement('p');
lootLine.textContent = `Loot: ${lootParts.join(', ')}`;
body.appendChild(lootLine);
}
body.appendChild(rewards);
// Level up
if (result.leveledUp) {
const lvLine = document.createElement('p');
lvLine.style.fontWeight = 'bold';
lvLine.textContent = `⬆ Level up! Now level ${result.newLevel}`;
body.appendChild(lvLine);
}
// HP remaining
const hpLine = document.createElement('p');
hpLine.textContent = `HP: ${result.hpRemaining} / ${result.hpMax}`;
body.appendChild(hpLine);
// Energy remaining
const energyLine = document.createElement('p');
energyLine.textContent = `Energy: ${result.energy}`;
body.appendChild(energyLine);
// Gold lost on defeat
if (result.goldLost > 0) {
const lostLine = document.createElement('p');
lostLine.style.color = '#c0392b';
lostLine.textContent = `Lost ${result.goldLost} gold to defeat.`;
body.appendChild(lostLine);
}
}
// ===== Combat log =====
async function loadCombatLog() {
const logList = document.getElementById('combat-log-list');
try {
const data = await api.get('/combat-log?limit=10');
if (!Array.isArray(data)) {
logList.innerHTML = '';
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'No combat history.';
logList.appendChild(p);
return;
}
const logs = data;
logList.innerHTML = '';
if (logs.length === 0) {
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'No combat history.';
logList.appendChild(p);
return;
}
for (const log of logs) {
const entry = document.createElement('div');
entry.className = 'log-entry';
const resultTag = document.createElement('span');
resultTag.className = `log-result ${log.won ? 'win' : 'loss'}`;
resultTag.textContent = log.won ? '✓' : '✗';
const logText = document.createElement('span');
logText.className = 'log-text';
const parts = [`${log.monsterName} (Lv.${log.monsterLevel})`];
if (log.won) {
if (log.xpGained) parts.push(`+${log.xpGained} XP`);
if (log.goldGained) parts.push(`+${log.goldGained} G`);
} else {
parts.push('defeated');
}
logText.textContent = parts.join(' · ');
entry.appendChild(resultTag);
entry.appendChild(logText);
logList.appendChild(entry);
}
} catch (_) {
logList.innerHTML = '';
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'Failed to load combat log.';
logList.appendChild(p);
}
}
// ===== Item display helpers =====
const ITEM_EMOJI = { weapon: '⚔️', armor: '🛡️', trinket: '💍', consumable: '🧪', material: '📦' };
function formatBonuses(bonuses) {
const parts = [];
if (bonuses.bonus_attack) parts.push(`+${bonuses.bonus_attack} ATK`);
if (bonuses.bonus_defense) parts.push(`+${bonuses.bonus_defense} DEF`);
if (bonuses.bonus_str) parts.push(`+${bonuses.bonus_str} STR`);
if (bonuses.bonus_agi) parts.push(`+${bonuses.bonus_agi} AGI`);
if (bonuses.bonus_int) parts.push(`+${bonuses.bonus_int} INT`);
if (bonuses.bonus_vit) parts.push(`+${bonuses.bonus_vit} VIT`);
return parts.join(', ');
}
function formatEffect(item) {
if (!item.effect) return '';
const match = item.effect.match(/^(heal|energy):(\d+)$/);
if (!match) return '';
const [_full, type, value] = match;
return type === 'heal' ? `Heals ${value} HP` : `Restores ${value} Energy`;
}
// ===== Inventory =====
const EQUIP_SLOTS = ['weapon', 'armor', 'trinket'];
async function loadInventory() {
try {
const data = await api.get('/inventory');
if (!data) return;
inventoryCache = data;
renderEquipmentPanel(data.items);
renderInventoryGrid(data.items);
} catch (_) {
// Character doesn't exist yet or not authenticated — skip.
}
}
function renderEquipmentPanel(items) {
const container = document.getElementById('equipment-slots');
container.innerHTML = '';
for (const slot of EQUIP_SLOTS) {
const slotDiv = document.createElement('div');
slotDiv.className = 'equip-slot';
const label = document.createElement('span');
label.className = 'slot-label';
label.textContent = slot;
const equipped = items.find((i) => i.equipped_slot === slot);
const itemInfo = document.createElement('span');
itemInfo.className = 'slot-item';
if (equipped) {
const nameSpan = document.createElement('span');
nameSpan.className = `item-name rarity-${equipped.rarity}`;
nameSpan.textContent = equipped.name;
itemInfo.appendChild(nameSpan);
const statsText = formatBonuses(equipped.bonuses);
if (statsText) {
const statsSpan = document.createElement('span');
statsSpan.className = 'item-stats';
statsSpan.textContent = statsText;
itemInfo.appendChild(statsSpan);
}
const unequipBtn = document.createElement('button');
unequipBtn.className = 'btn-unequip';
unequipBtn.textContent = '✕';
unequipBtn.title = `Unequip ${equipped.name}`;
unequipBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleUnequip(slot);
});
itemInfo.appendChild(unequipBtn);
} else {
const emptySpan = document.createElement('span');
emptySpan.className = 'slot-empty';
emptySpan.textContent = 'Empty';
itemInfo.appendChild(emptySpan);
}
slotDiv.appendChild(label);
slotDiv.appendChild(itemInfo);
container.appendChild(slotDiv);
}
}
function renderInventoryGrid(items) {
const container = document.getElementById('inventory-grid');
container.innerHTML = '';
const unequipped = items.filter((i) => !i.equipped);
if (unequipped.length === 0) {
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'No items.';
container.appendChild(p);
return;
}
for (const item of unequipped) {
const card = document.createElement('div');
card.className = 'inv-item';
// Tooltip (always shown on hover)
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
const tooltipName = document.createElement('div');
tooltipName.className = `tooltip-name rarity-${item.rarity}`;
tooltipName.textContent = item.name;
tooltip.appendChild(tooltipName);
const tooltipType = document.createElement('div');
tooltipType.className = 'tooltip-type';
tooltipType.textContent = `${item.type}${item.quantity > 1 ? ` (x${item.quantity})` : ''}`;
tooltip.appendChild(tooltipType);
const statsText = formatBonuses(item.bonuses);
if (statsText) {
const tooltipStats = document.createElement('div');
tooltipStats.className = 'tooltip-stats';
tooltipStats.textContent = statsText;
tooltip.appendChild(tooltipStats);
}
const effectText = formatEffect(item);
if (effectText) {
const tooltipEffect = document.createElement('div');
tooltipEffect.className = 'tooltip-effect';
tooltipEffect.textContent = effectText;
tooltip.appendChild(tooltipEffect);
}
if (item.min_level > 1) {
const tooltipLevel = document.createElement('div');
tooltipLevel.className = 'tooltip-level';
tooltipLevel.textContent = `Requires Lv. ${item.min_level}`;
tooltip.appendChild(tooltipLevel);
}
card.appendChild(tooltip);
// Compact card content
const header = document.createElement('div');
header.className = 'inv-item-header';
const icon = document.createElement('span');
icon.className = 'inv-item-icon';
icon.textContent = ITEM_EMOJI[item.type] || '❓';
header.appendChild(icon);
const name = document.createElement('span');
name.className = 'inv-item-name rarity-' + item.rarity;
name.textContent = item.name;
header.appendChild(name);
const rarityBadge = document.createElement('span');
rarityBadge.className = `inv-item-rarity bg-rarity-${item.rarity}`;
rarityBadge.textContent = item.rarity;
header.appendChild(rarityBadge);
card.appendChild(header);
const stats = document.createElement('div');
stats.className = 'inv-item-stats';
stats.textContent = formatBonuses(item.bonuses) || formatEffect(item) || '—';
card.appendChild(stats);
if (item.quantity > 1) {
const qty = document.createElement('span');
qty.className = 'inv-item-qty';
qty.textContent = `x${item.quantity}`;
card.appendChild(qty);
}
card.addEventListener('click', () => handleItemClick(item));
container.appendChild(card);
}
}
async function handleItemClick(item) {
try {
let data;
if (item.type === 'consumable') {
data = await api.post('/inventory/use', { itemId: item.itemId });
} else {
data = await api.post('/inventory/equip', { itemId: item.itemId, slot: item.type });
}
if (!data) {
showToast('Action failed.', 'error');
return;
}
inventoryCache = data;
renderEquipmentPanel(data.items);
renderInventoryGrid(data.items);
loadCharacter();
} catch (err) {
showToast(err.message || 'Action failed.', 'error');
}
}
async function handleUnequip(slot) {
try {
const data = await api.post('/inventory/unequip', { slot });
if (!data) {
showToast('Unequip failed.', 'error');
return;
}
inventoryCache = data;
renderEquipmentPanel(data.items);
renderInventoryGrid(data.items);
loadCharacter();
} catch (err) {
showToast(err.message || 'Unequip failed.', 'error');
}
}
// ===== 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'); // null when no active expedition
const statusEl = document.getElementById('expedition-status');
const defsEl = document.getElementById('expedition-defs');
if (data) {
renderActiveExpedition(statusEl, defsEl, data);
} else {
statusEl.innerHTML = '';
renderExpeditionDefs(defsEl);
}
} catch (_) {
const statusEl = document.getElementById('expedition-status');
statusEl.innerHTML = '<p class="placeholder">Failed to load expedition data.</p>';
}
}
function renderActiveExpedition(statusEl, defsEl, expeditionData) {
defsEl.innerHTML = '';
statusEl.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));
// Card wrapper
const card = document.createElement('div');
card.className = 'expedition-active-card';
// Name
const nameEl = document.createElement('div');
nameEl.className = 'expedition-name';
nameEl.textContent = definition.name;
card.appendChild(nameEl);
// Description
const descEl = document.createElement('div');
descEl.className = 'expedition-desc';
descEl.textContent = definition.description;
card.appendChild(descEl);
// Progress bar
const progressEl = document.createElement('div');
progressEl.className = 'expedition-progress';
const barEl = document.createElement('div');
barEl.className = 'expedition-progress-bar';
const fillEl = document.createElement('div');
fillEl.className = 'expedition-progress-fill';
fillEl.style.width = `${pct}%`;
barEl.appendChild(fillEl);
const textEl = document.createElement('div');
textEl.className = 'expedition-progress-text';
textEl.textContent = formatTimeRemaining(timeRemaining);
progressEl.appendChild(barEl);
progressEl.appendChild(textEl);
card.appendChild(progressEl);
if (isFinished) {
const claimBtn = document.createElement('button');
claimBtn.className = 'btn btn-expedition btn-claim';
claimBtn.dataset.expeditionId = expedition.id;
claimBtn.textContent = 'Claim Rewards';
claimBtn.addEventListener('click', handleClaimExpedition);
card.appendChild(claimBtn);
const rewardsEl = document.createElement('div');
rewardsEl.className = 'expedition-rewards-preview';
rewardsEl.textContent = `Rewards: ${definition.xpReward} XP, ${definition.goldMin}–${definition.goldMax} Gold`;
card.appendChild(rewardsEl);
} else {
const infoEl = document.createElement('div');
infoEl.className = 'expedition-info';
infoEl.textContent = `Duration: ${formatDuration(definition.durationSec)} · Cost: ${definition.energyCost}⚡`;
card.appendChild(infoEl);
}
statusEl.appendChild(card);
}
function renderExpeditionDefs(container) {
container.innerHTML = '';
// Fetch expedition definitions from the server
api.get('/expeditions/defs').then(defsData => {
if (!Array.isArray(defsData)) return;
const defs = defsData;
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.minLevel}+`;
const rewardsEl = document.createElement('div');
rewardsEl.className = 'expedition-rewards';
rewardsEl.textContent = `+${def.xpReward} XP, ${def.goldMin}–${def.goldMax} Gold`;
const btn = document.createElement('button');
btn.className = 'btn btn-expedition';
btn.textContent = `Start Expedition (${def.energyCost}⚡)`;
btn.disabled = window.heroLevel < def.minLevel;
btn.dataset.defId = def.id;
if (window.heroLevel < def.minLevel) {
btn.title = `Requires level ${def.minLevel}`;
}
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 = '<p class="placeholder">Failed to load expedition options.</p>';
});
}
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) {
showToast('Failed to start expedition.', 'error');
return;
}
showToast('Expedition started!', 'info');
loadExpeditions();
loadCharacter(); // refresh energy
} catch (err) {
showToast(err.message || '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) {
showToast('Failed to claim expedition.', 'error');
return;
}
const { xpGained, goldGained } = data;
showToast(`Expedition complete! +${xpGained} XP, +${goldGained} Gold`, 'info');
loadExpeditions();
loadCharacter(); // refresh gold/XP
} catch (err) {
showToast(err.message || '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);
}
// ===== Init =====
document.addEventListener('DOMContentLoaded', async () => {
await loadCharacter();
await loadZones();
await loadCombatLog();
await loadInventory();
startEnergySync();
// Tab click handlers
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
});
// Logout handler
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
e.preventDefault();
try {
await api.post('/auth/logout', {});
} catch (_) {}
window.location.href = '/login.html';
});