/** * 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(e.partsError) + '
'; return '
' + e.parts.map(function (p) { return '' + esc(KIND_LABELS[p.kind] || p.kind) + (partSummary(p) ? ': ' + partSummary(p) + '' : '') + ''; }).join('') + '
' + e.parts.filter(function (p) { return /^(Waiting|Couldn't|Failed|Not scheduled)/.test(p.status || ''); }).map(function (p) { return '
' + esc(KIND_LABELS[p.kind] + ': ' + p.status) + '
'; }).join(''); } // ---------------------------------------------------------------- the list function whenCell(e) { if (e.once) return esc(fmt.unix(e.startsAt)) + '
to ' + esc(fmt.unix(e.endsAt)); if (e.scheduleError) return '' + esc(e.scheduleError) + ''; return esc(describe(e.schedule)); } function nextCell(e) { var state = e.once ? fmt.badge(e.stateName, colour(e)) + ' ' : ''; if (e.mode === 0) return state + 'Off'; var now = e.on ? fmt.badge('On', 'success') : fmt.badge('Off now', 'secondary'); if (e.mode === 2) return now + ' always'; if (isOver(e)) return state; var bits = []; if (e.on && e.nextEnd) bits.push('until ' + esc(fmt.unix(e.nextEnd))); if (!e.on && e.nextStart) bits.push('from ' + esc(fmt.unix(e.nextStart)) + (e.nextEnd ? '
to ' + esc(fmt.unix(e.nextEnd)) : '')); if (e.on && e.nextStart) bits.push('then from ' + esc(fmt.unix(e.nextStart))); if (!bits.length && !e.once) bits.push(e.on ? 'for the next four years' : 'not in the next four years'); return now + '
' + bits.join('
') + '
'; } function worlds(e) { // Feature parts: running worlds of the zones that keep what they loaded until restarted var list = []; e.parts.forEach(function (p) { if (p.kind === 'feature' && p.applied) (p.affected || []).forEach(function (w) { list.push(w); }); }); if (!list.length) return ''; return '
Worlds to restart for the feature: ' + list.map(function (w) { return fmt.zone(w.zone, w.zoneName) + ' #' + esc(w.instance) + ' (' + esc(w.players) + ' player(s))' + (canShutDown ? ' ' : ''); }).join(', ') + '
'; } function renderList() { var filter = get('evFilter').value.trim().toLowerCase(), showOver = get('evShowOver').checked; var events = data.events.filter(function (e) { if (!showOver && isOver(e)) return false; return !filter || (e.name + ' ' + e.note + ' ' + JSON.stringify(e.parts.map(function (p) { return p.config; }))).toLowerCase().indexOf(filter) !== -1; }).sort(function (a, b) { return (b.on - a.on) || ((a.nextStart || 9e12) - (b.nextStart || 9e12)) || a.id - b.id; }); get('evRows').innerHTML = events.map(function (e) { var may = mayChange(e), lock = may ? '' : ' disabled title="' + esc('Needs ' + e.parts.filter(function (p) { return !allowed(p.kind); }).map(function (p) { return p.permission; }).join(', ')) + '"'; var modes = data.modes.map(function (m) { return ''; }).join(''); var hasVanity = e.parts.some(function (p) { return p.kind === 'vanity'; }); return '' + esc(e.name) + '' + (e.note ? '
' + esc(e.note) + '
' : '') + partBadges(e) + worlds(e) + '' + '' + whenCell(e) + '' + '' + nextCell(e) + '' + '' + esc(e.priority) + '' + '' + '' + '' + (hasVanity && DASH.can('vanity_manage') ? 'Preview' : '') + '' + (e.once && !isOver(e) ? '' : '') + ''; }).join('') || '' + (data.events.length ? 'Nothing matches.' : 'No events yet.') + ''; } function renderVanity() { var v = data.vanity; var items = []; if (v) { v.conflicts.forEach(function (c) { items.push('
  • Vanity NPC ' + esc(c.npc) + ' is changed by ' + esc(c.events.join(', ')) + '; ' + esc(c.winner) + ' wins.
  • '); }); v.fileConflicts.forEach(function (c) { items.push('
  • Vanity file ' + esc(c.file) + ' is switched by ' + esc(c.switches.map(function (s) { return s.event + ' (' + (s.on ? 'on' : 'off') + ')'; }).join(', ')) + '; ' + esc(c.winner) + ' wins, so it is ' + (c.on ? 'on' : 'off') + '.
  • '); }); } get('evConflicts').innerHTML = items.length ? 'Events that are on now change the same vanity NPCs or files:' : ''; get('evConflicts').classList.toggle('d-none', !items.length); var overlaps = v ? v.overlaps : []; get('evOverlaps').innerHTML = overlaps.map(function (o) { var a = byId(o.a) || {}, b = byId(o.b) || {}; var what = []; if (o.npcs.length) what.push('NPCs ' + o.npcs.join(', ')); if (o.files.length) what.push('files ' + o.files.join(', ')); return '
  • ' + esc(a.name) + ' and ' + esc(b.name) + ' both change ' + esc(what.join(' and ')) + '. When both are on, ' + esc((byId(o.winner) || {}).name) + ' wins.
  • '; }).join(''); get('evOverlapsCard').classList.toggle('d-none', !overlaps.length); } function renderSlots() { get('slots').innerHTML = data.slots.map(function (s) { var values = s.values.map(function (v) { 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') : '') + '
  • '; }).join(''); } function load() { return api.get('/api/events').then(function (d) { if (!d.success) { toast(d.error || 'Could not load the events', 'danger'); return; } data = d; get('scanning').classList.toggle('d-none', !d.scanning); get('clientVersion').textContent = d.clientVersion; renderList(); renderVanity(); renderSlots(); cal.range = ''; renderCalendar(); renderNewPartOptions(); }); } // ---------------------------------------------------------------- the calendar function renderCalendar() { var year = cal.shown.getFullYear(), month = cal.shown.getMonth(); get('calTitle').textContent = cal.shown.toLocaleDateString([], { month: 'long', year: 'numeric' }); var first = new Date(year, month, 1); var start = new Date(first); start.setDate(1 - ((first.getDay() + 6) % 7)); // weeks start on Monday var html = ''; for (var i = 0; i < 7; i++) html += '
    ' + esc(new Date(2024, 0, 1 + i).toLocaleDateString([], { weekday: 'short' })) + '
    '; var today = new Date(); today.setHours(0, 0, 0, 0); for (var d = 0; d < 42; d++) { var day = new Date(start); day.setDate(start.getDate() + d); var from = day.getTime() / 1000, to = from + 86400; var chips = cal.list.filter(function (o) { return o.start < to && (o.end > from || (o.end === o.start && o.start >= from)); }).map(function (o) { var title = (o.kind === 'event' ? '' : (o.kind === 'live_event' ? 'Live event ' : o.kind === 'announcement' ? 'Announcement ' : '')) + o.name + ': ' + fmt.unix(o.start) + (o.end !== o.start ? ' to ' + fmt.unix(o.end) : ''); if (o.kind !== 'event') return '' + esc(o.name) + ''; var e = byId(o.id) || {}; return '' + esc(o.name) + ''; }).join(''); html += '
    ' + day.getDate() + '' + chips + '
    '; } get('calendar').innerHTML = html; loadOccurrences(start.getTime() / 1000, start.getTime() / 1000 + 42 * 86400); } // When everything is on in the days shown, fetched once per month shown function loadOccurrences(from, to) { var range = Math.floor(from) + '-' + Math.floor(to); if (cal.range === range) return; cal.range = range; api.get('/api/events/occurrences?from=' + Math.floor(from) + '&to=' + Math.floor(to)).then(function (d) { if (!d.success || cal.range !== range) return; cal.list = d.occurrences; renderCalendar(); }); } // ---------------------------------------------------------------- the schedule builder function newRule(type) { switch (type) { case 'yearly': return { type: type, from: '10-01', to: '10-31' }; case 'dates': { var today = new Date(); var iso = today.getFullYear() + '-' + pad(today.getMonth() + 1) + '-' + pad(today.getDate()); return { type: type, from: iso, to: iso }; } case 'weekdays': return { type: type, days: ['sat', 'sun'] }; case 'time_of_day': return { type: type, from: '18:00', to: '23:00' }; case 'moon': return { type: type, phase: 'full_moon', days: 0 }; default: return { type: 'group', match: 'all', rules: [{ type: 'weekdays', days: ['fri', 'sat'] }, { type: 'time_of_day', from: '18:00', to: '23:59' }] }; } } function ruleInput(i, field, value, attrs) { return ''; } function ruleBody(r, i) { switch (r.type) { case 'yearly': return 'from ' + ruleInput(i, 'from', r.from, 'placeholder="MM-DD" size="6" aria-label="From (month-day)"') + ' to ' + ruleInput(i, 'to', r.to, 'placeholder="MM-DD" size="6" aria-label="To (month-day)"') + 'both days included; may run past the new year (12-20 to 01-02)'; case 'dates': return 'from ' + ruleInput(i, 'from', r.from, 'placeholder="2026-12-20" size="16" aria-label="From"') + ' to ' + ruleInput(i, 'to', r.to, 'placeholder="2027-01-02" size="16" aria-label="To"') + 'a date is the whole day; add a time as 2026-12-20T18:00'; case 'weekdays': return '
    ' + DAYS.map(function (d) { var id = 'ev-day-' + i + '-' + d, on = (r.days || []).indexOf(d) !== -1; return '' + ''; }).join('') + '
    '; case 'time_of_day': return 'from ' + ruleInput(i, 'from', r.from, 'type="time" aria-label="From"') + ' to ' + ruleInput(i, 'to', r.to, 'type="time" aria-label="To"') + 'may run past midnight'; case 'moon': { var phases = PHASES.map(function (p) { return ''; }).join(''); var byHours = r.hours !== undefined; return '' + '' + (byHours ? ruleInput(i, 'hours', r.hours, 'type="number" min="1" max="240" aria-label="Hours"') : ruleInput(i, 'days', r.days || 0, 'type="number" min="0" max="7" aria-label="Days"')) + '"the day" is the day in the rules\' time zone'; } case 'group': return '' + '{"match": "any" or "all", "rules": [...]}, the same rules as JSON'; } return ''; } function renderRules() { get('evRules').innerHTML = rules.map(function (r, i) { return '
    ' + '' + esc(RULE_LABELS[r.type] || r.type) + '' + ruleBody(r, i) + '
    ' + '
    ' + '
    '; }).join('') || '

    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 = '
    ' + (d.on ? fmt.badge('On now', 'success') : fmt.badge('Off now', 'secondary')) + ' ' + esc(describe(d.schedule)) + '
    ' + (d.windows.length ? '
    On (shown in your time zone, ' + esc(offsetLabel(browserOffset())) + '):
    ' : '
    Not on in the next four years.
    '); }); }, 300); } function setWhen(repeat) { get(repeat ? 'evRepeat' : 'evOnce').checked = true; get('evOnceBox').classList.toggle('d-none', repeat); get('evRepeatBox').classList.toggle('d-none', !repeat); schedulePreview(); } // ---------------------------------------------------------------- the parts function renderNewPartOptions() { get('evNewPart').innerHTML = data.kinds.map(function (k) { return ''; }).join(''); var first = data.kinds.filter(function (k) { return k.allowed; })[0]; if (first) get('evNewPart').value = first.name; } function defaultConfig(kind) { switch (kind) { case 'feature': return { feature: '' }; case 'vanity': return { file: '', removals: [], fileSwitches: {} }; case 'live_event': return { type: 'celebration', title: '', message: '', zones: [], instance: -1, config: liveDefaults('celebration') }; case 'announcement': return { title: '', message: '', zones: [], atStart: true, repeat: '', endMessage: '' }; case 'restart': return { when: 'end', minutes: 15, reason: '' }; } return {}; } function liveDefaults(type) { switch (type) { case 'treasure_hunt': return { lot: 0, count: 10, coins: 0, radius: 4, itemLot: 0, itemCount: 1 }; case 'bonus': return { coins: 2, uscore: 1, lootChance: 1 }; case 'invasion': return { lots: [], waves: 3, perWave: 5, interval: 90, radius: 25, center: 'spawn' }; default: return { effectId: 0, effectType: '', interval: 10 }; } } function field(i, path, label, value, attrs, help) { var id = 'evp-' + i + '-' + path.replace(/\./g, '-'); return '
    ' + '' + (help ? '
    ' + help + '
    ' : '') + '
    '; } function zonesField(i, path, selected, help) { var id = 'evp-' + i + '-zones'; return '
    ' + '
    ' + help + ' Ctrl-click for several.
    '; } function featureBody(p, i) { var inZones = data.features.filter(function (f) { return f.zones.length; }), others = data.features.filter(function (f) { return !f.zones.length; }); function option(f) { return ''; } var known = !p.config.feature || feature(p.config.feature); var f = feature(p.config.feature), info = []; if (f) { if (f.description) info.push(esc(f.description)); if (f.version) info.push('FeatureGating: from client version ' + esc(f.version) + (f.unlocked ? ' (already on at ' + esc(data.clientVersion) + ', so it changes nothing in the zones)' : '')); info.push(f.zones.length ? 'Objects in: ' + f.zones.map(function (z) { return esc(z.name) + ' (' + esc(z.objects) + ')'; }).join(', ') + '. Worlds of these zones that are running when it starts or ends need a restart.' : 'No zone has objects gated on it; it only reaches the client at login.'); } return '' + '
    ' + info.map(function (x) { return '
    ' + x + '
    '; }).join('') + '
    ' + '
    It goes in a free event_1–event_8 setting while the event is on; players get it at their next login.
    '; } function vanityFiles() { return pickers ? pickers.files : []; } function vanityBody(p, i) { if (!pickers) { loadPickers(); return '

    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 '
    File switches (like root.xml\'s switches, while the event is on)
    ' + Object.keys(switches).map(function (name) { return '
    ' + '' + '
    '; }).join('') + '' + '
    On: the file (and what it includes) loads even if nothing switches it on. Off: it doesn\'t load, nor what only it includes.
    ' + '
    ' + '
    ' + 'Edit its NPCs
    ' + '
    Its NPCs are laid over the others: one with the same name as a vanity NPC replaces it (all of its locations). A new name makes an empty file when you save; root.xml shouldn\'t load it.
    ' + '
    ' + '' + '
    '; } function liveConfigBody(p, i) { var c = p.config.config || {}, type = p.config.type; switch (type) { case 'treasure_hunt': return '
    ' + '
    ' + ['count|Treasures|1', 'coins|Coins per find|0', 'radius|Pickup distance|2'].map(function (x) { var bits = x.split('|'); return '
    ' + field(i, 'config.' + bits[0], bits[1], c[bits[0]], 'type="number" min="' + bits[2] + '" step="any"') + '
    '; }).join('') + '
    ' + '
    ' + '
    ' + field(i, 'config.itemCount', 'Count', c.itemCount, 'type="number" min="1" max="999"') + '
    '; case 'bonus': return '
    ' + ['coins|Coins ×', 'uscore|U-score ×', 'lootChance|Loot chance ×'].map(function (x) { var bits = x.split('|'); return '
    ' + field(i, 'config.' + bits[0], bits[1], c[bits[0]], 'type="number" min="1" step="0.1"') + '
    '; }).join('') + '
    '; case 'invasion': return '
    Enemies
    ' + (c.lots || []).map(function (lot, n) { return 'LOT ' + esc(lot) + ' '; }).join('') + '
    ' + '
    ' + ['waves|Waves|1', 'perWave|Per wave|1', 'interval|Seconds apart|15', 'radius|Radius|5'].map(function (x) { var bits = x.split('|'); return '
    ' + field(i, 'config.' + bits[0], bits[1], c[bits[0]], 'type="number" min="' + bits[2] + '"') + '
    '; }).join('') + '
    ' + ''; default: return '
    ' + '
    ' + field(i, 'config.interval', 'Every (seconds)', c.interval, 'type="number" min="3" max="600"') + '
    '; } } function liveBody(p, i) { var c = p.config; var copy = recentLive && recentLive.length ? '' : ''; if (!recentLive) loadRecentLive(); return copy + '
    ' + '
    ' + field(i, 'title', 'Title', c.title, 'maxlength="100"') + '
    ' + field(i, 'message', 'Said when it starts (optional)', c.message, 'maxlength="300"') + zonesField(i, 'zones', c.zones, c.type === 'bonus' ? 'None: every world.' : 'Where it runs.') + field(i, 'instance', 'Instance', c.instance, 'type="number" min="-1"', '-1: every instance of those zones.') + liveConfigBody(p, i) + '
    It starts when the event does and ends when it ends (a live event runs at most 7 days).
    '; } function announcementBody(p, i) { var c = p.config; return field(i, 'title', 'Title (optional)', c.title, 'maxlength="100"') + '
    ' + '
    ' + field(i, 'repeat', 'Repeat while it is on (optional)', c.repeat, 'placeholder="@every 2h or 0 */3 * * *" spellcheck="false"', 'A schedule as on the Scheduled Announcements page (cron in UTC, or @every). At most once a minute.') + field(i, 'endMessage', 'Say when it ends (optional)', c.endMessage, 'maxlength="1000"') + zonesField(i, 'zones', c.zones, 'None: every world.'); } function restartBody(p, i) { var c = p.config; return '
    ' + '
    ' + field(i, 'minutes', 'Warn players this many minutes before', c.minutes, 'type="number" min="1" max="1440"') + '
    ' + field(i, 'reason', 'Reason (shown to players)', c.reason, 'maxlength="300"') + '
    Needs a process supervisor to start the server again. A restart already scheduled is left as it is.
    '; } function partBody(p, i) { switch (p.kind) { case 'feature': return featureBody(p, i); case 'vanity': return vanityBody(p, i); case 'live_event': return liveBody(p, i); case 'announcement': return announcementBody(p, i); case 'restart': return restartBody(p, i); } return ''; } function renderParts() { get('evParts').innerHTML = (parts.map(function (p, i) { var may = allowed(p.kind); return '
    ' + esc(KIND_LABELS[p.kind] || p.kind) + '' + (p.applied !== undefined && editing ? (p.applied ? fmt.badge('on', 'success') : fmt.badge('off', 'secondary')) : '') + (p.status && editing ? '' + esc(p.status) + '' : '') + '
    ' + (may ? partBody(p, i) : '

    Needs ' + esc(permissionOf(p.kind)) + ' to change: ' + partSummary(p) + '

    ') + '
    '; }).join('') || '

    Add what the event switches on.

    ') + '' + vanityFiles().map(function (f) { return ''; 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))); }); })();