/** * The Vanity page, Files & NPCs tab (the Preview tab is vanity-preview.js). * * - Left: the vanity files as the world reads them: root.xml, then the files each one includes (), * any file may include others. A switch per include; files nothing includes are listed apart. Badges say which * scheduled events use a file (as their overlay file, or switching it on or off) and what the worlds load now with * the events that are on. Then the events with vanity changes, and the plaque texts. * - Right: the chosen file's includes and NPCs, edited in place. Nothing is sent until Save, which sends the whole file; * the server checks every NPC the way the world server reads it and keeps the old file as .bak. * - Inputs keep what was typed (strings); numbers are made when saving, so half-typed values don't jump around. * - Rotation is shown as a facing in degrees (a turn about the up axis), with the raw quaternion behind "Advanced". * - An NPC that an event replaces or takes out has a badge linking to the event. * - The hash is #file:NAME or #text:NAME on this tab, #preview on the other. */ (function () { 'use strict'; var RANDOM_KEY = 'useLocationsAsRandomSpawnPoint=7:1'; var DEFAULT_LOT = 2279; var DEFAULT_ZONE = 1200; var ROOT = 'root.xml'; var FILE_NAME = /^[A-Za-z0-9_-]{1,60}\.xml$/; var el = { page: document.getElementById('vanityPage'), folder: document.getElementById('vanityFolder'), files: document.getElementById('vanityFiles'), texts: document.getElementById('vanityTexts'), events: document.getElementById('vanityEvents'), tabFiles: document.getElementById('vanityTabFiles'), main: document.getElementById('vanityMain'), disabled: document.getElementById('vanityDisabled'), rootError: document.getElementById('vanityRootError'), reload: document.getElementById('vanityReload'), newFile: document.getElementById('vanityNewFile'), saveBar: document.getElementById('vanitySaveBar'), saveLabel: document.getElementById('vanitySaveLabel'), save: document.getElementById('vanitySave'), discard: document.getElementById('vanityDiscard') }; var state = { files: [], texts: [], zones: [], zoneNames: {}, events: [], npcEvents: {}, // NPC name -> the events that replace or take it out selected: null, // { kind: 'file' | 'text', name } objects: null, // the chosen file's NPCs as edited (see fromServer) baseline: '', // what the file held when loaded, serialized, to tell whether anything changed includes: null, // the chosen file's entries as edited: [{name, enabled}] includesBaseline: '', loadError: '', itemNames: {}, // lot -> item name open: {}, // NPC id -> card expanded raw: {}, // NPC id -> showing the raw quaternion filter: '', text: null, // { name, where, value, baseline } for a plaque text saving: false, nextId: 1 }; // ---------------------------------------------------------------- model helpers // Floats arrive as doubles (287.6 becomes 287.6000061035156): show them the way they were written function tidy(n) { return n === undefined || n === null ? '' : String(Number(Number(n).toPrecision(7))); } function num(v) { return String(v).trim() === '' ? NaN : Number(v); } function lines(text) { return String(text).split('\n').map(function (l) { return l.trim(); }).filter(Boolean); } function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's'); } function fromServer(o) { var config = (o.config || []).slice(); var random = config.indexOf(RANDOM_KEY) !== -1; return { id: state.nextId++, name: o.name || '', lot: String(o.lot || ''), equipment: (o.equipment || []).slice(), phrases: (o.phrases || []).join('\n'), config: config.filter(function (c) { return c !== RANDOM_KEY; }).join('\n'), random: random, locations: (o.locations || []).map(function (l) { return { zone: String(l.zone), x: tidy(l.x), y: tidy(l.y), z: tidy(l.z), rw: tidy(l.rw), rx: tidy(l.rx), ry: tidy(l.ry), rz: tidy(l.rz), chance: tidy(l.chance), scale: tidy(l.scale) }; }) }; } // What the server takes: {name, lot, equipment, phrases, config, locations} function toServer(o) { var config = lines(o.config).filter(function (c) { return c !== RANDOM_KEY; }); if (o.random) config.unshift(RANDOM_KEY); return { name: o.name.trim(), lot: num(o.lot), equipment: o.equipment.slice(), phrases: lines(o.phrases), config: config, locations: o.locations.map(function (l) { var out = { zone: num(l.zone), x: num(l.x), y: num(l.y), z: num(l.z), rw: num(l.rw), rx: num(l.rx), ry: num(l.ry), rz: num(l.rz) }; if (String(l.chance).trim() !== '') out.chance = num(l.chance); if (String(l.scale).trim() !== '') out.scale = num(l.scale); return out; }) }; } function serialize() { return state.objects ? JSON.stringify(state.objects.map(toServer)) : ''; } function isDirty() { if (!state.selected) return false; if (state.selected.kind === 'text') return !!state.text && state.text.value !== state.text.baseline; return !!state.objects && (serialize() !== state.baseline || includesDirty()); } function includesDirty() { return !!state.includes && JSON.stringify(state.includes) !== state.includesBaseline; } function byId(id) { id = Number(id); for (var i = 0; i < (state.objects || []).length; i++) if (state.objects[i].id === id) return state.objects[i]; return null; } function newLocation(from) { return { zone: from ? from.zone : String(DEFAULT_ZONE), x: '0', y: '0', z: '0', rw: '1', rx: '0', ry: '0', rz: '0', chance: '', scale: '' }; } // ---- rotation: a facing in degrees for turns about the up (Y) axis function yawOnly(l) { return Math.abs(Number(l.rx) || 0) < 1e-4 && Math.abs(Number(l.rz) || 0) < 1e-4; } function facing(l) { var rw = Number(l.rw), ry = Number(l.ry); if (!isFinite(rw) || !isFinite(ry) || !yawOnly(l)) return ''; var deg = 2 * Math.atan2(ry, rw) * 180 / Math.PI; while (deg > 180) deg -= 360; while (deg <= -180) deg += 360; return String(Math.round(deg * 10) / 10); } function setFacing(l, degrees) { var half = Number(degrees) * Math.PI / 360; l.rw = tidy(Math.round(Math.cos(half) * 1e6) / 1e6); l.ry = tidy(Math.round(Math.sin(half) * 1e6) / 1e6); l.rx = '0'; l.rz = '0'; } // ---- checks, the same ones the server makes function fieldError(field, value) { var n = num(value); switch (field) { // Names are optional: the world spawns nameless objects too (demo.xml's plaque and trees) case 'name': return String(value).length > 100 ? 'Up to 100 characters' : ''; case 'lot': return n > 0 && Math.floor(n) === n ? '' : 'A LOT above 0'; case 'zone': return n > 0 ? '' : 'Pick a zone'; case 'x': case 'y': case 'z': case 'rw': case 'rx': case 'ry': case 'rz': return isFinite(n) ? '' : 'A number'; case 'facing': return String(value).trim() === '' || isFinite(n) ? '' : 'A number'; case 'chance': return String(value).trim() === '' || (n >= 0 && n <= 1) ? '' : 'Between 0 and 1'; case 'scale': return String(value).trim() === '' || n > 0 ? '' : 'Above 0'; default: return ''; } } function configError(text) { var bad = lines(text).filter(function (c) { var equals = c.indexOf('='), colon = c.indexOf(':'); return equals <= 0 || colon < equals || !/^\s*[-+]?\d+\s*$/.test(c.slice(equals + 1, colon)); })[0]; return bad ? '"' + bad + '" should look like name=type:value' : ''; } // The first problem with an NPC, or '' function npcError(o) { var who = '"' + (o.name.trim() || 'NPC without a name') + '": '; if (fieldError('name', o.name)) return who + 'the name is too long'; if (fieldError('lot', o.lot)) return who + 'pick a LOT'; var config = configError(o.config); if (config) return who + 'config ' + config; if (!o.locations.length) return who + 'add at least one location'; for (var i = 0; i < o.locations.length; i++) { var l = o.locations[i]; var fields = ['zone', 'x', 'y', 'z', 'rw', 'rx', 'ry', 'rz', 'chance', 'scale']; for (var f = 0; f < fields.length; f++) { var error = fieldError(fields[f], l[fields[f]]); if (error) return who + 'location ' + (i + 1) + ' ' + fields[f] + ': ' + error.toLowerCase(); } } return ''; } // ---- names function itemName(lot) { return state.itemNames[String(lot)] || ''; } function zoneLabel(id) { return id + (state.zoneNames[id] ? ' · ' + state.zoneNames[id] : ''); } function fetchNames(lots) { var missing = lots.map(String).filter(function (lot) { return /^\d+$/.test(lot) && !(lot in state.itemNames); }); if (!missing.length) return Promise.resolve(); return api.get('/api/vanity/names?lots=' + missing.join(',')).then(function (d) { if (!d.success) return; missing.forEach(function (lot) { state.itemNames[lot] = d.itemNames[lot] || ''; }); }); } // ---------------------------------------------------------------- rendering: the side lists // Why a file isn't loaded in game, or '' when it is function notLoadedWhy(f) { if (!f.exists) return 'The file is missing'; if (f.error) return 'It can\'t be read: ' + f.error; if (f.loaded) return ''; var by = f.includedBy || []; if (!by.length) return 'No file includes it'; var loadedParents = by.filter(function (b) { var p = fileInfo(b.file); return p && p.loaded; }); if (loadedParents.length) return 'Switched off in ' + loadedParents.map(function (b) { return b.file; }).join(', '); return 'Only included by files that aren\'t loaded (' + by.map(function (b) { return b.file; }).join(', ') + ')'; } // What the events do with a file: its overlay file, or switching it on or off; filled in while the event is on var USES = { overlay: 'overlay of', on: 'on by', off: 'off by' }; function eventBadges(list, labels) { return (list || []).map(function (u) { return '' + esc((labels[u.use] || u.use) + ' ' + u.name) + ' '; }).join(''); } // What the worlds load now differs from what the files say: an event that is on switched it function nowBadge(f) { if (!f.exists || f.loadedNow === f.loaded || f.root) return ''; return '' + (f.loadedNow ? 'on now' : 'off now') + ' '; } // In the narrow file list: one badge, the events in its tooltip function compactEventBadge(list) { if (!list || !list.length) return ''; var title = list.map(function (u) { return (USES[u.use] || u.use) + ' ' + u.name + (u.on ? ' (on now)' : ''); }).join('; '); return '' + list.length + (list.length === 1 ? ' event' : ' events') + ' '; } function fileBadges(f, compact) { if (!f) return 'missing'; var why = notLoadedWhy(f); return (compact ? compactEventBadge(f.events) : eventBadges(f.events, USES)) + nowBadge(f) + (f.error ? 'error ' : '') + (!f.exists ? 'missing' : f.loaded ? 'loaded' : 'not loaded'); } // One row of the tree; `edge` is the entry in `parent` that brings it in (none for root.xml) function fileRow(name, parent, edge, depth, again) { var f = fileInfo(name); var active = state.selected && state.selected.kind === 'file' && state.selected.name === name; var exists = f && f.exists; var label = '' + esc(name) + '' + '' + (exists && !f.error ? plural(f.npcs, 'NPC') + ' ' : '') + fileBadges(f, true) + (again ? ' (see above)' : '') + ''; var why = f ? notLoadedWhy(f) : 'The file is missing'; return '
' + (exists ? '' + label + '' : '
' + label + '
') + (parent ? '
' + '
' : '') + '
'; } // root.xml, then what each file includes, depth first. A file included twice is shown under each parent, but its // own includes only the first time; a file including one of its own ancestors stops there. function treeRows() { var rows = [], shown = {}; (function walk(name, parent, edge, depth, path) { var again = !!shown[name]; rows.push(fileRow(name, parent, edge, depth, again && (fileInfo(name) || {}).includes && fileInfo(name).includes.length)); if (again || path.indexOf(name) !== -1) return; shown[name] = true; var f = fileInfo(name); (f && f.includes || []).forEach(function (inc) { if (path.indexOf(inc.name) !== -1 || inc.name === name) { rows.push('
' + esc(inc.name) + ' · loops back to a file above; the world loads each file once
'); return; } walk(inc.name, name, inc, depth + 1, path.concat(name)); }); })(ROOT, null, null, 0, []); return { html: rows.join(''), shown: shown }; } function renderSide() { var tree = treeRows(); var loose = state.files.filter(function (f) { return !f.root && !tree.shown[f.name] && f.exists; }); el.files.innerHTML = tree.html + (loose.length ? '
Not included
' + loose.map(function (f) { var active = state.selected && state.selected.kind === 'file' && state.selected.name === f.name; return ''; }).join('') : ''); el.events.innerHTML = state.events.map(function (e) { return '' + '' + esc(e.name) + '' + esc(e.on ? (e.nextEnd ? 'on until ' + fmt.unix(e.nextEnd) : 'on') : e.nextStart ? 'next ' + fmt.unix(e.nextStart) : e.modeName) + ' · priority ' + esc(e.priority) + '' + (e.on ? 'on' : 'off') + ''; }).join('') || '
No event changes the vanity NPCs. Add one.
'; el.texts.innerHTML = state.texts.map(function (t) { var active = state.selected && state.selected.kind === 'text' && state.selected.name === t.name; return '' + '
' + esc(t.name) + '
' + esc(t.where) + '
'; }).join(''); } // ---------------------------------------------------------------- rendering: the chosen file function zoneOptions(selected) { var known = state.zones.some(function (z) { return String(z.id) === String(selected); }); return (known || !selected ? '' : '') + state.zones.map(function (z) { return ''; }).join(''); } function numberField(field, label, value, extra) { var error = fieldError(field, value); return ''; } function locationHtml(o, l, i) { var raw = state.raw[o.id] || !yawOnly(l); return '
' + '' + numberField('x', 'X', l.x) + numberField('y', 'Y', l.y) + numberField('z', 'Z', l.z) + numberField('facing', 'Facing (°)', facing(l), ' step="1"' + (yawOnly(l) ? '' : ' placeholder="tilted" title="Tilted, not just turned: edit the quaternion"')) + (raw ? numberField('rw', 'rw', l.rw) + numberField('rx', 'rx', l.rx) + numberField('ry', 'ry', l.ry) + numberField('rz', 'rz', l.rz) : '') + numberField('chance', 'Chance', l.chance, ' min="0" max="1" placeholder="always" title="0 to 1: how likely the NPC appears here"') + numberField('scale', 'Scale', l.scale, ' min="0" placeholder="normal"') + '
'; } function summary(o) { var zones = []; o.locations.forEach(function (l) { if (zones.indexOf(l.zone) === -1) zones.push(l.zone); }); var name = itemName(o.lot); return 'LOT ' + esc(o.lot || '?') + (name ? ' · ' + esc(name) : '') + ' · ' + plural(o.locations.length, 'location') + (zones.length ? ' in ' + esc(zones.map(zoneLabel).join(', ')) : ''); } function headHtml(o) { var open = !!state.open[o.id], error = npcError(o); return '' + '' + eventBadges(state.npcEvents[o.name.trim()], { replace: 'replaced by', remove: 'taken out by' }) + (o.random ? 'one random location' : '') + (error ? 'needs fixing' : '') + ''; } function equipmentHtml(o) { return '
' + (o.equipment.length ? o.equipment.map(function (lot, i) { var name = itemName(lot); return '' + esc(lot) + (name ? ' · ' + esc(name) : '') + ''; }).join('') : 'Nothing equipped') + '
' + '
' + '
'; } function bodyHtml(o) { var id = 'npc' + o.id; var phraseCount = lines(o.phrases).length; return '
' + '
' + '
' + '
' + '
' + '
' + '' + esc(itemName(o.lot) || (o.lot ? 'no item name' : '')) + '
' + '
' + '
Equipment
' + equipmentHtml(o) + '
' + '
' + '
' + '
' + '' + '
' + esc(configError(o.config)) + '
' + '
Written name=type:value, e.g. custom_script_client=0:scripts\\ai\\SPEC\\MISSION_MINIGAME_CLIENT.lua. Types: 0 text, 1 whole number, 3 decimal, 5 unsigned, 7 true/false (1 or 0), 13 UTF-8 text.
' + '
' + '
Locations
' + '
' + '
' + '
' + '
' + '
' + (o.random ? '

Each time the world starts, the NPC appears at one of these, picked at random, instead of at all of them.

' : '') + '
' + o.locations.map(function (l, i) { return locationHtml(o, l, i); }).join('') + '
' + '' + '
Use /pos and /rot in game to read where you stand. Facing 0 looks along +Z.
' + '
' + ''; } function cardHtml(o) { return '
' + '
' + headHtml(o) + '
' + (state.open[o.id] ? bodyHtml(o) : '') + '
'; } function matches(o) { if (!state.filter) return true; var text = [o.name, o.lot, itemName(o.lot), o.phrases, o.config].concat(o.locations.map(function (l) { return zoneLabel(l.zone); })).join(' ').toLowerCase(); return text.indexOf(state.filter) !== -1; } function statusLine(name) { var f = fileInfo(name); if (!f) return ''; if (f.root) return 'The file the worlds start from. It can hold NPCs too.'; // An event that is on now loads it differently from what the files say if (f.exists && f.loadedNow !== f.loaded) { return (f.loadedNow ? 'Loaded in game now: switched on by ' : 'Not loaded in game now: switched off by ') + (f.switchedNow || 'an event') + ', which is on (' + (f.loaded ? 'loaded' : 'not loaded') + ' without events)'; } return f.loaded ? 'Loaded in game' : 'Not loaded in game: ' + notLoadedWhy(f).replace(/^./, function (c) { return c.toLowerCase(); }); } function includesHtml() { var name = state.selected.name; var rows = state.includes.map(function (inc, i) { var f = fileInfo(inc.name), id = 'inc-' + i; return '
' + '
' + '
' + (f && f.exists ? '' + (f.error ? '' : plural(f.npcs, 'NPC')) + '' : 'missing') + '
'; }).join(''); var options = state.files.filter(function (f) { return !f.root && f.name !== name && f.exists && !state.includes.some(function (inc) { return inc.name === f.name; }); }).map(function (f) { return ''; }).join(''); document.getElementById('newFileName').value = ''; newFileModal.show(); } function createFile(e) { e.preventDefault(); var name = document.getElementById('newFileName').value.trim(), parent = document.getElementById('newFileParent').value; if (state.selected && state.selected.name === parent && includesDirty()) { toast('Save or discard your changes to ' + parent + '\'s includes first', 'warning'); return; } if (isDirty() && !confirm('Discard your unsaved changes to ' + state.selected.name + '?')) return; api.action('/api/vanity/files', { name: name, parent: parent }).then(function (d) { toast(d.message, 'success'); newFileModal.hide(); return loadList().then(function () { select('file', d.name, true); }); }).catch(function () {}); } function addInclude() { var input = el.main.querySelector('[data-include-input]'); var name = input.value.trim(); if (name && !/\.xml$/i.test(name)) name += '.xml'; if (!FILE_NAME.test(name)) { toast('Use letters, digits, - and _ for the file name', 'warning'); input.focus(); return; } if (name === ROOT || name === state.selected.name) { toast(name + ' can\'t be included here', 'warning'); input.focus(); return; } if (state.includes.some(function (inc) { return inc.name === name; })) { toast(name + ' is already included', 'info'); return; } state.includes.push({ name: name, enabled: true }); renderIncludes(); var again = el.main.querySelector('[data-include-input]'); if (again) again.focus(); } function deleteFile() { var name = state.selected.name; var by = (fileInfo(name) || {}).includedBy || []; if (!confirm('Delete ' + name + '?' + (by.length ? ' It is taken out of ' + by.map(function (b) { return b.file; }).join(', ') + '.' : '') + ' A copy is kept as ' + name + '.bak.')) return; api.action('/api/vanity/files/' + encodeURIComponent(name) + '/delete', {}).then(function (d) { toast(d.message, 'success'); state.selected = null; return loadList().then(function () { setHash(''); selectDefault(); }); }).catch(function () {}); } function respawn() { el.reload.disabled = true; api.action('/api/vanity/reload', {}, null).then(function (d) { if (d.result && d.result.then) return d.result; toast('Respawn requested', 'info'); }).catch(function () {}).then(function () { el.reload.disabled = false; }); } // ---------------------------------------------------------------- events el.files.addEventListener('click', function (e) { var link = e.target.closest('[data-select-file]'); if (!link) return; e.preventDefault(); if (!state.selected || state.selected.kind !== 'file' || state.selected.name !== link.dataset.selectFile) select('file', link.dataset.selectFile); }); el.files.addEventListener('change', function (e) { if (e.target.matches('[data-enable]')) setInclude(e.target.dataset.enable, e.target.dataset.parent, e.target.checked, e.target); }); el.files.addEventListener('click', function (e) { var include = e.target.closest('[data-include-root]'); if (include) setInclude(include.dataset.includeRoot, ROOT, false, null); }); el.texts.addEventListener('click', function (e) { var link = e.target.closest('[data-select-text]'); if (!link) return; e.preventDefault(); if (!state.selected || state.selected.kind !== 'text' || state.selected.name !== link.dataset.selectText) select('text', link.dataset.selectText); }); el.main.addEventListener('input', function (e) { if (e.target.id === 'npcFilter') { state.filter = e.target.value.trim().toLowerCase(); (state.objects || []).forEach(function (o) { var card = el.main.querySelector('[data-npc="' + o.id + '"]'); if (card) card.classList.toggle('d-none', !matches(o)); }); return; } if (e.target.matches('[data-text]')) { state.text.value = e.target.value; renderSaveBar(); return; } if (!e.target.matches('[data-random], [data-action="raw"]')) onCardInput(e.target, false); }); el.main.addEventListener('change', function (e) { if (e.target.matches('[data-inc-toggle]')) { state.includes[Number(e.target.closest('[data-inc]').dataset.inc)].enabled = e.target.checked; renderSaveBar(); return; } if (e.target.matches('[data-action="raw"]')) { onCardAction(e.target); return; } if (e.target.matches('select, [data-random], [data-field="lot"]')) onCardInput(e.target, true); }); el.main.addEventListener('click', function (e) { var button = e.target.closest('button[data-action]'); if (button) onCardAction(button); }); el.main.addEventListener('keydown', function (e) { if (e.key === 'Enter' && e.target.matches('[data-include-input]')) { e.preventDefault(); addInclude(); return; } if (e.key === 'Enter' && e.target.matches('[data-equip-input]')) { e.preventDefault(); addEquipment(byId(e.target.closest('[data-npc]').dataset.npc), e.target.closest('[data-npc]')); } }); // The tabs keep their place in the hash el.tabFiles.addEventListener('shown.bs.tab', function () { setHash(state.selected ? '#' + state.selected.kind + ':' + state.selected.name : ''); }); document.getElementById('vanityTabPreview').addEventListener('shown.bs.tab', function () { if (location.hash.indexOf('#preview') !== 0) setHash('#preview'); }); if (window.Live) Live.on('scheduled_events', Live.throttle(function () { loadList().then(refreshFileHead); }, 1000)); var newFileModal = new bootstrap.Modal(document.getElementById('newFileModal')); el.newFile.addEventListener('click', newFile); document.getElementById('newFileForm').addEventListener('submit', createFile); el.reload.addEventListener('click', respawn); el.save.addEventListener('click', save); el.discard.addEventListener('click', function () { if (confirm('Throw away your unsaved changes?')) discard(); }); document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && e.key === 's' && isDirty() && filesTab()) { e.preventDefault(); save(); } }); window.addEventListener('beforeunload', function (e) { if (isDirty()) { e.preventDefault(); e.returnValue = ''; } }); loadList().then(selectDefault); })();