';
}).join('');
}
function loadViews() {
return api.get('/api/reports/views').then(function (d) {
if (!d.success) return;
views = d.views; emailReady = d.emailReady;
renderViews();
});
}
function saveView(view) {
return api.action('/api/reports/views', view).then(function (d) { toast(d.message, 'success'); views = d.views; renderViews(); });
}
var saveModal = new bootstrap.Modal(document.getElementById('saveViewModal'));
var manageModal = new bootstrap.Modal(document.getElementById('manageViewsModal'));
document.getElementById('viewsMenu').addEventListener('click', function (e) {
var item = e.target.closest('[data-view]');
if (item) { e.preventDefault(); applyView(views[Number(item.dataset.view)]); return; }
if (e.target.closest('#saveViewBtn')) {
e.preventDefault();
var draft = { days: state.days, staff: state.staff, lot: state.lot, tab: currentTab(), lot_name: state.lotName };
document.getElementById('saveViewSummary').textContent = 'Shows: ' + describeView(draft) + '. The range stays relative, so it always covers the latest days.';
document.getElementById('viewName').value = '';
document.getElementById('viewEmail').disabled = !emailReady;
document.getElementById('viewEmailHelp').textContent = emailReady ? 'Reports cover the range up to yesterday and are sent around 07:00 UTC.'
: 'To get it by email, confirm an email address on your account page (and the server needs email set up).';
saveModal.show();
}
if (e.target.closest('#manageViewsBtn')) { e.preventDefault(); manageModal.show(); }
});
document.getElementById('saveViewForm').addEventListener('submit', function (e) {
e.preventDefault();
saveView({ name: document.getElementById('viewName').value.trim(), days: state.days, staff: state.staff, lot: state.lot, lot_name: state.lotName,
tab: currentTab(), email: document.getElementById('viewEmail').value }).then(function () { saveModal.hide(); }).catch(function () {});
});
document.getElementById('viewRows').addEventListener('change', function (e) {
var select = e.target.closest('[data-email]');
if (select) saveView($.extend({}, views[Number(select.dataset.email)], { email: select.value })).catch(function () {});
});
document.getElementById('viewRows').addEventListener('click', function (e) {
var send = e.target.closest('[data-send]'), remove = e.target.closest('[data-remove]');
if (send) api.action('/api/reports/views/send', { name: views[Number(send.dataset.send)].name }).then(function (d) { toast(d.message, 'success'); }).catch(function () {});
if (remove && confirm('Delete the view "' + views[Number(remove.dataset.remove)].name + '"?')) {
api.action('/api/reports/views/delete', { name: views[Number(remove.dataset.remove)].name }).then(function (d) { toast(d.message, 'success'); views = d.views; renderViews(); }).catch(function () {});
}
});
// ---- Transfers ----
// Names come from the server's enums (Labels); only the colours are kept here
var METHOD_COLOURS = { 1: 'info', 2: 'secondary', 3: 'success', 4: 'light' };
function methodBadge(value) { return fmt.badge(Labels.name('transferMethods', value) || '?', METHOD_COLOURS[value] || 'secondary'); }
var transfersTable = null;
function traceLink(id) {
if (!id || id === '0') return '-';
return '' + esc(id) + '';
}
// Character names for the filter's suggestions, loaded the first time it's used
var characterNamesLoaded = false;
document.getElementById('transferCharacter').addEventListener('focus', function () {
if (characterNamesLoaded) return;
characterNamesLoaded = true;
api.get('/api/characters/list').then(function (list) {
var datalist = document.getElementById('characterNames');
(Array.isArray(list) ? list : []).forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.name;
datalist.appendChild(opt);
});
});
});
function initTransfers() {
if (transfersTable) return;
transfersTable = serverTable('#transfersTable', '/api/reports/transfers', [
{ data: 'time', orderable: false, render: function (d) { return esc(fmt.unix(d)); } },
{ data: 'method', orderable: false, render: function (d) { return methodBadge(d); } },
{ data: 'lot', orderable: false, render: function (d, t, row) { return d ? itemLink(d, row.name) : '' + esc(GameText.term('coins')) + ''; } },
{ data: 'count', orderable: false, className: 'text-end', render: function (d) { return d ? num(d) : ''; } },
{ data: 'coins', orderable: false, className: 'text-end', render: function (d) { return d ? num(d) : ''; } },
{ data: 'from_character', orderable: false, render: function (d, t, row) { return charLink(d, row.from_name); } },
{ data: 'to_character', orderable: false, render: function (d, t, row) { return charLink(d, row.to_name); } },
{ data: 'item_id', orderable: false, render: function (d) { return traceLink(d); } }
], {
dataTable: { searching: false, ordering: false },
liveTable: 'economy',
extra: function () {
return {
character: document.getElementById('transferCharacter').value.trim(),
lot: parseInt(document.getElementById('transferLot').value, 10) || 0
};
}
});
$('#transfersTable').on('xhr.dt', function (e, settings, json) { if (json && json.error) toast(json.error, 'warning'); });
}
// ---- Trace ----
// An item gets a new object ID at every trade, mail and inventory move; the server follows the chain from any of them
var traceMerges = false;
function shortId(id) { return '' + esc(id) + ''; }
function whereText(l) { return l.where === 'mail' ? 'Mailbox (mail ' + l.mail_id + ')' : String(l.inventory || '').toLowerCase().replace(/_/g, ' '); }
// A copy's character version ( in its save) and whether its next login gives it a new object ID: saves from
// before item IDs were made unique get new IDs for every item at login
function versionBadge(l) {
if (l.where === 'mail' || l.character_version === undefined) return '';
return l.new_id_at_login
? ' old save v' + esc(l.character_version) + ': new ID at next login'
: ' v' + esc(l.character_version) + '';
}
// Whose login clears a shared ID (login_fix from the server): "Alice", "Alice or Bob", "all but one of ..."
function loginNames(fix) {
var names = (fix.characters || []).map(function (c) { return charLink(c.character_id, c.character_name); });
var anyButOne = fix.logins_needed < names.length;
if (names.length <= 1) return names.join('');
if (names.length === 2) return names[0] + (anyButOne ? ' or ' : ' and ') + names[1];
return (anyButOne ? 'all but one of ' : 'all of ') + names.join(', ');
}
// kind: 'collision' (different items: the login fixes it) or 'duplicate' (same item: the login only hides it)
function loginFixCell(fix, kind) {
if (!fix) return '';
if (!fix.resolves) return kind === 'collision'
? 'Needs a look'
: 'Needs a look';
return kind === 'collision'
? 'Resolves on next login
at the next login of ' + loginNames(fix) + '
'
: 'IDs split on next login' +
'
at the next login of ' + loginNames(fix) + '; the copies stay, so check it first
';
}
var GAP_TEXT = {
OWNER: 'Starts with someone the previous hop did not give it to: something in between was not recorded.',
MAIL_NOT_SENT: 'Claimed from mail that was sent before sending was recorded (or those rows were pruned).',
NO_NEW_ID: 'The new object ID was not recorded, so the trace cannot follow it further.'
};
function trace(id, merges) {
id = String(id || '').trim();
if (!/^\d+$/.test(id)) { toast('Enter a numeric object ID', 'warning'); return; }
traceMerges = !!merges;
document.getElementById('traceId').value = id;
bootstrap.Tab.getOrCreateInstance(document.getElementById('traceTabBtn')).show();
var out = document.getElementById('traceResult');
out.innerHTML = '
';
return null;
}).then(function (r) {
if (!r) return;
var history = r.history || [], ids = r.ids || [], latest = r.latest_ids || [];
var lineHops = history.filter(function (h) { return h.role !== 'MERGE_IN'; });
var title = r.lot ? esc(r.name || 'LOT ' + r.lot) + ' LOT ' + esc(r.lot) + '' : 'Unknown item';
var status = !r.locations.length ? fmt.badge('Not found in any inventory or mail', 'secondary')
: r.duplicated ? 'Duplicated: the same item is under one ID in more than one place'
: 'Found';
if (r.collision) status += ' ' +
'ID collision: a different item shares an ID';
if (r.resolves_on_login) status += ' Resolves on next login';
var facts = [
'Searched ' + shortId(r.item_id),
'First recorded ID ' + shortId(r.first_id),
'Latest ' + (latest.length ? latest.map(shortId).join(', ') : '-'),
num(lineHops.length) + ' hop' + (lineHops.length === 1 ? '' : 's') + ', ' + num(ids.length) + ' ID' + (ids.length === 1 ? '' : 's')
];
var html = '
' + title + '
' + status + '
' +
'
' + facts.join('') + '
' +
'
' +
'
';
// What the chain can't tell: shown above the timeline so it isn't missed
var notes = [];
if (r.truncated) notes.push('Stopped after ' + num(r.max_hops) + ' hops; trace from one of the later IDs to see the rest.');
if (r.gaps) notes.push(num(r.gaps) + ' hop' + (r.gaps === 1 ? ' has' : 's have') + ' a gap before it (marked below): the chain was not recorded in full there.');
if (history.length) {
notes.push('Before ' + esc(fmt.unix(history[0].time)) + ' nothing is recorded for ' + shortId(r.first_id) +
': it was looted, bought or made, or changed hands before trades and mail were recorded. Moves between a player\'s own inventories are recorded since the ledger started tracking them.');
} else {
notes.push('No trades, mail or inventory moves are recorded for this ID. It may never have changed hands, or it did before these records started.');
}
var gone = latest.filter(function (l) { var entry = ids.find(function (e) { return e.id === l; }); return entry && entry.checked && !entry.locations.length; });
if (gone.length) notes.push('Latest ID ' + gone.map(shortId).join(', ') + ' is not in any saved inventory or unclaimed mail: sold, used, deleted, merged or moved before that was recorded, or in an online player\'s unsaved inventory.');
html += '
' + notes.map(function (n) { return '
' + n + '
'; }).join('') + '
';
html += '
Where it is now
';
html += '
Timeline
Oldest first. Every hop gives the item a new object ID; click an ID to trace from it.
' +
'
';
html += '
Its IDs
';
// Give it back under its latest ID when there is just one, so its history carries on
var restoreId = latest.length === 1 ? latest[0] : r.item_id;
if (DASH.can('items_restore') && r.lot) {
// Suggest the player who last gave it away (usually the one asking for it back), or its last holder
var last = lineHops.length ? lineHops[lineHops.length - 1] : null;
var suggestion = last ? (r.locations.length ? last.from_name : last.to_name) || '' : (r.locations[0] ? r.locations[0].character_name : '');
var restoreHere = ids.find(function (e) { return e.id === restoreId; });
var restoreExists = restoreHere ? restoreHere.locations.length > 0 : r.locations.length > 0;
html += '
Give it back
' +
'
Mails the item (ID ' + esc(restoreId) + ') to a player. ' + (restoreExists
? 'It still exists, so they get a replacement with a new ID.' : 'It no longer exists anywhere, so it keeps that ID and its history carries on.') + '
' +
'
';
}
out.innerHTML = html;
document.getElementById('traceMerges').addEventListener('change', function (e) { trace(r.item_id, e.target.checked); });
var restoreForm = document.getElementById('restoreForm');
if (restoreForm) restoreForm.addEventListener('submit', function (e) {
e.preventDefault();
var to = document.getElementById('restoreTo').value.trim();
if (!confirm('Mail ' + (r.name || 'this item') + ' to ' + to + '?')) return;
api.job('/api/reports/objects/' + restoreId + '/restore', { to: to, lot: r.lot, count: parseInt(document.getElementById('restoreCount').value, 10) || 1,
note: document.getElementById('restoreNote').value.trim() }).then(function (res) { toast(res.message, 'success'); }).catch(function () {});
});
table(document.getElementById('traceLocations'), [{ label: 'Item' }, { label: 'Character' }, { label: 'Where' }, { label: 'Count', num: true }, { label: 'Under ID' }],
r.locations.map(function (l) {
var other = r.lot && l.lot !== r.lot ? ' ' + fmt.badge('different item: ID collision', 'warning') : '';
return [itemLink(l.lot, l.name) + other, charLink(l.character_id, l.character_name) + versionBadge(l), esc(whereText(l)), num(l.count),
shortId(l.item_id) + (latest.indexOf(l.item_id) !== -1 ? ' ' + fmt.badge('latest', 'primary') : '')];
}), 'Not under any of its IDs in a saved inventory or unclaimed mail.');
var el = document.getElementById('traceHistory');
if (!history.length) {
el.innerHTML = '
Nothing recorded.
';
} else {
el.innerHTML = '
Time
How
From
To
Zone
' +
'
Count
' + esc(GameText.term('coins')) + '
Old ID → new ID
' +
history.map(function (h) {
var marks = [];
if (h.role === 'BRANCH') marks.push('other part');
if (h.role === 'MERGE_IN') marks.push('joined the stack');
if (h.merge) marks.push('merged' + (h.merge_proven ? '*' : '') + '');
var gap = h.gap ? '
Gap: ' + esc(GAP_TEXT[h.gap] || h.gap) + '
' : '';
var muted = h.role === 'LINE' ? '' : ' class="text-body-secondary"';
return gap + '
';
drawMap(null);
}
function loadMapData() {
if (!map.zone) { showNoMapData(); return Promise.resolve(); }
var pending = document.querySelector('#mapBox .viz-empty');
if (pending) pending.textContent = 'Loading…';
var q = query('&kind=' + map.kind + (map.lot ? '&lot=' + map.lot : '') + (map.clone !== null ? '&clone=' + map.clone : ''));
return Promise.all([api.get('/api/reports/map/' + map.zone + q), loadTerrain(map.zone)]).then(function (res) {
map.data = res[0];
var lotName = '';
(map.data.lots || []).forEach(function (l) { if (map.lot && l.lot === map.lot) lotName = l.name || 'LOT ' + l.lot; });
var info = map.data.property && map.data.property.info;
var where = info ? info.name : (map.data.property ? (map.data.name || 'Zone ' + map.zone) + ' · all properties' : map.data.name || 'Zone ' + map.zone);
document.getElementById('mapTitle').textContent = where + (lotName ? ' · ' + lotName : '');
showMapProperty();
var note = 'Scroll to zoom, drag to move, hover for counts. Events are recorded in ' + map.data.cellSize + '×' + map.data.cellSize +
' world unit squares and merged into larger ones until you zoom in.';
if (!res[1]) note += ' No terrain for this zone (client_location unset or no .raw file), so only the recorded area is shown.';
if (map.data.property && map.clone === null) note += ' Every property of this zone is drawn together: they are different builds on the same ground, so pick one property to see its own events.';
if (map.data.property && map.clone !== null) note += ' The dashed line is where the owner may build; blue squares are placed models.';
document.getElementById('mapNote').textContent = note;
mapSideTables();
return renderMap();
});
}
function loadMapZones() {
return api.get('/api/reports/map/zones' + query('&kind=' + map.kind)).then(function (res) {
map.zones = res.zones || [];
// Zones with players online are worth picking even before anything was recorded there
map.players.forEach(function (p) {
if (!map.zones.some(function (z) { return z.zone === p.zone; })) map.zones.push({ zone: p.zone, name: 'Zone ' + p.zone + ' (players online)', events: 0 });
});
var select = document.getElementById('mapZone');
if (!map.zones.some(function (z) { return z.zone === map.zone; })) {
// A property picked from a link keeps its zone even with nothing of this kind recorded there
if (map.zone && map.clone !== null) map.zones.push({ zone: map.zone, name: 'Zone ' + map.zone, events: 0, property: true, properties: [] });
else { map.zone = map.zones.length ? map.zones[0].zone : 0; map.clone = null; }
}
var all = res.allProperties || {};
function option(z) {
return '';
}
var worlds = map.zones.filter(function (z) { return !z.property; }), props = map.zones.filter(function (z) { return z.property; });
select.innerHTML = map.zones.length ? worlds.map(option).join('') +
(props.length ? '' : '')
: '';
renderMapProperties();
return loadMapData();
});
}
// On a property zone: all its properties together, or one of them (each is its own instance of the zone)
function renderMapProperties() {
var select = document.getElementById('mapProperty');
var zone = map.zones.filter(function (z) { return z.zone === map.zone; })[0];
select.hidden = !(zone && zone.property);
if (select.hidden) { map.clone = null; return; }
var properties = (zone.properties || []).slice();
if (map.clone !== null && !properties.some(function (p) { return p.clone === map.clone; })) {
properties.push({ clone: map.clone, name: map.cloneName || 'Clone ' + map.clone, events: 0 });
}
select.innerHTML = '' + properties.map(function (p) {
return '';
}).join('');
}
// The property card beside the map: who it belongs to, links, and the place's totals
function showMapProperty() {
var card = document.getElementById('mapPropertyCard');
var property = map.data && map.data.property;
card.hidden = !property;
if (!property) return;
var body = document.getElementById('mapPropertyBody');
var info = property.info;
if (!info) {
var zone = map.zones.filter(function (z) { return z.zone === map.zone; })[0];
document.getElementById('mapPropertyTitle').textContent = 'All properties here';
body.innerHTML = '
' + esc(num(zone && zone.properties ? zone.properties.filter(function (p) { return !p.unknown; }).length : 0)) +
' properties with ' + esc(currentKind().name.toLowerCase()) + ' in this range, drawn together. Pick one above to see its own map.
' +
'Activity for these properties';
return;
}
document.getElementById('mapPropertyTitle').textContent = info.unknown ? 'Unknown property' : 'Property';
if (info.unknown) {
body.innerHTML = '
These events were recorded before the game told properties apart, so which property they happened on is not known. They are kept here rather than mixed into a property.
' + (t.visitors.length ? t.visitors.slice(0, 50).map(function (v) {
return charLink(v.character_id, v.name) + ' ' + esc(fmt.unix(v.last)) + '';
}).join(' ') : 'Nobody in the movement history (kept a few days).');
} else if (t.visitorsHidden) {
html += '
Who was here needs the movement history permission.
';
}
el.innerHTML = html;
}).catch(function () {});
}
// Open the World Map on one place: '' or ':'
function openMapPlace(place) {
var parts = String(place).split(':');
map.zone = parseInt(parts[0], 10) || 0;
map.clone = parts.length > 1 ? parseInt(parts[1], 10) || 0 : null;
map.lot = 0;
map.view = null;
// Once the map has loaded, showing the tab only redraws, so load the new place here
if (mapLoaded) loadMapZones();
bootstrap.Tab.getOrCreateInstance(document.getElementById('mapTabBtn')).show();
}
document.getElementById('mapPropertyBody').addEventListener('click', function (e) {
var link = e.target.closest('[data-activity-place]');
if (!link) return;
e.preventDefault();
activity.place = link.getAttribute('data-activity-place');
activity.loaded = true;
loadActivity();
bootstrap.Tab.getOrCreateInstance(document.getElementById('activityTabBtn')).show();
});
// A property zone's build areas from the map data, as [{outline: [[x, y, z]]}]
function propertyAreas(data) {
var areas = data && data.property && data.property.areas;
return Array.isArray(areas) ? areas.filter(function (a) { return a && Array.isArray(a.outline); }) : [];
}
var mapCanvas = document.getElementById('mapCanvas');
var drag = null;
mapCanvas.addEventListener('mousemove', function (evt) {
var r = mapCanvas.getBoundingClientRect();
var x = evt.clientX - r.left, y = evt.clientY - r.top;
if (drag) {
var dx = (x - drag.x) / r.width * (drag.view.maxX - drag.view.minX), dz = (y - drag.y) / r.height * (drag.view.maxZ - drag.view.minZ);
// Screen right is lower x, so dragging right shows higher x
map.view = clampView({ minX: drag.view.minX + dx, maxX: drag.view.maxX + dx, minZ: drag.view.minZ + dz, maxZ: drag.view.maxZ + dz });
drawMap(map.backdropImage);
return;
}
var hit = null;
(map.hit || []).forEach(function (h) { if (x >= h.x - 2 && x <= h.x + h.s + 2 && y >= h.y - 2 && y <= h.y + h.s + 2) hit = h; });
if (hit && hit.model) {
showTip(evt, '