/**
* 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 '
';
}
// 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
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 '