/** * Character page: the editor (coins, U-score, level, items) and the history (snapshots compared with the character * now, download, restore). Saving goes through the server's safe path: the owner is disconnected first and the * current version is kept as a snapshot. */ (function () { 'use strict'; var config = window.characterToolsConfig; if (!config) return; var nf = new Intl.NumberFormat(); var current = null; var edit = null; // { counts: {id: n}, remove: {id: true}, add: [{lot, name, count, inventory}] } var inventoryTab = 0; function inventoryName(type) { return Labels.name('inventories', type) || 'Inventory ' + type; } function itemLabel(item) { return esc(item.name || 'LOT ' + item.lot) + ' ' + esc(item.lot) + ''; } function loadSummary() { return api.get('/api/characters/' + config.id + '/summary').then(function (d) { if (!d.success) { toast(d.error || 'Could not read the character', 'danger'); return null; } current = d.summary; return current; }); } // ---- editor ---- function renderEditItems() { var inventory = current.inventories.filter(function (i) { return i.type === inventoryTab; })[0] || { items: [], size: 0 }; document.getElementById('editInventoryTabs').innerHTML = current.inventories.filter(function (i) { return i.items.length || i.type === 0; }).map(function (i) { return ''; }).join(''); var rows = inventory.items.slice().sort(function (a, b) { return a.slot - b.slot; }).map(function (item) { var removed = !!edit.remove[item.id]; var count = edit.counts[item.id] !== undefined ? edit.counts[item.id] : item.count; return '' + itemLabel(item) + (item.equipped ? ' ' + fmt.badge('equipped', 'info') : '') + '' + '' + esc(item.slot) + '' + '' + ''; }); document.getElementById('editItems').innerHTML = rows.join('') || 'Empty'; document.getElementById('pendingAdds').innerHTML = edit.add.map(function (a, i) { return '
  • Add ' + esc(a.count) + 'x ' + esc(a.name) + ' to ' + esc(inventoryName(a.inventory)) + ' undo
  • '; }).join(''); describeChanges(); } function buildEdit() { var body = {}; var coins = document.getElementById('editCoins').value, uscore = document.getElementById('editUScore').value, level = document.getElementById('editLevel').value; if (coins !== '' && Number(coins) !== current.coins) body.coins = Number(coins); if (uscore !== '' && Number(uscore) !== current.uscore) body.uscore = Number(uscore); if (level !== '' && Number(level) !== current.level) body.level = Number(level); var counts = {}; Object.keys(edit.counts).forEach(function (id) { if (!edit.remove[id]) counts[id] = edit.counts[id]; }); if (Object.keys(counts).length) body.counts = counts; var remove = Object.keys(edit.remove).filter(function (id) { return edit.remove[id]; }); if (remove.length) body.remove = remove; if (edit.add.length) body.add = edit.add.map(function (a) { return { lot: a.lot, count: a.count, inventory: a.inventory }; }); return body; } function describeChanges() { var body = buildEdit(), parts = []; ['coins', 'uscore', 'level'].forEach(function (k) { if (body[k] !== undefined) parts.push(k === 'uscore' ? 'U-score' : k); }); if (body.counts) parts.push(Object.keys(body.counts).length + ' count(s)'); if (body.remove) parts.push(body.remove.length + ' removed'); if (body.add) parts.push(body.add.length + ' added'); document.getElementById('editChanges').textContent = parts.length ? 'Changes: ' + parts.join(', ') : 'No changes yet'; document.getElementById('saveEditBtn').disabled = !parts.length; } function openEditor() { loadSummary().then(function (summary) { if (!summary) return; edit = { counts: {}, remove: {}, add: [] }; inventoryTab = 0; document.getElementById('editCoins').value = summary.coins; document.getElementById('editUScore').value = summary.uscore; document.getElementById('editLevel').value = summary.level; renderEditItems(); bootstrap.Modal.getOrCreateInstance(document.getElementById('editModal')).show(); }); } var suggestions = {}; var searchTimer = null; function wireEditor() { var button = document.getElementById('editCharacterBtn'); if (!button) return; button.addEventListener('click', openEditor); ['editCoins', 'editUScore', 'editLevel'].forEach(function (id) { document.getElementById(id).addEventListener('input', describeChanges); }); document.getElementById('editInventoryTabs').addEventListener('click', function (e) { var tab = e.target.closest('[data-inventory]'); if (tab) { inventoryTab = Number(tab.dataset.inventory); renderEditItems(); } }); document.getElementById('editItems').addEventListener('input', function (e) { if (e.target.dataset.count) { edit.counts[e.target.dataset.count] = Number(e.target.value); describeChanges(); } }); document.getElementById('editItems').addEventListener('change', function (e) { if (e.target.dataset.remove) { edit.remove[e.target.dataset.remove] = e.target.checked; renderEditItems(); } }); document.getElementById('pendingAdds').addEventListener('click', function (e) { var undo = e.target.closest('[data-undo-add]'); if (undo) { e.preventDefault(); edit.add.splice(Number(undo.dataset.undoAdd), 1); renderEditItems(); } }); var input = document.getElementById('addItemName'); input.addEventListener('input', function () { clearTimeout(searchTimer); var q = input.value.trim(); if (q.length < 2 || /^\d+$/.test(q)) return; searchTimer = setTimeout(function () { api.get('/api/items/search?q=' + encodeURIComponent(q)).then(function (items) { suggestions = {}; document.getElementById('addItemList').innerHTML = (Array.isArray(items) ? items : []).map(function (i) { suggestions[i.name] = i.lot; return ''; }).join(''); }); }, 250); }); document.getElementById('addItemBtn').addEventListener('click', function () { var text = input.value.trim(); var lot = /^\d+$/.test(text) ? Number(text) : suggestions[text]; var count = Number(document.getElementById('addItemCount').value) || 1; if (!lot) { toast('Pick an item from the list or type its LOT', 'warning'); return; } edit.add.push({ lot: lot, name: /^\d+$/.test(text) ? 'LOT ' + lot : text, count: count, inventory: inventoryTab }); input.value = ''; renderEditItems(); }); document.getElementById('saveEditBtn').addEventListener('click', function () { var body = buildEdit(); if (!confirm('Save these changes? The player is disconnected if online.')) return; api.action('/api/characters/' + config.id + '/edit', body).then(function (d) { return d.result; }).then(function (r) { if (r && r.success) { bootstrap.Modal.getInstance(document.getElementById('editModal')).hide(); setTimeout(function () { location.reload(); }, 600); } }).catch(function () {}); }); } // ---- history ---- function itemsById(summary) { var map = {}; summary.inventories.forEach(function (inventory) { inventory.items.forEach(function (item) { map[item.id] = { item: item, inventory: inventory.type }; }); }); return map; } function compare(before, after) { var rows = []; [['coins', 'Coins'], ['uscore', 'U-score'], ['level', 'Level'], ['missions_done', 'Missions done']].forEach(function (f) { if (before[f[0]] !== after[f[0]]) rows.push('' + f[1] + '' + nf.format(before[f[0]]) + '' + nf.format(after[f[0]]) + ''); }); var a = itemsById(before), b = itemsById(after); Object.keys(a).forEach(function (id) { var old = a[id], now = b[id]; if (!now) rows.push('' + itemLabel(old.item) + ' (' + esc(inventoryName(old.inventory)) + ')' + nf.format(old.item.count) + 'gone'); else if (now.item.count !== old.item.count || now.inventory !== old.inventory) { rows.push('' + itemLabel(old.item) + '' + nf.format(old.item.count) + ' in ' + esc(inventoryName(old.inventory)) + '' + nf.format(now.item.count) + ' in ' + esc(inventoryName(now.inventory)) + ''); } }); Object.keys(b).forEach(function (id) { if (!a[id]) rows.push('' + itemLabel(b[id].item) + ' (' + esc(inventoryName(b[id].inventory)) + ')not there' + nf.format(b[id].item.count) + ''); }); return rows; } function openSnapshot(id) { document.querySelectorAll('#snapshotList [data-snapshot]').forEach(function (el) { el.classList.toggle('active', el.dataset.snapshot === String(id)); }); var box = document.getElementById('snapshotCompare'); box.innerHTML = '

    Loading…

    '; Promise.all([api.get('/api/snapshots/' + id), loadSummary()]).then(function (res) { var snap = res[0]; if (!snap.success || !current) { box.innerHTML = '

    ' + esc(snap.error || 'Could not load it') + '

    '; return; } var rows = compare(snap.summary, current); box.innerHTML = '
    ' + esc(fmt.unix(snap.taken_at)) + ' → now' + 'Download XML' + (config.canEdit ? '' : '') + '
    ' + (rows.length ? '
    ' + rows.join('') + '
    WhatThenNow
    ' : '

    No differences in coins, level or items.

    '); }); } function openHistory() { bootstrap.Modal.getOrCreateInstance(document.getElementById('historyModal')).show(); api.get('/api/characters/' + config.id + '/snapshots').then(function (d) { var list = document.getElementById('snapshotList'); if (!d.success) { list.innerHTML = '
    ' + esc(d.error) + '
    '; return; } list.innerHTML = d.snapshots.map(function (s) { return '
    ' + esc(fmt.unix(s.taken_at)) + '
    ' + '
    ' + esc(s.reason) + (s.actor ? ' by ' + esc(s.actor) : '') + '
    '; }).join('') || '
    No snapshots yet. One is kept before every change made here, and the Character snapshots task saves changed characters daily.
    '; }); } function wireHistory() { var button = document.getElementById('historyBtn'); if (!button) return; button.addEventListener('click', openHistory); document.getElementById('snapshotList').addEventListener('click', function (e) { var item = e.target.closest('[data-snapshot]'); if (item) { e.preventDefault(); openSnapshot(item.dataset.snapshot); } }); document.getElementById('snapshotCompare').addEventListener('click', function (e) { var restore = e.target.closest('[data-restore]'); if (!restore || !confirm('Put the character back to this version? The player is disconnected if online, and the current version is kept in the history.')) return; api.action('/api/snapshots/' + restore.dataset.restore + '/restore', {}).then(function (d) { return d.result; }).then(function (r) { if (r && r.success) setTimeout(function () { location.reload(); }, 600); }).catch(function () {}); }); } wireEditor(); wireHistory(); })();