/**
* The Scheduled Events page.
*
* - The events, with what each switches on (its parts, and what each part last did), when it is on and next turns on
* and off, and a mode switch (off, by its schedule, always on). Then a month calendar with the events and the things
* scheduled on their own pages, the event_N settings, and the events that change the same vanity NPCs or files.
* - The editor: name, mode, priority; once (two times) or by rules (every year, once, days of the week, times of day,
* moon phases, groups; or JSON, see dCommon/ScheduleRules.h), which the server checks and lists as you type; and the
* parts, one editor per kind. Kinds the user may not add are shown but can't be picked.
* - #event:ID opens that event, #new a new one, #new:vanity a new one with a vanity part (the Vanity page links here).
*/
(function () {
'use strict';
var DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
var PHASES = [['new_moon', 'New moon'], ['first_quarter', 'First quarter'], ['full_moon', 'Full moon'], ['last_quarter', 'Last quarter']];
var RULE_LABELS = { yearly: 'Every year', dates: 'Once', weekdays: 'Days of the week', time_of_day: 'Times of day', moon: 'Moon phase', group: 'Group' };
var KIND_LABELS = { feature: 'Game feature', vanity: 'Vanity changes', live_event: 'Live event', announcement: 'Announcement', restart: 'Restart' };
var LIVE_TYPES = [['treasure_hunt', 'Treasure hunt'], ['bonus', 'Bonus'], ['invasion', 'Invasion'], ['celebration', 'Celebration']];
// Colours by state name, so the numbers stay the server's
var COLOURS = { 'Scheduled': 'info', 'Active': 'success', 'Ended': 'secondary', 'Cancelled': 'secondary', 'Missed': 'warning' };
var data = { events: [], features: [], slots: [], states: [], modes: [], kinds: [], zones: [], vanity: null };
var editing = null; // the event being edited, or null for a new one
var rules = []; // the schedule's rules as edited (the JSON format)
var parts = []; // the parts as edited: [{kind, config, applied, status}]
var pickers = null; // vanity files and NPC names, fetched when a vanity part is edited
var recentLive = null; // recent live events to copy from
var previewTimer = null;
var cal = { shown: new Date(), range: '', list: [] };
cal.shown.setDate(1);
var modal = new bootstrap.Modal(document.getElementById('evModal'));
var importModal = new bootstrap.Modal(document.getElementById('evImportModal'));
var canShutDown = DASH.can('worlds_manage');
function get(id) { return document.getElementById(id); }
function byId(id) { return data.events.filter(function (e) { return String(e.id) === String(id); })[0]; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function lines(text) { return String(text).split('\n').map(function (l) { return l.trim(); }).filter(Boolean); }
function allowed(kind) { var k = data.kinds.filter(function (x) { return x.name === kind; })[0]; return !!(k && k.allowed); }
function permissionOf(kind) { return (data.kinds.filter(function (x) { return x.name === kind; })[0] || {}).permission || ''; }
function colour(e) { return COLOURS[e.stateName] || 'secondary'; }
function isOver(e) { return e.once && (e.stateName === 'Ended' || e.stateName === 'Missed' || e.stateName === 'Cancelled'); }
function feature(name) { return data.features.filter(function (f) { return f.name === name; })[0]; }
// Whether the user may change the event: every kind of part it has needs its permission
function mayChange(e) { return e.parts.every(function (p) { return allowed(p.kind); }); }
function offsetLabel(minutes) {
if (!minutes) return 'UTC';
var sign = minutes < 0 ? '-' : '+', abs = Math.abs(minutes);
return 'UTC' + sign + pad(Math.floor(abs / 60)) + ':' + pad(abs % 60);
}
// The browser's offset now, to the quarter hour (a new schedule starts in it)
function browserOffset() { return Math.round(-new Date().getTimezoneOffset() / 15) * 15; }
function toInput(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
function fromInput(value) { return value ? Math.floor(new Date(value).getTime() / 1000) : 0; }
// ---------------------------------------------------------------- describing schedules and parts
function monthDay(text) {
var p = String(text || '').split('-');
var d = new Date(2000, Number(p[0]) - 1, Number(p[1]));
return isNaN(d) ? text : d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function describeRule(r) {
var text;
switch (r.type) {
case 'yearly': text = 'every year ' + monthDay(r.from) + ' to ' + monthDay(r.to); break;
case 'dates': text = String(r.from).replace('T', ' ') + ' to ' + String(r.to).replace('T', ' '); break;
case 'weekdays': text = (r.days || []).map(function (d) { return d.charAt(0).toUpperCase() + d.slice(1); }).join(', '); break;
case 'time_of_day': text = r.from + ' to ' + r.to; break;
case 'moon': {
var phase = (PHASES.filter(function (p) { return p[0] === r.phase; })[0] || [r.phase, r.phase])[1].toLowerCase();
text = r.hours ? phase + ' ±' + r.hours + 'h' : phase + (r.days ? ' day ±' + r.days + 'd' : ' day');
break;
}
case 'group': text = '(' + describeRules(r.rules || [], r.match) + ')'; break;
default: text = r.type;
}
return (r.not ? 'not ' : '') + text;
}
function describeRules(list, match) { return list.map(describeRule).join(match === 'all' ? ' and ' : ' or '); }
// Always with the rules' time zone: a UTC day is two days in most browsers
function describe(schedule) {
if (!schedule) return '';
return describeRules(schedule.rules || [], schedule.match) + ' (' + offsetLabel(schedule.utcOffset || 0) + ')';
}
function partSummary(p) {
var c = p.config || {};
switch (p.kind) {
case 'feature': return esc(c.feature || '?') + (p.state && p.state.slot ? ' ' + esc('event_' + p.state.slot) + '' : '');
case 'vanity': {
var bits = [];
Object.keys(c.fileSwitches || {}).forEach(function (f) { bits.push(esc(f) + (c.fileSwitches[f] ? ' on' : ' off')); });
if (c.file) bits.push('overlay ' + esc(c.file));
var removals = Array.isArray(c.removals) ? c.removals : lines(c.removals || '');
if (removals.length) bits.push('takes out ' + esc(removals.join(', ')));
return bits.join('; ');
}
case 'live_event': return esc((LIVE_TYPES.filter(function (t) { return t[0] === c.type; })[0] || ['', c.type])[1]) + (c.title ? ' “' + esc(c.title) + '”' : '');
case 'announcement': return (c.atStart !== false ? 'at the start' : '') + (c.repeat ? (c.atStart !== false ? ', ' : '') + 'repeats ' + esc(c.repeat) + '' : '') +
(c.endMessage ? ', at the end' : '');
case 'restart': return 'when it ' + (c.when === 'start' ? 'starts' : 'ends') + ', ' + esc(c.minutes) + ' min warning';
}
return '';
}
function partBadges(e) {
if (e.partsError) return '
' + esc(v.value) + ' (' + esc(v.source === 'web' ? 'Settings page' : v.source === 'env' ? 'environment' : v.file) + ')';
}).join(', ');
var owner = s.event ? byId(s.event) : null;
return '' + esc(s.setting) + ': ' + (values || 'free') +
(owner ? ' ' + fmt.badge(owner.name, 'success') : '') + 'Add at least one rule.
'; } function builderSchedule() { return { utcOffset: Number(get('evOffset').value) || 0, match: get('evMatch').value, rules: rules }; } function repeating() { return get('evRepeat').checked; } // The schedule as edited (null for once), or undefined (after a toast) when the JSON can't be read function currentSchedule(quiet) { if (!repeating()) return null; if (!get('evJsonMode').checked) return builderSchedule(); try { return JSON.parse(get('evJson').value); } catch (e) { if (!quiet) toast('The schedule isn\'t valid JSON: ' + e.message, 'danger'); return undefined; } } function offsetInfo() { var offset = Number(get('evOffset').value) || 0, mine = browserOffset(); get('evOffsetInfo').textContent = 'Dates, days and times in the rules are ' + offsetLabel(offset) + ' (a fixed offset: no daylight saving)' + (offset === mine ? ', the same as your browser now.' : '; your browser is ' + offsetLabel(mine) + ', so a day in the rules is ' + offsetLabel(offset) + ' midnight to midnight.'); } function loadBuilder(schedule) { schedule = schedule || { utcOffset: browserOffset(), match: 'any', rules: [] }; get('evOffset').value = String(schedule.utcOffset || 0); get('evMatch').value = schedule.match === 'all' ? 'all' : 'any'; rules = (schedule.rules || []).slice(); renderRules(); offsetInfo(); } function schedulePreview() { clearTimeout(previewTimer); var out = get('evSchedulePreview'); if (!repeating()) { var s = fromInput(get('evStarts').value), e = fromInput(get('evEnds').value); out.innerHTML = s && e ? (e <= s ? 'The end has to be after the start.' : 'On from ' + esc(fmt.unix(s)) + ' to ' + esc(fmt.unix(e)) + ' (your time zone).') : ''; return; } previewTimer = setTimeout(function () { var schedule = currentSchedule(true); if (!schedule) { out.innerHTML = 'Not valid JSON'; return; } api.post('/api/events/check', { schedule: schedule, count: 6 }).then(function (d) { if (!d.success) return; if (!d.valid) { out.innerHTML = '' + esc(d.error) + ''; return; } out.innerHTML = 'event_1–event_8 setting while the event is on; players get it at their next login.Loading the vanity files…
'; } var c = p.config, switches = c.fileSwitches || {}; var fileOptions = function (selected) { return '' + vanityFiles().map(function (f) { return ''; }).join('') + (selected && !vanityFiles().some(function (f) { return f.name === selected; }) ? '' : ''); }; var removals = Array.isArray(c.removals) ? c.removals : lines(c.removals || ''); return 'Needs ' + esc(permissionOf(p.kind)) + ' to change: ' + partSummary(p) + '
') + 'Add what the event switches on.
') + ''; attachSearches(); } // Object and effect pickers of live event parts, as on the Live Events page function attachSearches() { Array.prototype.forEach.call(get('evParts').querySelectorAll('[data-search]'), function (input) { var kind = input.dataset.search, i = Number(input.dataset.p), path = input.dataset.f; var value = input.dataset.value, label = input.value; SearchSelect(input, { value: value, label: label, search: function (q) { if (kind === 'effect') return api.get('/api/live-events/effects?q=' + encodeURIComponent(q)).then(function (d) { return (d.effects || []).map(function (e) { return { value: e.id + '|' + e.type, label: e.id + ' ' + e.type, detail: e.name }; }); }); return api.get('/api/live-events/objects?kind=' + kind + '&q=' + encodeURIComponent(q)).then(function (d) { return (d.objects || []).map(function (o) { return { value: String(o.lot), label: o.name + ' (LOT ' + o.lot + ')', detail: o.type }; }); }); }, onPick: function (item) { var c = parts[i].config.config; if (kind === 'effect') { var bits = item.value.split('|'); c.effectId = Number(bits[0]); c.effectType = bits.slice(1).join('|'); return; } if (input.hasAttribute('data-add')) { if ((c.lots || []).indexOf(Number(item.value)) === -1) (c.lots = c.lots || []).push(Number(item.value)); renderParts(); return; } c[path.split('.')[1]] = Number(item.value); } }); }); } function loadPickers() { if (pickers || loadPickers.busy || !DASH.can('vanity_manage')) return; loadPickers.busy = true; api.get('/api/vanity/pickers').then(function (d) { loadPickers.busy = false; if (!d.success) return; pickers = d; renderParts(); }); } function loadRecentLive() { if (recentLive || !DASH.can('live_events_manage')) return; recentLive = []; api.get('/api/live-events').then(function (d) { if (!d.success) return; recentLive = d.events.slice(0, 20); if (parts.some(function (p) { return p.kind === 'live_event'; })) renderParts(); }); } function setPath(obj, path, value) { var keys = path.split('.'), target = obj; for (var k = 0; k < keys.length - 1; k++) target = target[keys[k]] = target[keys[k]] || {}; target[keys[keys.length - 1]] = value; } function partChanged(e) { var t = e.target, i = Number(t.dataset.p); if (isNaN(i) || !parts[i]) return; var p = parts[i]; if (t.dataset.f && !t.hasAttribute('data-search')) { var value = t.type === 'checkbox' ? t.checked : t.type === 'number' ? (t.value === '' ? '' : Number(t.value)) : t.hasAttribute('data-zones') ? Array.prototype.map.call(t.selectedOptions, function (o) { return Number(o.value); }) : t.hasAttribute('data-lines') ? lines(t.value) : t.value; setPath(p.config, t.dataset.f, value); if (t.hasAttribute('data-live-type')) { p.config.config = liveDefaults(t.value); renderParts(); } else if (t.hasAttribute('data-rerender') && e.type === 'change') renderParts(); return; } if (t.dataset.switchFile !== undefined && e.type === 'change') { var switches = p.config.fileSwitches, old = t.dataset.switchFile, on = switches[old]; delete switches[old]; if (t.value) switches[t.value] = on; renderParts(); } else if (t.dataset.switchOn !== undefined) { p.config.fileSwitches[t.dataset.switchOn] = t.value === '1'; } else if (t.hasAttribute('data-removal-pick') && t.value) { var list = Array.isArray(p.config.removals) ? p.config.removals : lines(p.config.removals || ''); if (list.indexOf(t.value) === -1) list.push(t.value); p.config.removals = list; renderParts(); } else if (t.hasAttribute('data-live-copy') && t.value !== '') { var from = recentLive[Number(t.value)]; p.config = { type: from.type, title: from.title, message: from.message, zones: from.zones.slice(), instance: from.instanceId, config: JSON.parse(JSON.stringify(from.config)) }; renderParts(); } } // ---------------------------------------------------------------- the editor function open(e, day) { editing = e || null; var may = !e || mayChange(e); get('evModalTitle').textContent = e ? (may ? 'Change ' : '') + e.name : 'New event'; get('evName').value = e ? e.name : ''; get('evNote').value = e ? e.note : ''; get('evPriority').value = e ? e.priority : 0; get('evMode').innerHTML = data.modes.map(function (m) { return ''; }).join(''); get('evMode').value = String(e ? e.mode : 1); var start = day ? Math.floor(day.getTime() / 1000) : Math.ceil(Date.now() / 3600000) * 3600; get('evStarts').value = toInput(e && e.once ? e.startsAt : start); get('evEnds').value = toInput(e && e.once ? e.endsAt : start + 86400); get('evJsonMode').checked = false; get('evJson').classList.add('d-none'); get('evBuilder').classList.remove('d-none'); loadBuilder(e && e.schedule ? e.schedule : { utcOffset: browserOffset(), match: 'any', rules: [newRule('yearly')] }); parts = e ? e.parts.map(function (p) { return { kind: p.kind, config: JSON.parse(JSON.stringify(p.config)), applied: p.applied, status: p.status }; }) : []; renderParts(); setWhen(!!(e && !e.once)); get('evForm').querySelector('[type=submit]').disabled = !may; modal.show(); } function eventBody(e) { return { name: e.name, note: e.note, mode: e.mode, schedule: e.once ? null : e.schedule, startsAt: e.once ? e.startsAt : 0, endsAt: e.once ? e.endsAt : 0, priority: e.priority, parts: e.parts.map(function (p) { return { kind: p.kind, config: p.config }; }) }; } // ---------------------------------------------------------------- wiring get('evAdd').addEventListener('click', function () { open(null); }); get('evFilter').addEventListener('input', renderList); get('evShowOver').addEventListener('change', renderList); get('evOnce').addEventListener('change', function () { setWhen(false); }); get('evRepeat').addEventListener('change', function () { setWhen(true); }); get('evStarts').addEventListener('change', schedulePreview); get('evEnds').addEventListener('change', schedulePreview); get('evMatch').addEventListener('change', schedulePreview); get('evOffset').addEventListener('change', function () { offsetInfo(); schedulePreview(); }); get('evJson').addEventListener('input', schedulePreview); get('evAddRule').addEventListener('click', function () { rules.push(newRule(get('evNewRule').value)); renderRules(); schedulePreview(); }); get('evRules').addEventListener('click', function (e) { var remove = e.target.closest('[data-remove-rule]'); if (!remove) return; rules.splice(Number(remove.dataset.removeRule), 1); renderRules(); schedulePreview(); }); function ruleChanged(e) { var t = e.target, i = Number(t.dataset.i); if (isNaN(i) || !rules[i]) return; var r = rules[i]; if (t.dataset.field) { r[t.dataset.field] = t.type === 'number' ? Number(t.value) : t.value; } else if (t.dataset.day) { r.days = DAYS.filter(function (d) { return get('ev-day-' + i + '-' + d).checked; }); } else if (t.hasAttribute('data-not')) { if (t.checked) r.not = true; else delete r.not; } else if (t.hasAttribute('data-window')) { if (t.value === 'hours') { delete r.days; r.hours = 12; } else { delete r.hours; r.days = 0; } renderRules(); } else if (t.hasAttribute('data-group')) { try { var group = JSON.parse(t.value); r.match = group.match; r.rules = group.rules; t.classList.remove('is-invalid'); } catch (err) { t.classList.add('is-invalid'); return; } } schedulePreview(); } get('evRules').addEventListener('input', ruleChanged); get('evRules').addEventListener('change', function (e) { if (e.target.tagName === 'SELECT' || e.target.type === 'checkbox') ruleChanged(e); }); get('evJsonMode').addEventListener('change', function () { if (this.checked) { get('evJson').value = JSON.stringify(builderSchedule(), null, 2); } else { var schedule = currentSchedule(false); if (!schedule) { this.checked = true; return; } loadBuilder(schedule); } get('evJson').classList.toggle('d-none', !this.checked); get('evBuilder').classList.toggle('d-none', this.checked); schedulePreview(); }); get('evAddPart').addEventListener('click', function () { var kind = get('evNewPart').value; if (!allowed(kind)) return; parts.push({ kind: kind, config: defaultConfig(kind) }); renderParts(); var last = get('evParts').querySelector('[data-part="' + (parts.length - 1) + '"]'); if (last) last.scrollIntoView({ block: 'nearest' }); }); get('evParts').addEventListener('input', partChanged); get('evParts').addEventListener('change', partChanged); get('evParts').addEventListener('click', function (e) { var t = e.target.closest('[data-remove-part],[data-switch-add],[data-switch-remove],[data-lot-remove]'); if (!t) return; var i = Number(t.dataset.p !== undefined ? t.dataset.p : t.dataset.removePart); if (t.dataset.removePart !== undefined) parts.splice(Number(t.dataset.removePart), 1); else if (t.hasAttribute('data-switch-add')) { var free = vanityFiles().filter(function (f) { return !(f.name in parts[i].config.fileSwitches); })[0]; if (!free) { toast('Every vanity file has a switch already', 'info'); return; } parts[i].config.fileSwitches[free.name] = !free.loaded; } else if (t.dataset.switchRemove !== undefined) delete parts[i].config.fileSwitches[t.dataset.switchRemove]; else if (t.dataset.lotRemove !== undefined) parts[i].config.config.lots.splice(Number(t.dataset.lotRemove), 1); renderParts(); }); get('evForm').addEventListener('submit', function (e) { e.preventDefault(); var schedule = currentSchedule(false); if (schedule === undefined) return; var body = { name: get('evName').value.trim(), note: get('evNote').value.trim(), priority: Number(get('evPriority').value) || 0, mode: Number(get('evMode').value), schedule: schedule, startsAt: schedule ? 0 : fromInput(get('evStarts').value), endsAt: schedule ? 0 : fromInput(get('evEnds').value), parts: parts.map(function (p) { return { kind: p.kind, config: p.config }; }) }; api.action(editing ? '/api/events/' + editing.id : '/api/events', body).then(function (d) { toast(d.message || 'Saved', 'success'); modal.hide(); load(); }).catch(function () {}); }); get('evRows').addEventListener('change', function (e) { var select = e.target.closest('[data-mode]'); if (!select) return; api.action('/api/events/' + select.dataset.mode + '/mode', { mode: Number(select.value) }).then(function (d) { toast(d.message, 'success'); load(); }, load); }); get('evRows').addEventListener('click', function (e) { var el = e.target.closest('[data-edit],[data-export],[data-cancel],[data-delete],[data-shutdown]'); if (!el) return; if (el.dataset.edit) return open(byId(el.dataset.edit)); if (el.dataset.export) { var text = JSON.stringify(eventBody(byId(el.dataset.export)), null, 2); return navigator.clipboard.writeText(text).then(function () { toast('Copied the event as JSON', 'success'); }, function () { window.prompt('Copy the event:', text); }); } if (el.dataset.cancel && confirm('Switch this event off now? Whatever it switched on is ended.')) { return api.action('/api/events/' + el.dataset.cancel + '/cancel', {}, 'Cancelled').then(load, load); } if (el.dataset.delete && confirm('Delete this event? Whatever it switched on is ended first; vanity files it used stay in the vanity folder.')) { return api.action('/api/events/' + el.dataset.delete + '/delete', {}, 'Deleted').then(load, load); } if (el.dataset.shutdown && confirm('Shut this world down? Its players are saved and disconnected; a fresh instance starts when someone goes there.')) { var bits = el.dataset.shutdown.split('/'); api.action('/api/worlds/shutdown', { zone: +bits[0], instance: +bits[1] }, 'Shutting it down'); } }); get('calPrev').addEventListener('click', function () { cal.shown.setMonth(cal.shown.getMonth() - 1); renderCalendar(); }); get('calNext').addEventListener('click', function () { cal.shown.setMonth(cal.shown.getMonth() + 1); renderCalendar(); }); get('calendar').addEventListener('click', function (e) { var chip = e.target.closest('[data-open]'); if (chip && byId(chip.dataset.open)) open(byId(chip.dataset.open)); }); get('evImport').addEventListener('click', function () { get('evImportText').value = ''; importModal.show(); }); get('evImportForm').addEventListener('submit', function (e) { e.preventDefault(); var list; try { list = JSON.parse(get('evImportText').value); } catch (err) { toast('Not valid JSON: ' + err.message, 'danger'); return; } if (!Array.isArray(list)) list = [list]; var done = 0; // One at a time, so the first bad one stops the rest with its error list.reduce(function (chain, item) { return chain.then(function () { return api.action('/api/events', item).then(function () { done++; }); }); }, Promise.resolve()).then(function () { toast('Imported ' + done + ' event(s)', 'success'); importModal.hide(); }).catch(function () { if (done) toast('Imported ' + done + ' before that one', 'info'); }).then(load); }); // Time zone choices, every quarter hour from UTC-12 to UTC+14 get('evOffset').innerHTML = (function () { var html = '', mine = browserOffset(); for (var m = -720; m <= 840; m += 15) html += ''; return html; })(); if (window.Live) { Live.on('scheduled_events', Live.throttle(load, 500)); Live.on('live_events', Live.throttle(function () { cal.range = ''; renderCalendar(); }, 1000)); } setInterval(load, 60000); // on and off as time passes, and the running worlds load().then(function () { // #event:ID opens that event, #new a new one, #new:KIND a new one with that part var hash = decodeURIComponent(location.hash.slice(1)); if (hash === 'new') open(null); else if (hash.indexOf('new:') === 0 && allowed(hash.slice(4))) { open(null); parts.push({ kind: hash.slice(4), config: defaultConfig(hash.slice(4)) }); renderParts(); } else if (hash.indexOf('event:') === 0 && byId(hash.slice(6))) open(byId(hash.slice(6))); }); })();