From cb8fbeb2af252e3a089fda7d1743cb5f2f9dbdcb Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:23:22 +0000 Subject: [PATCH 1/8] Add MCGalaxy's command shortcuts and aliases lib/commands/shortcuts.js lists them per command. An alias can carry arguments ('zadd add' runs /zone add), and a real command name always wins over an alias. /v and /pl now mean /paste and /place, as in MCGalaxy. --- lib/commands/core.js | 6 +-- lib/commands/manager.js | 29 +++++++--- lib/commands/shortcuts.js | 93 ++++++++++++++++++++++++++++++++ plugins/core-moderation/index.js | 3 +- 4 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 lib/commands/shortcuts.js diff --git a/lib/commands/core.js b/lib/commands/core.js index bb5071e..453add8 100644 --- a/lib/commands/core.js +++ b/lib/commands/core.js @@ -68,7 +68,7 @@ module.exports = function registerCoreCommands (server) { commands.register({ name: 'plugins', - aliases: ['pl', 'plist'], + aliases: ['plist'], category: 'server', usage: '/plugins', description: 'Lists loaded plugins', @@ -276,8 +276,8 @@ module.exports = function registerCoreCommands (server) { server.broadcast(`&e${reason}...`) await server.stop(reason) if (server.config.exitOnStop === false) return - // under systemd, pm2 or a start script loop the supervisor starts us again - if (process.env.MCSCRIPT_SUPERVISED) process.exit(0) + // under systemd, pm2, a start script loop or Pterodactyl the supervisor starts us again + if (process.env.MCSCRIPT_SUPERVISED || process.env.P_SERVER_UUID) process.exit(0) const { spawn } = require('child_process') spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit', cwd: process.cwd(), detached: true }).unref() process.exit(0) diff --git a/lib/commands/manager.js b/lib/commands/manager.js index 65abddf..2d9efa9 100644 --- a/lib/commands/manager.js +++ b/lib/commands/manager.js @@ -1,6 +1,7 @@ 'use strict' const fs = require('fs') +const SHORTCUTS = require('./shortcuts') class CommandError extends Error {} @@ -10,7 +11,7 @@ class CommandManager { constructor (server, overridesFile) { this.server = server this.commands = new Map() // name -> def - this.aliases = new Map() // alias -> name + this.aliases = new Map() // alias -> { name, args } (args are put in front of what the player typed) this.overridesFile = overridesFile this.overrides = {} if (overridesFile && fs.existsSync(overridesFile)) { @@ -33,9 +34,12 @@ class CommandManager { name } this.commands.set(name, cmd) - for (const alias of cmd.aliases) { - const a = alias.toLowerCase() - if (!this.commands.has(a) && !this.aliases.has(a)) this.aliases.set(a, name) + const extra = SHORTCUTS[name] || [] + for (const entry of [...cmd.aliases, ...extra]) { + const [a, ...args] = entry.toLowerCase().split(' ') + if (this.commands.has(a) || this.aliases.has(a)) continue + this.aliases.set(a, { name, args }) + if (!cmd.aliases.includes(a) && !args.length) cmd.aliases = [...cmd.aliases, a] } return () => this.unregister(name) } @@ -45,7 +49,7 @@ class CommandManager { const cmd = this.commands.get(name) if (!cmd) return false this.commands.delete(name) - for (const [alias, target] of this.aliases) if (target === name) this.aliases.delete(alias) + for (const [alias, target] of this.aliases) if (target.name === name) this.aliases.delete(alias) return true } @@ -56,7 +60,16 @@ class CommandManager { find (name) { if (!name) return null name = name.toLowerCase() - return this.commands.get(name) || this.commands.get(this.aliases.get(name)) || null + const alias = this.aliases.get(name) + return this.commands.get(name) || (alias && this.commands.get(alias.name)) || null + } + + // Arguments an alias adds in front of the player's own ('zadd' -> ['add']) + aliasArgs (label) { + label = String(label).toLowerCase() + if (this.commands.has(label)) return [] + const alias = this.aliases.get(label) + return alias ? alias.args : [] } // minimum permission number required to use a command @@ -84,7 +97,9 @@ class CommandManager { if (!line) return const space = line.indexOf(' ') const label = (space === -1 ? line : line.slice(0, space)).toLowerCase() - const raw = space === -1 ? '' : line.slice(space + 1).trim() + const typed = space === -1 ? '' : line.slice(space + 1).trim() + const prefix = this.aliasArgs(label) + const raw = [...prefix, typed].filter(Boolean).join(' ') const args = raw.length ? raw.split(/\s+/) : [] const cmd = this.find(label) diff --git a/lib/commands/shortcuts.js b/lib/commands/shortcuts.js new file mode 100644 index 0000000..79225ad --- /dev/null +++ b/lib/commands/shortcuts.js @@ -0,0 +1,93 @@ +'use strict' + +// MCGalaxy's shortcuts and aliases, so people coming from MCGalaxy can keep typing what they are used to. +// An entry with a space carries arguments: 'zadd add' makes /zadd x the same as /zone add x. +// They are added when the command registers; a real command name always wins over an alias. +module.exports = { + // chat and info + adminchat: ['admin'], + opchat: ['op'], + clear: ['playercls', 'globalcls all', 'gcls all'], + about: ['whodid'], + blocks: ['materials'], + help: ['cmdhelp'], + mapinfo: ['mi', 'winfo', 'worldinfo'], + serverinfo: ['sinfo', 'host', 'zall'], + time: ['ti'], + top: ['most'], + whois: ['whowas', 'info', 'i'], + players: ['who'], + pclients: ['clients'], + levels: ['worlds', 'maps', 'loaded'], + lastcmd: ['last'], + rankinfo: ['ri'], + whonick: ['realname'], + bans: ['banned'], + vote: ['vo'], + alts: ['clones', 'whoip'], + // essentials + hold: ['holdthis'], + reach: ['reachdistance'], + entityrot: ['entrot'], + tp: ['move', 'teleport'], + tphere: ['summon', 's', 'fetch', 'bring'], + tpa: ['tpaccept accept', 'tpdeny deny'], + bot: ['botlist list'], + // economy and games + buy: ['purchase'], + give: ['gib'], + shop: ['store', 'item'], + explode: ['ex'], + // moderation + ban: ['tempban', 'tb', 'kickban', 'kb'], + banedit: ['be'], + banip: ['bi', 'ipban'], + unbanip: ['unipban'], + xban: ['banx'], + kick: ['k'], + freeze: ['fz'], + rank: ['setrank'], + moveall: ['ma'], + playeredit: ['pe', 'setinfo'], + report: ['reports list'], + undoplayer: ['xundo'], + whitelist: ['wl'], + // levels + copylvl: ['wcopy', 'worldcopy'], + deletelvl: ['wdelete', 'worlddelete', 'wremove'], + fixgrass: ['fg'], + load: ['mapload', 'wload'], + lockdown: ['ld', 'wlock', 'wunlock'], + main: ['h', 'wmain', 'worldmain'], + newlvl: ['gen'], + reload: ['reveal', 'rejoin', 'rd', 'wflush', 'worldflush'], + renamelvl: ['wrename', 'worldrename'], + resizelvl: ['wresize', 'worldresize'], + save: ['mapsave', 'wsave', 'worldsave'], + map: ['perbuild buildrank', 'wbuild buildrank', 'worldbuild buildrank', 'pervisit visitrank', 'waccess visitrank', 'worldaccess visitrank'], + zone: ['zadd add', 'zremove delete', 'zdelete delete', 'zonelist list', 'zonetest check', 'ztest check'], + // building + cuboid: ['cw wire', 'ch hollow', 'walls walls', 'hbox hollow'], + sphere: ['sph hollow', 'sphereh hollow'], + spheroid: ['eh hollow'], + fill: ['f3d 3d', 'fill3d 3d', 'f2d layer', 'fill2d layer'], + line: ['ln'], + mark: ['click', 'x'], + measure: ['ms'], + calculate: ['calc'], + center: ['centre'], + paint: ['p'], + paste: ['v'], + place: ['pl'], + mirror: ['flip'], + spin: ['rotate'], + portal: ['o'], + torus: ['tor', 'bagel'], + write: ['wrt', 'writetext'], + abort: ['a'], + replacenot: ['rn'], + triangle: ['tri'], + static: ['t'], + delete: ['d'], + bind: ['cmdbind', 'cb'] +} diff --git a/plugins/core-moderation/index.js b/plugins/core-moderation/index.js index 79bec94..4c01405 100644 --- a/plugins/core-moderation/index.js +++ b/plugins/core-moderation/index.js @@ -308,7 +308,7 @@ module.exports = { ctx.command({ name: 'vanish', - aliases: ['hide', 'v'], + aliases: ['hide'], category: 'moderation', rank: 'Operator', usage: '/vanish', @@ -334,6 +334,7 @@ module.exports = { }) require('./more')(ctx, { find, findRecord, rankOf, checkHigher, actorName }) + require('./alts')(ctx, { findRecord }) // anti-spam From bd855d9b7312ebe6eaf1d39d1ea9ac039c8de7f1 Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:23:22 +0000 Subject: [PATCH 2/8] Add /main , /autoload and more commands from MCGalaxy /vote, /timer, /pronouns, /quit, /ragequit, /alts, /like, /dislike, /replacenot, /triangle, /delete and /static. Levels listed in autoloadLevels load at startup. --- lib/config.js | 3 + lib/level/manager.js | 9 +++ lib/server.js | 30 ++++++++ plugins/core-building/index.js | 1 + plugins/core-building/tools.js | 115 ++++++++++++++++++++++++++++ plugins/core-essentials/index.js | 18 ++++- plugins/core-essentials/social.js | 120 ++++++++++++++++++++++++++++++ plugins/core-moderation/alts.js | 22 ++++++ plugins/core-worlds/index.js | 21 +++++- plugins/core-worlds/ratings.js | 47 ++++++++++++ 10 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 plugins/core-building/tools.js create mode 100644 plugins/core-essentials/social.js create mode 100644 plugins/core-moderation/alts.js create mode 100644 plugins/core-worlds/ratings.js diff --git a/lib/config.js b/lib/config.js index c63152a..c477c89 100644 --- a/lib/config.js +++ b/lib/config.js @@ -19,6 +19,7 @@ const DEFAULTS = { allowWebClient: true, trustProxy: false, mainLevel: 'main', + autoloadLevels: [], defaultRank: 'Guest', owners: [], welcomeMessage: '&eWelcome to the server, &f{player}&e! Type &f/help&e for commands.', @@ -28,6 +29,7 @@ const DEFAULTS = { '&f3. &7No spamming or advertising.' ], defaultTexture: '', + publicAddress: '', // your server's address as players reach it (domain or IP, optional :port), for texture packs in texpacks/ autosaveMinutes: 5, backupMinutes: 30, backupsToKeep: 10, @@ -44,6 +46,7 @@ const DEFAULTS = { // Environment variables that map onto config keys const ENV_MAP = { PORT: ['port', Number], + SERVER_PORT: ['port', Number], // Pterodactyl and similar panels HOST: ['host', String], SERVER_NAME: ['name', String], MOTD: ['motd', String], diff --git a/lib/level/manager.js b/lib/level/manager.js index e20d349..e652b13 100644 --- a/lib/level/manager.js +++ b/lib/level/manager.js @@ -229,6 +229,15 @@ class LevelManager { } // Load or create the main level, migrating the pre-2.0 levels/level.dat if present + // Makes another level the main one (where players join), and remembers it in config/server.json + setMain (name) { + const level = this.get(name) || this.load(name) + this.main = level + this.server.config.mainLevel = level.name + if (this.server.config.file) this.server.saveConfig() + return level + } + initMain (name) { if (this.exists(name)) { this.main = this.load(name) diff --git a/lib/server.js b/lib/server.js index 15c660d..c6f48b8 100644 --- a/lib/server.js +++ b/lib/server.js @@ -108,6 +108,9 @@ class MCScriptServer { this.levels.initMain(cfg.mainLevel) registerCoreCommands(this) this.plugins.loadAll() + for (const name of cfg.autoloadLevels || []) { + try { if (!this.levels.get(name)) this.levels.load(name) } catch (err) { this.log.warn(`Could not autoload ${name}: ${err.message}`) } + } this.tcp = net.createServer(socket => this._onConnection(socket)) this.tcp.maxConnections = cfg.maxPlayers + 10 @@ -193,12 +196,39 @@ class MCScriptServer { else this._connectionsPerIp.delete(ip) }) const conn = new Connection(socket) + conn.httpHandler = (method, target) => this._serveHttp(method, target) conn.on('socketError', err => this.log.debug(`Socket error (${conn.socketIp}): ${err.message}`)) conn.on('handlerError', (err, packet) => this.log.error(`Error handling ${packet.name} from ${conn.socketIp}:`, err)) // eslint-disable-next-line no-new new Player(this, conn) } + // Texture packs in texpacks/ can be downloaded from the game port: http://host:port/texpacks/ + _serveHttp (method, target) { + if (method !== 'GET' && method !== 'HEAD') return null + const m = String(target).split('?')[0].match(/^\/texpacks\/([A-Za-z0-9_.-]{1,48}\.(zip|png))$/) + if (!m || m[1].startsWith('.')) return null + const file = path.join(this.root, 'texpacks', m[1]) + try { + const stat = fs.statSync(file) + if (!stat.isFile() || stat.size > 16 * 1024 * 1024) return null + return { type: m[2] === 'zip' ? 'application/zip' : 'image/png', body: fs.readFileSync(file) } + } catch (err) { + return null + } + } + + // URL players' clients use to download a pack from texpacks/ (needs publicAddress in the config) + texturePackUrl (fileName) { + let address = String(this.config.publicAddress || '').replace(/^https?:\/\//, '').replace(/\/+$/, '') + // Pterodactyl tells us the address of the allocation + const panelIp = process.env.SERVER_IP + if (!address && panelIp && panelIp !== '0.0.0.0') address = `${panelIp}:${this.port}` + if (!address) return null + const host = /:\d+$/.test(address) ? address : `${address}:${this.port}` + return `http://${host}/texpacks/${fileName}` + } + _allocateId () { for (let id = 0; id <= MAX_ENTITY_ID; id++) { if (!this._usedIds.has(id)) { this._usedIds.add(id); return id } diff --git a/plugins/core-building/index.js b/plugins/core-building/index.js index 8b81b72..d3c746a 100644 --- a/plugins/core-building/index.js +++ b/plugins/core-building/index.js @@ -614,6 +614,7 @@ module.exports = { }) require('./more')(ctx, { select, blockArg, checkVolume, box, apply, clipboards, key }) + require('./tools')(ctx, { select, blockArg, checkVolume, box, apply }) // manual building diff --git a/plugins/core-building/tools.js b/plugins/core-building/tools.js new file mode 100644 index 0000000..687bc78 --- /dev/null +++ b/plugins/core-building/tools.js @@ -0,0 +1,115 @@ +'use strict' + +// /replacenot /triangle /delete /static, as in MCGalaxy + +module.exports = function tools (ctx, h) { + const { server, CommandError } = ctx + const { select, blockArg, checkVolume, box, apply } = h + + ctx.command({ + name: 'replacenot', + category: 'building', + rank: 'Builder', + usage: '/replacenot ', + description: 'Replaces everything except one block inside a marked box (air too)', + inGame: true, + async run (player, args, { usage }) { + if (args.length < 2) return usage() + const level = player.level + const keep = level.parseBlock(args[0]) + if (keep === null) throw new CommandError(`Unknown block "${args[0]}".`) + const to = blockArg(player, args[1]) + const marks = await select(player, 2, 'Replace not') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, (b.x2 - b.x1 + 1) * (b.y2 - b.y1 + 1) * (b.z2 - b.z1 + 1)) + const changes = [] + for (let y = b.y1; y <= b.y2; y++) { + for (let z = b.z1; z <= b.z2; z++) { + for (let x = b.x1; x <= b.x2; x++) if (level.getBlock(x, y, z) !== keep) changes.push([x, y, z, to]) + } + } + apply(player, level, changes, 'Replace not') + } + }) + + ctx.command({ + name: 'triangle', + category: 'building', + rank: 'AdvBuilder', + usage: '/triangle [block]', + description: 'Fills the triangle between three marked corners', + inGame: true, + async run (player, args) { + const block = blockArg(player, args[0]) + const marks = await select(player, 3, 'Triangle') + if (!marks) return + const [a, b, c] = marks + const len = (p, q) => Math.hypot(p.x - q.x, p.y - q.y, p.z - q.z) + // sample the triangle densely enough that no block is skipped + const steps = Math.max(1, Math.ceil(2 * Math.max(len(a, b), len(a, c), len(b, c)))) + checkVolume(player, Math.ceil(steps * steps / 8)) + const seen = new Set() + const changes = [] + for (let i = 0; i <= steps; i++) { + for (let j = 0; j <= steps - i; j++) { + const u = i / steps; const v = j / steps + const x = Math.round(a.x + (b.x - a.x) * u + (c.x - a.x) * v) + const y = Math.round(a.y + (b.y - a.y) * u + (c.y - a.y) * v) + const z = Math.round(a.z + (b.z - a.z) * u + (c.z - a.z) * v) + const k = `${x},${y},${z}` + if (seen.has(k) || !player.level.inBounds(x, y, z)) continue + seen.add(k) + changes.push([x, y, z, block]) + } + } + apply(player, player.level, changes, 'Triangle') + } + }) + + ctx.command({ + name: 'delete', + category: 'building', + rank: 'Builder', + usage: '/delete', + description: 'Removes the block you mark (use /static delete to keep going)', + inGame: true, + async run (player) { + const marks = await select(player, 1, 'Delete') + if (!marks) return + const { x, y, z } = marks[0] + apply(player, player.level, [[x, y, z, 0]], 'Delete') + } + }) + + // /static : runs a drawing command again each time it finishes, until /abort + ctx.on('playerAbort', ({ player }) => { player.data['building.static'] = false }) + ctx.command({ + name: 'static', + category: 'building', + rank: 'Builder', + usage: '/static [args]', + description: 'Repeats a drawing command (for example /static cuboid stone) until you type /abort', + inGame: true, + async run (player, args, { usage }) { + if (!args.length) { + if (player.data['building.static']) { player.data['building.static'] = false; return player.message('&eStatic mode off.') } + return usage() + } + const cmd = server.commands.find(args[0]) + if (!cmd) throw new CommandError(`Unknown command "/${args[0]}".`) + if (cmd.name === 'static') throw new CommandError('That would never end.') + if (cmd.category !== 'building') throw new CommandError('Static mode only works with building commands.') + player.data['building.static'] = true + player.message(`&eStatic mode: /${args.join(' ')} repeats until you type &f/abort&e.`) + const line = '/' + args.join(' ') + for (let i = 0; i < 1000 && player.data['building.static'] && !player.conn.closed; i++) { + const before = Date.now() + await server.commands.execute(player, line) + // a command that didn't wait for marks would spin; stop instead + if (Date.now() - before < 50) break + } + player.data['building.static'] = false + } + }) +} diff --git a/plugins/core-essentials/index.js b/plugins/core-essentials/index.js index bde412b..4e78cef 100644 --- a/plugins/core-essentials/index.js +++ b/plugins/core-essentials/index.js @@ -53,10 +53,18 @@ module.exports = { ctx.command({ name: 'main', category: 'essentials', - usage: '/main', - description: 'Takes you to the main level', - inGame: true, - run (player) { + usage: '/main [level]', + description: 'Takes you to the main level. Admins can pick another main level with /main ', + help: ['The main level is where players arrive when they join. It is saved in config/server.json.'], + run (player, args) { + if (args[0]) { + if (player.permission < server.ranks.permissionOf('Admin')) throw new CommandError('Only admins can change the main level.') + const name = server.levels.resolveName(args[0]) + if (!name) throw new CommandError(`There is no level called ${args[0]}.`) + const level = server.levels.setMain(name) + return server.broadcast(`&e${level.name} is now the main level.`, p => p.permission >= server.ranks.permissionOf('Admin') || p === player) + } + if (player.isConsole) return player.message(`&eThe main level is &f${server.levels.main.name}&e.`) if (player.level === server.levels.main) { const s = player.level.spawn return player.teleport(s.x, s.y, s.z, s.yaw, s.pitch) @@ -246,6 +254,7 @@ module.exports = { const onlinePlayer = server.findPlayerExact(record.name) player.message(`&e${rank.color}${record.name}&e ${onlinePlayer ? '&a(online)' : '&7(offline)'}`) if (record.nick) player.message(`&7 Nickname: &f${record.nick}`) + if (record.pronouns) player.message(`&7 Pronouns: &f${record.pronouns}`) player.message(`&7 Rank: ${rank.color}${rank.name}`) if (onlinePlayer) { player.message(`&7 In level: &f${onlinePlayer.level.name}&7, using &f${onlinePlayer.appName}${onlinePlayer.pingMs !== null ? `&7, ping &f${onlinePlayer.pingMs}ms` : ''}`) @@ -493,6 +502,7 @@ module.exports = { } require('./more')(ctx, { find }) + require('./social')(ctx) ctx.on('tabListEntry', (ev) => { if (isAfk(ev.player)) ev.listName += ' &7(AFK)' diff --git a/plugins/core-essentials/social.js b/plugins/core-essentials/social.js new file mode 100644 index 0000000..da72258 --- /dev/null +++ b/plugins/core-essentials/social.js @@ -0,0 +1,120 @@ +'use strict' + +// /vote /timer /pronouns /quit, as in MCGalaxy + +module.exports = function social (ctx) { + const { server, CommandError, text } = ctx + const isOp = p => p.permission >= server.ranks.permissionOf('Operator') + + // a yes/no vote everyone online can answer with /yes or /no (or by typing yes/no in chat) + let vote = null + const finishVote = () => { + if (!vote) return + const { question, yes, no } = vote + vote = null + server.broadcast(`&eVote over: &f${question} &a${yes.size} yes &7- &c${no.size} no`) + } + const answer = (player, choice) => { + if (!vote) throw new CommandError('There is no vote going on.') + const key = player.name.toLowerCase() + vote.yes.delete(key); vote.no.delete(key) + vote[choice].add(key) + player.message(`&eYou voted &f${choice}&e.`) + } + ctx.command({ + name: 'vote', + category: 'chat', + rank: 'Operator', + usage: '/vote | /vote end', + description: 'Asks everyone a yes/no question for 30 seconds', + run (player, args, { usage }) { + if (!args.length) return usage() + if (args[0].toLowerCase() === 'end') { if (!vote) throw new CommandError('There is no vote going on.'); return finishVote() } + if (vote) throw new CommandError('A vote is already going on. /vote end stops it.') + const question = text.sanitize(args.join(' ')) + vote = { question, yes: new Set(), no: new Set(), timer: ctx.setTimeout(finishVote, 30000) } + server.broadcast(`&eVote by ${player.coloredName}&e: &f${question}`) + server.broadcast('&eAnswer with &a/yes&e or &c/no&e (30 seconds).') + } + }) + ctx.command({ name: 'yes', category: 'chat', usage: '/yes', description: 'Votes yes', run (p) { answer(p, 'yes') } }) + ctx.command({ name: 'no', category: 'chat', usage: '/no', description: 'Votes no', run (p) { answer(p, 'no') } }) + + // countdown shown in the middle of everyone's screen + let countdown = null + ctx.command({ + name: 'timer', + category: 'chat', + rank: 'Operator', + usage: '/timer [message] | /timer stop', + description: 'Counts down on everyone\'s screen', + run (player, args, { usage }) { + if ((args[0] || '').toLowerCase() === 'stop') { + if (!countdown) throw new CommandError('No timer is running.') + ctx.clearTimer(countdown); countdown = null + return server.broadcast('&eTimer stopped.', null, 'announce') + } + const seconds = Number(args[0]) + if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600) return usage() + if (countdown) ctx.clearTimer(countdown) + const label = text.sanitize(args.slice(1).join(' ')) || 'Time left' + let left = seconds + const tick = () => { + if (left <= 0) { + ctx.clearTimer(countdown); countdown = null + return server.broadcast(`&e${label}: &cTime's up!`, null, 'announce') + } + if (left <= 10 || left % 10 === 0 || left === seconds) server.broadcast(`&e${label}: &f${text.formatDuration(left * 1000)}`, null, 'announce') + left-- + } + tick() + countdown = ctx.setInterval(tick, 1000) + } + }) + + ctx.command({ + name: 'pronouns', + category: 'essentials', + usage: '/pronouns [player]', + description: 'Sets the pronouns shown in /whois, like they/them', + run (player, args, { usage }) { + if (!args.length) return usage() + let record = player.record + if (args[1]) { + if (!isOp(player)) throw new CommandError('Only operators can set other people\'s pronouns.') + record = server.playerDB.find(args[1]) + if (!record) throw new CommandError('Player not found.') + } + if (!record) throw new CommandError('Give a player name.') + const value = args[0].toLowerCase() === 'clear' ? null : text.stripColors(args[0]).slice(0, 24) + if (value && !/^[A-Za-z]+(\/[A-Za-z]+){0,3}$/.test(value)) throw new CommandError('Use something like she/her, he/him, they/them or any/all.') + record.pronouns = value + server.playerDB.save() + player.message(value ? `&ePronouns set to &f${value}&e.` : '&ePronouns cleared.') + } + }) + + const leave = (player, reason) => player.kick(reason) + ctx.command({ + name: 'quit', + category: 'essentials', + usage: '/quit [message]', + description: 'Leaves the server with a message', + inGame: true, + run (player, args) { leave(player, text.stripColors(args.join(' ')).slice(0, 60) || 'Left the game') } + }) + ctx.command({ + name: 'ragequit', + aliases: ['rq'], + category: 'essentials', + usage: '/ragequit', + description: 'Leaves the server in a hurry', + inGame: true, + run (player) { + server.broadcast(`${player.coloredName} &cragequit!`) + leave(player, 'RAGEQUIT!!') + } + }) + + return { pronounsOf: record => (record && record.pronouns) || null } +} diff --git a/plugins/core-moderation/alts.js b/plugins/core-moderation/alts.js new file mode 100644 index 0000000..fcd41c2 --- /dev/null +++ b/plugins/core-moderation/alts.js @@ -0,0 +1,22 @@ +'use strict' + +// /alts: accounts that have used the same IP address (MCGalaxy's /clones) + +module.exports = function alts (ctx, { findRecord }) { + const { server } = ctx + ctx.command({ + name: 'alts', + category: 'moderation', + rank: 'Operator', + usage: '/alts ', + description: 'Lists other accounts that joined from the same IP addresses', + run (player, args, { usage }) { + if (!args[0]) return usage() + const record = findRecord(args[0]) + const ips = new Set(record.ips || []) + const others = server.playerDB.all().filter(r => r.name.toLowerCase() !== record.name.toLowerCase() && (r.ips || []).some(ip => ips.has(ip))) + if (!others.length) return player.message(`&eNo other accounts share an IP with ${record.name}.`) + player.message(`&eAccounts sharing an IP with ${record.name}: &f${others.map(r => r.name + (r.ban ? ' &c(banned)&f' : '')).join(', ')}`) + } + }) +} diff --git a/plugins/core-worlds/index.js b/plugins/core-worlds/index.js index c414176..f14e937 100644 --- a/plugins/core-worlds/index.js +++ b/plugins/core-worlds/index.js @@ -2,6 +2,9 @@ // Multiple levels: create, load, visit, import, backup and customize environment (colors, texture pack, weather). +const fs = require('fs') +const path = require('path') + const PRESETS = { normal: { skyColor: null, cloudColor: null, fogColor: null, shadowColor: null, sunColor: null, skyboxColor: null }, sunny: { skyColor: '70a3f7', cloudColor: 'ffffff', fogColor: 'ffffff', shadowColor: '9b9b9b', sunColor: 'ffffff' }, @@ -254,6 +257,8 @@ module.exports = { player.message(`&7 Build: ${rankName(level.buildRank)}&7+, visit: ${rankName(level.visitRank)}&7+, players here: &f${level.players.length}`) if (level.owners.length) player.message(`&7 Owners: &f${level.owners.join(', ')}`) player.message(`&7 MOTD: &f${level.motd}`) + const rating = level.meta.ratings + if (rating && (rating.likes.length || rating.dislikes.length)) player.message(`&7 Rating: &a${rating.likes.length} likes&7, &c${rating.dislikes.length} dislikes`) const e = level.env player.message(`&7 Texture: &f${e.texture || 'default'}&7, weather: &f${['sun', 'rain', 'snow'][e.weather]}`) player.message(`&7 Custom blocks: &f${Object.keys(level.blockDefs).length}&7 level, &f${Object.keys(server.blockDefs).length}&7 global`) @@ -321,6 +326,7 @@ module.exports = { }) require('./more')(ctx, { levelArg }) + require('./ratings')(ctx) // backups @@ -448,13 +454,24 @@ module.exports = { aliases: ['tex', 'texturepack'], category: 'world', rank: 'Operator', - usage: '/texture [global]', + usage: '/texture [global]', description: 'Sets the texture pack (.zip) or terrain (.png) of your level, or of all levels with "global"', + help: ['Packs you put in the texpacks/ folder are downloaded from the server itself: /texture mypack.zip', '(set publicAddress in config/server.json to your server\'s domain or IP first)'], inGame: true, run (player, args, { usage }) { if (!args[0]) return usage() + if (args[0].toLowerCase() === 'list') { + const dir = path.join(server.root, 'texpacks') + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /\.(zip|png)$/i.test(f)) : [] + return player.message(files.length ? `&eTexture packs in texpacks/: &f${files.join(', ')}` : '&eThe texpacks/ folder is empty.') + } const reset = args[0].toLowerCase() === 'reset' - const url = reset ? '' : args[0] + let url = reset ? '' : args[0] + if (!reset && !/^https?:\/\//i.test(url) && /^[A-Za-z0-9_.-]+\.(zip|png)$/i.test(url)) { + if (!fs.existsSync(path.join(server.root, 'texpacks', url))) throw new CommandError(`texpacks/${url} doesn't exist.`) + url = server.texturePackUrl(url) + if (!url) throw new CommandError('Set "publicAddress" in config/server.json (your domain or IP) to serve texture packs from this server.') + } if (!reset) { if (!/^https?:\/\//i.test(url)) throw new CommandError('The URL must start with http:// or https://') if (!/\.(zip|png)(\?.*)?$/i.test(url)) player.message('&eWarning: texture URLs normally end in .zip (texture pack) or .png (terrain).') diff --git a/plugins/core-worlds/ratings.js b/plugins/core-worlds/ratings.js new file mode 100644 index 0000000..72f19da --- /dev/null +++ b/plugins/core-worlds/ratings.js @@ -0,0 +1,47 @@ +'use strict' + +// /like /dislike (level ratings, shown in /mapinfo) and /autoload, as in MCGalaxy + +module.exports = function ratings (ctx) { + const { server, CommandError } = ctx + + const ratingsOf = level => (level.meta.ratings = level.meta.ratings || { likes: [], dislikes: [] }) + const rate = (player, like) => { + const level = player.level + const r = ratingsOf(level) + const name = player.name.toLowerCase() + r.likes = r.likes.filter(n => n !== name) + r.dislikes = r.dislikes.filter(n => n !== name) + ;(like ? r.likes : r.dislikes).push(name) + level.dirty = true + player.message(`&eYou ${like ? '&aliked' : '&cdisliked'}&e ${level.name}. It has &a${r.likes.length}&e likes and &c${r.dislikes.length}&e dislikes.`) + } + ctx.command({ name: 'like', category: 'world', usage: '/like', description: 'Rates the level you are in', inGame: true, run (p) { rate(p, true) } }) + ctx.command({ name: 'dislike', category: 'world', usage: '/dislike', description: 'Rates the level you are in', inGame: true, run (p) { rate(p, false) } }) + + ctx.command({ + name: 'autoload', + category: 'world', + rank: 'Admin', + usage: '/autoload [on|off] | /autoload list', + description: 'Levels loaded when the server starts, besides the main one', + run (player, args, { usage }) { + const list = server.config.autoloadLevels || (server.config.autoloadLevels = []) + if (!args[0]) return usage() + if (args[0].toLowerCase() === 'list') { + return player.message(list.length ? `&eLoaded at startup: &f${list.join(', ')}` : '&eNo levels are loaded at startup besides the main one.') + } + const name = server.levels.resolveName(args[0]) + if (!name) throw new CommandError(`There is no level called ${args[0]}.`) + const on = (args[1] || 'on').toLowerCase() !== 'off' + const i = list.findIndex(n => n.toLowerCase() === name.toLowerCase()) + if (on && i === -1) list.push(name) + if (!on && i !== -1) list.splice(i, 1) + server.saveConfig() + if (on && !server.levels.get(name)) server.levels.load(name) + player.message(on ? `&e${name} is loaded at startup.` : `&e${name} is no longer loaded at startup.`) + } + }) + + return { ratingsOf } +} From 5b8fefe5e2b695300ce3770bb3df48b64726550a Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:23:22 +0000 Subject: [PATCH 3/8] Serve texture packs on the game port, add a biomes generator Files in texpacks/ are served over HTTP on the same port as the game, and /texture mypack.zip points players at them (publicAddress, or the Pterodactyl allocation). The biomes generator makes plains, forests, deserts and snowy mountains with rivers, caves and ores. --- lib/level/biomes.js | 198 ++++++++++++++++++++++++++++++++++++++ lib/level/generators.js | 9 ++ lib/network/connection.js | 2 + lib/network/websocket.js | 17 +++- 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 lib/level/biomes.js diff --git a/lib/level/biomes.js b/lib/level/biomes.js new file mode 100644 index 0000000..0048af2 --- /dev/null +++ b/lib/level/biomes.js @@ -0,0 +1,198 @@ +'use strict' + +// "biomes" generator: rolling plains, forests, deserts and snowy mountains, with rivers, beaches, +// caves and ores. Seeded, so the same seed gives the same world. + +// block ids +const B = { air: 0, stone: 1, grass: 2, dirt: 3, cobble: 4, sapling: 6, bedrock: 7, water: 9, lava: 11, sand: 12, gravel: 13, goldOre: 14, ironOre: 15, coalOre: 16, log: 17, leaves: 18, green: 25, dandelion: 37, rose: 38, brownMush: 39, redMush: 40, mossy: 48, sandstone: 52, snow: 53, ice: 60 } + +function generate (level, { seed, rand, makeNoise }) { + const { width, height, length } = level + const sea = Math.floor(height * 0.42) + const continent = makeNoise(rand) + const detail = makeNoise(rand) + const mountains = makeNoise(rand) + const ridges = makeNoise(rand) + const rivers = makeNoise(rand) + const heat = makeNoise(rand) + const wet = makeNoise(rand) + + const heights = new Int16Array(width * length) + const biome = new Uint8Array(width * length) // 0 plains, 1 forest, 2 desert, 3 snow, 4 water + const set = (x, y, z, b) => level.setBlockRaw(x, y, z, b) + const get = (x, y, z) => level.getBlock(x, y, z) + + for (let x = 0; x < width; x++) { + for (let z = 0; z < length; z++) { + const base = continent(x / 180, z / 180, 4) * 0.8 + detail(x / 40, z / 40, 3) * 0.2 + const mask = Math.max(0, Math.min(1, (mountains(x / 140, z / 140, 3) + 0.05) * 2.2)) ** 2 + const ridge = 1 - Math.abs(ridges(x / 55, z / 55, 4)) + let h = sea + base * height * 0.2 + mask * ridge * height * 0.42 + // rivers cut through everything but the high mountains + const r = Math.abs(rivers(x / 160, z / 160, 3)) + if (r < 0.04 && h < sea + height * 0.25) h = Math.min(h, sea - 1 - (0.04 - r) * 60 + r * 40) + h = Math.max(4, Math.min(height - 12, Math.floor(h))) + heights[x + z * width] = h + + const temp = heat(x / 220, z / 220, 2) - Math.max(0, h - sea) / height * 1.6 + const moist = wet(x / 160, z / 160, 2) + let kind = 0 + if (h < sea) kind = 4 + else if (temp < -0.32 || h > sea + height * 0.3) kind = 3 + else if (temp > 0.22 && moist < 0) kind = 2 + else if (moist > 0.08) kind = 1 + biome[x + z * width] = kind + + // column: bedrock, stone with ores, soil, surface + set(x, 0, z, B.bedrock) + if (rand() < 0.5) set(x, 1, z, B.bedrock) + for (let y = 1; y <= h; y++) { + if (get(x, y, z) === B.bedrock) continue + let b = B.stone + const depth = h - y + const beach = h <= sea + 1 && h >= sea - 2 && kind !== 3 + if (kind === 2 || beach) b = depth < 3 ? B.sand : depth < 6 ? B.sandstone : B.stone + else if (kind === 3 && h > sea + height * 0.3) b = depth === 0 ? B.snow : B.stone + else if (kind === 4) b = depth < 2 ? (rand() < 0.3 ? B.gravel : B.sand) : depth < 4 ? B.dirt : B.stone + else if (depth === 0) b = kind === 3 ? B.snow : B.grass + else if (depth < 4) b = B.dirt + if (b === B.stone) { + const roll = rand() + if (roll < 0.012 && y < sea + 20) b = B.coalOre + else if (roll < 0.018 && y < sea - 4) b = B.ironOre + else if (roll < 0.0205 && y < sea / 2) b = B.goldOre + else if (roll < 0.03) b = B.gravel + } + set(x, y, z, b) + } + // water, frozen on top in cold places + for (let y = h + 1; y <= sea - 1; y++) set(x, y, z, B.water) + if (h < sea - 1 && temp < -0.32) set(x, sea - 1, z, B.ice) + } + } + + // caves: worms that wander underground and carve tunnels + const worms = Math.floor(width * length / 1800) + for (let w = 0; w < worms; w++) { + let x = rand() * width; let z = rand() * length + let y = 6 + rand() * (heights[Math.floor(x) + Math.floor(z) * width] - 14) + let yaw = rand() * Math.PI * 2; let pitch = (rand() - 0.5) * 0.4 + const steps = 60 + Math.floor(rand() * 120) + for (let s = 0; s < steps; s++) { + yaw += (rand() - 0.5) * 0.5 + pitch = Math.max(-0.5, Math.min(0.5, pitch + (rand() - 0.5) * 0.2)) + x += Math.cos(yaw) * Math.cos(pitch); z += Math.sin(yaw) * Math.cos(pitch); y += Math.sin(pitch) + if (x < 2 || z < 2 || x >= width - 2 || z >= length - 2 || y < 3) break + const r = 1.3 + Math.sin(s / 9) * 0.6 + rand() * 0.5 + for (let dx = -3; dx <= 3; dx++) { + for (let dy = -3; dy <= 3; dy++) { + for (let dz = -3; dz <= 3; dz++) { + if (dx * dx + dy * dy + dz * dz > r * r) continue + const cx = Math.floor(x + dx); const cy = Math.floor(y + dy); const cz = Math.floor(z + dz) + if (cx < 0 || cz < 0 || cx >= width || cz >= length || cy < 2) continue + const top = heights[cx + cz * width] + if (cy > top - 3 || top < sea) continue // keep the surface and the sea floor whole + set(cx, cy, cz, cy <= 5 ? B.lava : B.air) + } + } + } + } + } + + // plants + const surface = (x, z) => heights[x + z * width] + for (let x = 3; x < width - 3; x++) { + for (let z = 3; z < length - 3; z++) { + const h = surface(x, z) + const kind = biome[x + z * width] + const top = get(x, h, z) + if (get(x, h + 1, z) !== 0 || h + 12 >= height) continue + const roll = rand() + if (top === B.grass) { + const treeChance = kind === 1 ? 0.03 : 0.0025 + if (roll < treeChance) { + const pick = rand() + if (kind === 1 && pick < 0.35) pine(level, x, h + 1, z, rand) + else if (pick < 0.12) bigOak(level, x, h + 1, z, rand) + else oak(level, x, h + 1, z, rand) + } else if (roll < treeChance + 0.05) { + set(x, h + 1, z, kind === 1 && rand() < 0.3 ? (rand() < 0.5 ? B.brownMush : B.redMush) : rand() < 0.75 ? B.sapling : rand() < 0.5 ? B.rose : B.dandelion) + } + } else if (top === B.snow && kind === 3 && roll < 0.008 && h < sea + height * 0.3) { + pine(level, x, h + 1, z, rand, true) + } else if (top === B.sand && kind === 2 && roll < 0.004) { + const tall = 1 + Math.floor(rand() * 3) + for (let i = 1; i <= tall; i++) set(x, h + i, z, B.green) // cactus + } + } + } + + // spawn on dry, mild land close to the middle + let best = null + for (let r = 0; r < Math.max(width, length) / 2 && !best; r += 2) { + for (let a = 0; a < 32 && !best; a++) { + const x = Math.floor(width / 2 + Math.cos(a / 32 * Math.PI * 2) * r) + const z = Math.floor(length / 2 + Math.sin(a / 32 * Math.PI * 2) * r) + if (x < 4 || z < 4 || x >= width - 4 || z >= length - 4) continue + const h = surface(x, z) + if ((biome[x + z * width] === 0 || biome[x + z * width] === 1) && h > sea + 1 && h < sea + height * 0.15) best = { x, z, h } + } + } + if (!best) best = { x: Math.floor(width / 2), z: Math.floor(length / 2), h: Math.max(sea, surface(Math.floor(width / 2), Math.floor(length / 2))) } + level.spawn = { x: best.x + 0.5, y: best.h + 1, z: best.z + 0.5, yaw: 0, pitch: 0 } + level.meta.seed = seed +} + +function leafBlob (level, cx, cy, cz, r, rand) { + for (let dx = -r; dx <= r; dx++) { + for (let dy = -r; dy <= r; dy++) { + for (let dz = -r; dz <= r; dz++) { + const d = dx * dx + dy * dy * 1.6 + dz * dz + if (d > r * r + 0.5 || (d > (r - 0.5) * (r - 0.5) && rand() < 0.35)) continue + const x = cx + dx; const y = cy + dy; const z = cz + dz + if (level.inBounds(x, y, z) && level.getBlock(x, y, z) === 0) level.setBlockRaw(x, y, z, B.leaves) + } + } + } +} + +function oak (level, x, y, z, rand) { + const h = 4 + Math.floor(rand() * 3) + for (let i = 0; i < h; i++) level.setBlockRaw(x, y + i, z, B.log) + leafBlob(level, x, y + h - 1, z, 2, rand) +} + +function bigOak (level, x, y, z, rand) { + const h = 7 + Math.floor(rand() * 4) + for (let i = 0; i < h; i++) level.setBlockRaw(x, y + i, z, B.log) + leafBlob(level, x, y + h, z, 3, rand) + for (let b = 0; b < 3; b++) { + const dx = Math.round((rand() - 0.5) * 6); const dz = Math.round((rand() - 0.5) * 6) + const by = y + h - 3 + Math.floor(rand() * 2) + for (let s = 1; s <= 2; s++) { + const bx = x + Math.round(dx * s / 3); const bz = z + Math.round(dz * s / 3) + if (level.inBounds(bx, by + s, bz)) level.setBlockRaw(bx, by + s, bz, B.log) + } + leafBlob(level, x + dx, by + 3, z + dz, 2, rand) + } +} + +function pine (level, x, y, z, rand, snowy = false) { + const h = 7 + Math.floor(rand() * 5) + for (let i = 0; i < h; i++) level.setBlockRaw(x, y + i, z, B.log) + for (let i = 2; i <= h; i++) { + const r = Math.max(0, Math.round((h - i) / 3.2 + (i % 2 ? 0.4 : 0))) + for (let dx = -r; dx <= r; dx++) { + for (let dz = -r; dz <= r; dz++) { + if (Math.abs(dx) + Math.abs(dz) > r + (r > 1 ? 1 : 0)) continue + const lx = x + dx; const lz = z + dz; const ly = y + i + if (!level.inBounds(lx, ly, lz) || level.getBlock(lx, ly, lz) !== 0) continue + level.setBlockRaw(lx, ly, lz, B.leaves) + if (snowy && level.inBounds(lx, ly + 1, lz) && level.getBlock(lx, ly + 1, lz) === 0 && rand() < 0.6) level.setBlockRaw(lx, ly + 1, lz, B.snow) + } + } + } + if (level.inBounds(x, y + h + 1, z)) level.setBlockRaw(x, y + h + 1, z, B.leaves) +} + +module.exports = { generate } diff --git a/lib/level/generators.js b/lib/level/generators.js index b38de65..542c096 100644 --- a/lib/level/generators.js +++ b/lib/level/generators.js @@ -220,7 +220,16 @@ function tree (level, x, y, z, rand) { for (let dy = 0; dy < trunk; dy++) level.setBlockRaw(x, y + dy, z, 17) } +generators.biomes = { + description: 'Plains, forests, deserts and snowy mountains with rivers, caves and ores (seeded)', + generate (level, opts = {}) { + const seed = opts.seed ?? Date.now() + require('./biomes').generate(level, { seed, rand: mulberry32(seed), makeNoise }) + } +} + // aliases +generators.nature = generators.biomes generators.natural = generators.terrain generators.hills = generators.terrain generators.water = generators.ocean diff --git a/lib/network/connection.js b/lib/network/connection.js index 95e00f4..646fc06 100644 --- a/lib/network/connection.js +++ b/lib/network/connection.js @@ -22,6 +22,7 @@ class Connection extends EventEmitter { this.socketIp = (socket.remoteAddress || '').replace(/^::ffff:/, '') this.forwardedFor = null // X-Forwarded-For of web clients behind a proxy (only trusted if configured) this.isWebSocket = false + this.httpHandler = null // (method, path) => { type, body } | null, for plain HTTP requests socket.setNoDelay(true) socket.on('data', data => this._onSocketData(data)) @@ -37,6 +38,7 @@ class Connection extends EventEmitter { if (data[0] === 0x47) { this.isWebSocket = true this.transport = new WebSocketTransport(this.socket) + this.transport.onHttp = this.httpHandler || null this.transport.on('data', d => this._onData(d)) } } diff --git a/lib/network/websocket.js b/lib/network/websocket.js index b0d64b5..b0bdd85 100644 --- a/lib/network/websocket.js +++ b/lib/network/websocket.js @@ -41,7 +41,22 @@ class WebSocketTransport extends EventEmitter { const key = headers['sec-websocket-key'] if (!key || !/websocket/i.test(headers.upgrade || '')) { - this.socket.end('HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nThis is a ClassiCube server. Connect with the game client.\r\n') + // a plain HTTP request: texture packs are served from here, anything else gets a short note + const [method, target] = head.split('\r\n')[0].split(' ') + const file = this.onHttp ? this.onHttp(method, target || '/') : null + if (file) { + this.socket.write([ + 'HTTP/1.1 200 OK', + `Content-Type: ${file.type}`, + `Content-Length: ${file.body.length}`, + 'Access-Control-Allow-Origin: *', + 'Cache-Control: public, max-age=300', + 'Connection: close' + ].join('\r\n') + '\r\n\r\n') + this.socket.end(method === 'HEAD' ? undefined : file.body) + } else { + this.socket.end('HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nThis is a ClassiCube server. Connect with the game client.\r\n') + } return false } From 81a5fba6128aee690f91f19780584233564d772f Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:23:22 +0000 Subject: [PATCH 4/8] Document Pterodactyl hosting, add the banner to the README Also reads the port from SERVER_PORT and lets the panel handle /restart. --- .gitignore | 1 + CHANGELOG.md | 14 +++++++ README.md | 40 +++++++++++++++--- docs/CONFIGURATION.md | 6 ++- docs/PLUGINS.md | 2 + docs/images/banner.png | Bin 0 -> 74778 bytes test/level.test.js | 2 +- test/server.test.js | 90 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 docs/images/banner.png diff --git a/.gitignore b/.gitignore index c6a9f9e..6b10c63 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ config/ data/ logs/ levels/ +texpacks/ plugins/.removed/ # editors and OS files diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d91ec..9eafd07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +### Added +- MCGalaxy's command shortcuts and aliases (`lib/commands/shortcuts.js`), including ones that carry arguments, + like `/zadd` for `/zone add` or `/cw` for `/cuboid wire`. `/v` is now `/paste` and `/pl` is `/place`, as in + MCGalaxy (`/vanish` keeps `/hide`, `/plugins` keeps `/plist`). +- `/main ` sets the main level; `/autoload` and `autoloadLevels` load more levels at startup. +- New commands from MCGalaxy: `/vote` (`/yes`, `/no`), `/timer`, `/pronouns` (shown in `/whois`), `/quit`, + `/ragequit`, `/alts`, `/like` and `/dislike` (shown in `/mapinfo`), `/replacenot`, `/triangle`, `/delete` + and `/static`. +- `biomes` generator: plains, forests, deserts and snowy mountains with rivers, caves and ores. +- Texture packs in `texpacks/` are served over HTTP on the game port; `/texture mypack.zip` uses them. + `publicAddress` sets the address players' clients download from. +- Pterodactyl: the port is read from `SERVER_PORT`, the allocation address is used for texture packs, and + `/restart` lets the panel start the server again. Setup steps in the README. + ## 2.1.0 (2026-10-05) ### Added diff --git a/README.md b/README.md index 6d84f9a..c33d45f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +

MCScript, a ClassiCube server written in JavaScript

+ # MCScript [![CI](https://github.com/perronosaurio/MCScript/actions/workflows/main.yml/badge.svg)](https://github.com/perronosaurio/MCScript/actions/workflows/main.yml) @@ -17,7 +19,7 @@ classicube.net and BetaCraft. - [Installing](#installing) - [Your first server](#your-first-server) - [Letting other people join](#letting-other-people-join) -- [Keeping it running](#keeping-it-running) +- [Keeping it running](#keeping-it-running) (including [Pterodactyl](#pterodactyl)) - [Updating](#updating) - [Features](#features) - [Bundled plugins](#bundled-plugins) @@ -123,6 +125,24 @@ pm2 save && pm2 startup The console isn't available as a service. Use the web panel plugin or join the game to run commands. +### Pterodactyl + +MCScript runs on Pterodactyl panels (and others like it) with a Node.js egg, such as the generic Node.js egg from +[parkervcp/eggs](https://github.com/parkervcp/eggs). + +1. **Create the server** with the Node.js egg. In **Startup**, pick a Docker image with Node.js 22 or newer + (for example `nodejs_22` or `nodejs_24`) and set the main file to `index.js`. +2. **Upload the server.** In **Files**, upload the release zip, right click it and choose **Unarchive**, then move + the contents of the `MCScript-...` folder to the top folder, so `index.js` and `package.json` sit next to + each other at `/home/container`. +3. **Start it.** The port comes from the panel's allocation (`SERVER_PORT`); you don't need to set it. The panel + console works like the normal console: type `/rank Name Owner` or any other command there. +4. Edit `config/server.json` from **Files** to set the name, MOTD and `owners`, and restart from the panel. + +`/restart` exits and lets the panel start the server again. The panel restarts crashed servers by default; if +yours doesn't, use the panel's Restart button. Texture packs in `texpacks/` use the allocation's address +automatically. + ### Backups Levels are backed up automatically to `levels/backups/`. `/restore` brings one back in-game. For a complete @@ -156,13 +176,21 @@ Protocol - Clients without an extension get sensible fallbacks, for example a replacement block for custom blocks. Levels -- Several loaded at once. Generators: `flat`, `empty`, `pixel`, `space`, `ocean`, `island`, `terrain` (seeded). +- Several loaded at once. Generators: `flat`, `empty`, `pixel`, `space`, `ocean`, `island`, `terrain` and `biomes` + (plains, forests, deserts and snowy mountains with rivers, caves and ores). All seeded. +- `/main ` picks the level players join in; `/autoload` keeps more levels loaded from startup. - ClassicWorld `.cw` files. MCGalaxy `.lvl` and the old `.dat` can be imported with `/import`. - Autosave, periodic backups with `/restore`. Deleted levels go to `levels/deleted`. - Per level: sky, fog, cloud and light colors, texture pack, weather, edge blocks, water level, MOTD hack flags (`-hax +fly`), build and visit ranks, owners, physics and lockdown. - Block history is written to disk, so `/about` and `/undoplayer` still work after a restart. +Texture packs: put a `.zip` in `texpacks/` and `/texture mypack.zip` serves it to players from the server itself, +on the game port. No web host needed (set `publicAddress` to your domain or IP). + +MCGalaxy habits work: the shortcuts and aliases from MCGalaxy are there (`/z`, `/ld`, `/gen`, `/wcopy`, `/zadd`, +`/cw`, `/v`, `/fg`, `/h`...). + Custom blocks: `/gb` for every level and `/lb` for one level. Name, textures per face, shape, collision, speed, sound, light, transparency, fog and fallback block. Presets included: invisible barrier, lamp, glass pane, ladder, carpet, slabs, speed pad and more. @@ -175,10 +203,10 @@ anti-grief. | Plugin | Commands | | --- | --- | -| core-essentials | `/spawn /main /tp /tphere /back /ascend /descend /tpa /kill /msg /reply /ignore /me /say /announce /rules /faq /news /view /players /whois /top /search /blocks /pclients /whonick /serverinfo /ping /where /time /model /modelscale /entityrot /skin /nick /color /title /tcolor /hold /reach /fly /afk /clear /roll /8ball /hug /high5 /send /inbox /loginmessage /logoutmessage /emotes /ccols /lastcmd` | -| core-moderation | `/rank /promote /demote /ranks /rankinfo /temprank /kick /warn /ban /unban /baninfo /banedit /banip /unbanip /xban /bans /mute /unmute /freeze /vanish /follow /p2p /patrol /moveall /moderate /voice /opchat /adminchat /rankmsg /report /whitelist /playeredit /limit /sudo /oprules` | -| core-worlds | `/newlvl /goto /levels /load /unload /save /deletelvl /copylvl /renamelvl /resizelvl /import /mapinfo /map /setspawn /backup /restore /lockdown /reload /fixgrass /unflood /env /weather /texture` | -| core-building | `/cuboid /replace /replaceall /line /sphere /spheroid /torus /pyramid /hollow /outline /fill /tree /maze /rainbow /drill /center /place /copy /paste /mirror /spin /write /mark /bind /mode /undo /undoplayer /redo /paint /about /measure /calculate` | +| core-essentials | `/spawn /main /tp /tphere /back /ascend /descend /tpa /kill /msg /reply /ignore /me /say /announce /rules /faq /news /view /players /whois /top /search /blocks /pclients /whonick /serverinfo /ping /where /time /model /modelscale /entityrot /skin /nick /color /title /tcolor /hold /reach /fly /afk /clear /roll /8ball /hug /high5 /send /inbox /loginmessage /logoutmessage /emotes /ccols /lastcmd /vote /yes /no /timer /pronouns /quit /ragequit` | +| core-moderation | `/rank /promote /demote /ranks /rankinfo /temprank /kick /warn /ban /unban /baninfo /banedit /banip /unbanip /xban /bans /mute /unmute /freeze /vanish /follow /p2p /patrol /moveall /moderate /voice /opchat /adminchat /rankmsg /report /whitelist /playeredit /limit /sudo /oprules /alts` | +| core-worlds | `/newlvl /goto /levels /load /unload /save /deletelvl /copylvl /renamelvl /resizelvl /import /mapinfo /map /setspawn /backup /restore /lockdown /reload /fixgrass /unflood /env /weather /texture /like /dislike /autoload` | +| core-building | `/cuboid /replace /replaceall /line /sphere /spheroid /torus /pyramid /hollow /outline /fill /tree /maze /rainbow /drill /center /place /copy /paste /mirror /spin /write /mark /bind /mode /undo /undoplayer /redo /paint /about /measure /calculate /replacenot /triangle /delete /static` | | core-blocks | `/gb /lb` | | warps | `/warp /home` | | zones | `/zone` (protected areas, shown in the client) | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0330bcc..7fea0c3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -21,12 +21,14 @@ write its own copy over your changes on shutdown. | `extraHeartbeats` | `[]` | More server lists to announce the server on, such as BetaCraft. See below | | `allowWebClient` | `true` | Accept the browser client (WebSocket on the same port) | | `trustProxy` | `false` | Use the `X-Forwarded-For` header as the player IP. Only turn this on behind your own reverse proxy | -| `mainLevel` | `main` | Level players spawn in | +| `mainLevel` | `main` | Level players spawn in. `/main ` changes it | +| `autoloadLevels` | `[]` | More levels to load at startup. `/autoload ` adds one | | `defaultRank` | `Guest` | Rank given to new players | | `owners` | `[]` | Names that always get the highest rank | | `welcomeMessage` | | Sent to players when they join. `{player}` is replaced with their name | | `rules` | three lines | Shown by `/rules` | | `defaultTexture` | `""` | Texture pack URL for levels that don't set their own | +| `publicAddress` | `""` | Your server's domain or IP (and `:port` if needed), used to serve packs from `texpacks/`. On Pterodactyl it is detected | | `autosaveMinutes` | `5` | `0` turns autosave off | | `backupMinutes` | `30` | How often changed levels are backed up. `0` turns backups off | | `backupsToKeep` | `10` | Backups kept per level | @@ -46,7 +48,7 @@ containers and hosting panels. | Variable | Key | | --- | --- | -| `PORT` | `port` | +| `PORT` or `SERVER_PORT` | `port` (`SERVER_PORT` is what Pterodactyl sets) | | `HOST` | `host` | | `SERVER_NAME` | `name` | | `MOTD` | `motd` | diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index f5e975b..fe2a8a1 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -63,6 +63,8 @@ ctx.command({ }) ``` +An alias can carry arguments: with `aliases: ['zadd add']`, typing `/zadd x` runs the command as `/zone add x`. + `run` can be `async` (for example to wait for the player to mark blocks). Owners can change the rank of any command with `/cmdset `. diff --git a/docs/images/banner.png b/docs/images/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..f019f19930928f41f2e895fe4f0d4f92d344f899 GIT binary patch literal 74778 zcmaHT1z40#_r8D%k|HIoba$uHjdU;4BH)tJi%G-Mxh$_zl1m9Fu}Dfwmvnc>lK%(Z z`22jo-~YOHW@E1Bd1mIEbD#U1nc0vh8j5(>6xcUz+`v;-lGDC%;}-A{{S_8E@TL|m zb9m#%-5bhsk9ECZ>$NzqbO+H3jVsZ%cTa zX{)k?HwIIYu_NPYIHlIU?aCpXpqf?qT*bqoOYTsmBa_bK~oJTZ|0Pb z3+0i?9FlK_cXn9xWPpoi{`Kl$D%j=jF$o}`^75YxzTxaS`&&iMA=!>h){l6NfdEqx z-q_Umt&(`l*g?r>wftiu+aLhvgDmFWe+oD0o@9%7;0IaHRM0u+JACTSpnpoOVW*vP z`&(4`JrQT&>E9lD{WX~*nUNh)^u50_C57VOJqLd2VB+Y;yMY;rbpvyckbL^7l=F3Y z@wCpJp!j6-Y3{so|6l#aU!>=&|K>M+T_&3R>~AGm;G#+%W8&A;xyaoRNrHX{Zt@!A zs(2FZE#y4f3#<_>y!m%C0hLJ7pQTDXsVCc}&7RSD`XGWH{hym&-F)JkpUbVJVSX#V z<@(pdqQ7^n$WkibNJ*5J+Ywn9(o%|)_-`%M4s)NZ)V+?LkEgL>{W(hh`gl5o(!T>Bm!%Z@8S5FRU1RqD3H#(60Qknj@Yw$ubd@-u z6TenFxCtCRVdi<>Wtn@ossDkjUxqFS481S$c>*r=*A92N8)8WW)PI++4oN*Je>$w+ z!K%@3f%S)qEpXA=?-!XQaTna!i)m2(!!+jO)hzr=g+oxYlJzym%=G@vN@0zeR}Mh% z_wg55)>WZ*2Al8P`?QLSh6y_Q*IfW47G$<nq&b7z%#zOc9p+$)i-I& zA`_Oxv1|Jm82*t&tL#dM4PMEscZC2Fg#r@w%=xW?FN~M_zOuo&0_@t~S=}|kGUC9~ zd@U3g2bei10GP#}X}?uoq^g>+Jvlaw65N+M__c)Bn79!Agm_E6PNAff@9+xHO5m}m zB;n~wu5(hCZqFIORexTqWa(`6yM+{762{q+oa-Gq_R{SLInYjsnR(+0KxX?Xr@m@% zOY6slbyfh4AA9MmPA*qUT!AqsbgI;A;BGIN+!r9Wsf65Bj3V%NQz`qY-(pDgcDDX+ zx_CuYDR|r1oBt;>Gi&rCUAptCn&KMm{-izE)F%){!^*#iXT;~eyxB{Lw)|Ae{hz{R zS?5Pk9DdJznXCWbO<&Q&|J&1Q4SBSwaAQ54cT7s$vr_**sHG%sE~!LM6r3E;-OGRJ zZV0MfvR*{-HwFZtSdHOp<)Tt&U-m?(o7mEAeSYH=p z(X_>}YX#^(_3uu`fHyt9uBpyYa1eA8-SE7EPfzCGD)={TB&LX<>TUd*i+`Cm z;a=C#4S;Pj{mwSo7Q@)U%3x!6toMTess zlx5a#0Ast3%Ki=!uHHR)resxY0k8iT9sg6Ke~BZ6dD9tpz>Jhk&Bj;JkHhud|2z5t z++2$(`CoCwuM85P^4{3xyjQ+m@PGOC6&}QyQ+Et-+koG13s8N1r64^Vw<`wtZ;DR0 z*rEA)alz5BH{yRqPJn%)Sbv27>h}M3YFF=74oOoy{)cCBbdUR{7*vh^;`rW3`Q6PO zAEF&OR-c77EiO!ixdklb;$-jF)yuCq^>C&*6 zoks36fV}}Pm6`KeqQ7$i=vP6)pD*2)E@@NQEea2b5)e^~gYlLMwcsjuuZ$i{;L%1% zc}HcP3`FPOl#;kr%lit$Ol~7>_E_fBrOcDBzOFfTSH7L|x(9LS29v7xcnOF}(PWi| zP16A2`r-^54ulhHE!>NE08D-q2{b=0q(ok!EBq|)=tfw>vRESk*8ht(RAJu#A70FC zCUH3Q8rq}s?Z-0)#49fxvjphmdnMtwQJpFws|YRR zN@K>OZ{*^~h2qGhbf9B6M`0*z$XeS~_X^37H(6ib=jXyx#(WqbQ*$WaTV{iG6aDq0 z2Y0bUS&08gO#IeZScO zb{YaAJJimq{T=7&g{J%*#`@QZLhCTt^GDaU*B=XD6aDrZ?8Y-n|jWAdmkvXdH3y$HF(2g zzk(z;=9P54+um6T)Gmv}S6!qW=06y0Fx*H}78EFbHO%m<^Lb%YSB_pF6|cyK=Tke~ zH)bq9)|i{MFJ-7O=v~A$XX;z^W;UH(~UwnBr z6y<1~0Mj4h;5suQMOqt5mzOTRd#NPGy`qpgeYhQ`pW?!W(VcjoJ9SFtD8mJMUv-V* zd$H8|K!(##=LH$%Rm{zozu@qu)Ro7mfs@BRMmU**=D3W`VVP@3ajQZMwog_m=tF!0 zeVWc6uY$DpYg}U7wGr+H9@KVx)4G;5j*zrwby|Yt!zveoJcs5*$xjH}#7O&HF=utC z+_YZs^_2ffgsU8qd!CbOYpC6>4RnF$^bK$;MyWP8%RlyC_+bcz+3fU%e#-6;QNFy} zL+P&V${(X*#(i@sR9BPwSg^$iDgmCy`%Kt zXoy^9)vSqWahuI8w8dFNtVbJ@p4dn5_gt4zolT*B2}A8|m;N#EhXc3vWDGn_qb)}5 zjJ8yL{K0%Togo_Xg*-=;<4wd*qjqHxJ43C`km247Hoh0+`2{<1dv-R^XTCuKY2}Va zlVvBMA-6e!z$D}EMz8?avHk&%r|T}$8oxSpPtH9fR>_L?R0ljwvr`Wcl1A`WrRf$U z-@HsVeDuS-b&W4NG% z5hnw7I8csn%1uw^h5GF&lI<1}E3GHlr3%g~0c;BAuFm^E2cM-whKoL~3Mz>y`kWi% zS&o#Qaw_}H=Oa3h=6U1Xv5H+(;4vg!cjic#`ThGb-Bb!M^gs~)X{y!|N5WOK|5j_2 z#WI!d?D;NNF1sHNXo-8!H)}Hs?Zhx8vm8c`1E|*4I^+4I7GINrk$mTD;_$|TpJ9L7 zwCYLYa^(XUySC)PtijZ<%Dm2@((OQ&{3dIWsc#Nb+5B>L(a|35G~98hRWau_Mbhn) z=3lZAKeIaR)LZvA2Sv>ix5)IIGR37sbSH{A5sS4U(xFkpW$_O zqH~}!+b~x}US*+BMWaevRW?~K8hU)6_W&*ZCyZB3-qPZ?a&pQu^@%=v?kO*Jpvpwq zZOzR&6J0myP`>m%pwqBFPHRhxcPN%vs?WbJ%7-j+CI-*6<{|IceN>aQ)Bp!>Xk2^% zhNgAi%KAt5A>La&=K4C__VI4SjbQat&i*sg+^JiDBgTcSSzSTvVzrZhxcQZ5=0#eZ ztVep$ePHrgV6UaooPmhKxjVTL0qlrg<*!VOPM`NM&$L=2)K<M|~G^ZqPU?mrI38@maK3B-peB`QHZdQrO{A=5oXYRJy-oeRr9;Eow9^;wlEv zg1Yme)h%=&P!dEn15wq%b#`H)w6)ha`>WYsh=Yj2p;6CvVv_*XFO1+?SkS-+^#?qVb^n1d4ZqAHNf{ zFkg$d?xd7nr|mr2xPTSCjwEL#`NW~*FDGa=;*l=LaEek?+l@dMu z|EFW~98ACLCgVD|s5~vdA97e@N$(^+GeS?aMAH;e7lqqU*?lS}BLA^0ayXeOrlp>@ zIdIs$Zc>qIJFoFq4z)J|xBOl& z&l5>&$zZ*UGv}{h4uAh%fFGA^iTa?QK4C=k_fX z)8PWsK}tlg7>#5yu_#dP82!k0La{O%BhGzNDN~qyBq9o0hwFXrzZs}@z9U7b%JlK) zu@aqB0)(MebMrohpPN`?F?L2AJJ!vX(4+BKHK`B}?CPdVYqOHJu)$KD+!nqF(=Kqb z9quuw?YZdEB=$KMkcv(i0(;k5)xDW|zGH&BIBZ#W1Nq`8^^5I8G<{C5JkK-(cTlUc z>H|4Sds=7K@xFJXL8C1qS#9q!C{377v1XYFHQbIRXwuWqV*N6LEEVw;bnqzVi0o*| zN8NPUFvvs9EWa(==R0Qnbl~ETa;bMd-@F{VAM%5}@IPMS`jg#70wd!dX-CSBtP1gw z1kFOlKIO`p%&PlNH{aPwwq@7`vZ^!n?9!ild?{Dkxg$_qZ|65Oy}+PAZrIwPHrG}! znU*C%Zm5XjGc}eh%F@EEo&6!xbIoE zZ7bwJ7A|-9I@~BW_fTFF+{`8v>+Eo<67<5&;89yZT$bl@q8yYF zkd8hb0A~1(yV}lXXG5p{%(RTcz?k5I&wgl-nd`ht$;k1s2(POr#M}hq$ND~#)uy=M%TIDRP zZ9~m@fj-@r<$QUcE1Vuhh1bJVfOl%S@^xyrn`YaDxR&8>t93eE=vYZZ`~VU?+o@YH zu%hQqjaJObOeF_|AHqI`!7-W7VzImP^)bTRv^FEgz!~Lt12N*Z=(G?b4^4^6DW1HU z+&pJ>2c1_*mV(icKyfVo*2nLi0>xTq3Buu;gMs|TB}EE!+j$MY8gW)<;uad|a9UjF zlQWtAHOi$-HN!^b7Gh*zWiYYwM60aZ}5sO$>QS@YYNQ|ZZi z!DmdYYl<)bT(af`qHy5xgGf1xw9be9qpWUDOu zciQQ=TDrBE%8T^4+!|`^sQTdsVhiCd+~2LKAkCrMtxdTbOhjnl?#;&&mB}n&g4k~l zU%;VZwT0?Qetd0k7nto|SGRgBSqpBcDt4-9s0D_933b-^F27gId~3-aX0X0MV#bQi zdJxQ(Gu#+P$u4qp_EFPAroGmBwaL&DSAC8?_4_HdrZySpgbWb919}QCo8M^Ddy=Qx z97<|Lct)=0v!NQLT!jO0@20fnk8MPAj7{U`>r<87jSbxo3OtfDu z2A9_);~Kd}%w^8iyxF@=DZdnv5L&zYfJ4@ndW7rmT!7?VrHhYzD#n|LRbiAA!~P@O*=%wDCRcqMzsa%VVN9Q2OS)A}aSBKPj5g1U}j&3$bjk ztqyMxZmEA1C|qJtVc^bJn)2M@oeY8#NPdBmF1}p$3V7RMEU=1$ls3KY{I&F6+t4|@ ziN+Mh@#WB2DpfMsS*n(XRU|J0#uxVC%_CTFi?a5yt?B7tk4#p2Zu@uVymLS^!~o^= zdR=9O_SXVALSRoaE{1W>-rFt9W#^N*WB!H@3$+2wiF}JMcNPzpJy(X$j0bnknXx|2 z7FwVP34Dh9y%u(|w#RGJf)?uo(VoolW!xpNQ9hC$9y+f`E5q#PiUQm2e!UGuc3l!L zwH=77E%WmeYN1^l?(L0|Wlsw}g4GH%@F* zWe`)x$I(lW*+^~9#kkzY&3*Y3)oCby-T=RKz0CoC(bsF?zFBv;cedYGC!{I-fO6d% zk2%)-){@$IcDbU={Wp)OLYjDaahMXcK4tRpy!QwvWKPQpAN<7H{7GVMU#G{g&*pm{ zYtE+qGbM&E?tW_()Z7JT=7Gucm$j%hTrH4O#Q;=}P8xX`UD^ZrI63I8eUzKSVJs3b zqfD)+{;W+S=ME=i#!9YRY)(LZs?2k0XhVDm>~)GFGcOws{MglVp}$!50=(vKJIh-T za4$6r2M?rh{NzP4&k3vWF0XJ+IwkqM6y-EsqpmzsahrWDQcem5>b=l!5_exF84eSV zWD~r~6NI(+5y+;z?@dA6Gxebc6Rs_}n|4Azf40%kW$6lBx`Xb*z?2=bR=L|zX(Pmk z-G9HO(@?>NBevaI2m}5awR=+57453B6cXUSv7n2KIvcesGOC8FNbs8m3}y)v=YEq- zhaB!<(y)$q3RU_i+@-Uq9oFb`Ft_bVdFaPwet^0>Cz$f~?Kn&vu0_p`4HMuI*uBvF zAUW&3IdTiHEYSxFAAbOP@kG}?xf476hu=ca4*ky$l0ScJ&NElX1W6YqOfgl?MKe7sf^l|e>q4)#4@DW^G-w6X@}qmCTk=tjCFW`f7m!yYtJKXBsXYrSzEFbI@&G%7d>1KN>yi1|w z7a+H0Ha7Ukk%>}AHCg)ZAh((KVhmS;O|Es{z(|$jisg)3$JFIU2cj0$p$H$To5N^z z>E7SivGX14*+0QxTo2CGAsVp~e-Bms(TeXDBsCv+$Z$Hu9NL|KkGDeVJccNDWasuF zkdp+@Fn;4c8-khxIZ_6@#?TiW>=6!?3S)(>PkS6hvW!A^7WL%XV4uNwN4P{cXVnBY zSwj#ZCA+RXA;S-x!^@={pFYS~j%#pc8x3a9GxZ;p&%ZoVS+NbYnFt$)I%Lc$hm5hV z;c=Y450eU*h)?WHAJ(uTMzp$Jh<3d1$>x+q`mC=cg8VaBB(b|~tLv7|>0H5dd>_VDNHkPMBdU!Fgc-ln+X<(p0X zhCcJ}H3XT3in!xVE9Zt^j(g=hk=w{8;tE+8>740H9X;@O@H36pMFZUBJ2Hf`?EyOu z@$EcDa>A~_xDEkccqe!v|jv+rx4{S@whLT=3Ok z+Xe<8*A0^VNPLYC?QQm9V()u`&QC8+Jzr2CKjYcJMz3~5`maT6C7mxp~Hf~t7B;21_? zh1v~InSBYfsPpSuV0^w8zS0WwIJw7`7xytu^((2=UaGm@+FF3w=(<7d+?ef6W=+1& z{OL4;pLv!he@Zx%BwFOroig%8{vmdDo`;qOQ_?F(KBrTT{D`$Ei|_z;+JNvn?LO}< z_oWLK?dglOiGXW4?Zv=9Y)qBn0CQ%#*YEZIHA9Jp-$zOW9QFZMZPESIok_=D;YrED z$GvX2h>HxX!il+=i;JvmG+2_;M;v^%mcNBu3c$I;tQVizo$%6k-pQi-Yi_QMO7iCM zXVzrJyZfcf^KyA}A|H(DR@CD3_MZnc?2q+bEuGapwo|twTGM7WTheiwf3kd->Bmk( z%&$_4{#t}2S@j-h%wY zG+^o00AtU{W@Iwp)1mn<keUF>`d`&w`RryE%rmRBJ@ zxB^%?CYPP;TsCr*oPm*an}9n~S6>4_2;mByUrSe(nkGD;3Xe}xUZ7;vrTB7E8WuXz z&7Vk$+E7d(rs;|F-te3_tX<4j$a~Q{=9r&@H{g+#n5XBJRWLkNu-clo@fo`knRlg& zhLRhYAlYkPsadlBNwj1iM5>cuN+NGs>+>0C)6?2CD5qG8Cqf932I-?TZ^`K!h*3R-cZy zD44%L1vVpn)kt=vLfjx*uG9un{VFG%eIj{_*C8PHqOSmsN>2=Xp{jQtwQqNDY;T}} zL8o?Q77+8Ma0{NskKX|L8gIq{?g9jDD;Zbzwo`e0~0EnFV2Y}JO?cOqV4kB z=6HJs-R>>7xxZ2G6eLsmq0?y_wKOb1Mx4aNXL9_D&A2ZuEfobmEzR*W=k2+-w0JZk zFBZcyay0H}zy-XupSI5rCAVs9N$k8!KHL%$a-}!edQ)bUj{M_QYb$C7S~cU}wnOjd z9``Zm%Q7M4j7M5Z22X0T|GR)d3?h0*prnh)-jz~3x6op@Q-Tym?gsrswo;H?Und_J zx)oRkVSfc^Lck74nhbgWI8!zm1=G_6VpGr-V@Bn$Z+?ZT0dkh ztSi*cR)pN5FuBwIP*jz#WLSA+KSbzD^A`AGmW=DTM-gJ6gi|ZkL&hZrRbh&pJF5jx z8aCNU(UM0L_`3C<;BD2^Y<5LQ)0ZZ;FqykcFs;$zuaApwYpGBI?!>6n%VM}8R%1n? zWbRm0Hyn<=P)?MlW9gpcdzp2-8L^{wz-*;P*TNr^tQEb~aMnTKNVuJ}y}{cmtN}~F zrJ1@5o0xX8E+U7iUZ?)CR|ClQo|N6lJW+Y4%m=$cIfc1BVLxMroQ94&G+4aFD-=eF-up- zWiGWV_pt2Spn28ur#sC7r_oH^#-IO~dx!jT5vGevP6?Glb?#m6Y+D84`zL@^rO3s~ zPu?5A{VfXrNH2B1091MBi(R@sN~Eafxs+{P?1DL$vOl<~Qkrb;9{!Ps*DHL#yO&SR z-q=q=rRpq<6&rb(y_jxtQZ!B<6WBx~-kQYtbC(2lB79&i^K;*cVa+Emmorz^sxzUC zbx41y!^R8j$ayhFDin89i4ty{t1c%_yl7jj>lAlZfJ3K;5k{j?EB*VywYF#GUrC-m z9rA;XO&fbb7TO*isTdY1mo3VQftp$EdgTxXL)!nlOgq6hGEXA)>#ljN0+{1WdYgOdExbnQd&D|>fUsyxh6gT=QPRZ*y9q(_Q_2` zm3$Za3ceSkBUoh6AAyQ31OW~4{M}h=V5W|QHphM~8R+PDXIBZ+Y|veDqyW6X1T zlkE{*bV+KR?|8+AxYwX>i0wdcNlE(T(e&2E@!1vk>6XZ_&HX0)t?dJ+X+)(su7cHQAxo)m7v&Bq8r3b47HoF#+H<9KrDO$`ZMLSXY+qe(pOv4YY70qD z%yQOlhy7PWX}ceS$jl-*Is8@DU}HWQBVkJT%B!)WNKo{pVRQF~ytuidSHcq?3n$!$ zaVSRV2P_hP<8$oR(g|dz=8_6%MqZprL&GB%1wSI)56D!G4(Zj8O?lNq_AZZ4%ylew zk(UQ`T>;+0=Ez99W`A1h<8J9Q5t5!7y^IXzNO|Z;sP&6l0VEFUB+Un1{0Mi8cTd7` z(g^-u4Xd=lZjp8Jed2C9zVqp2%t$pOjkskca!>!5C1CU@(b}CMSv#lly%_g4wXf^j z$~LPq8kfTo%WRA`&r7cAl91iRLK-*5n$e`73jS8Q4Dj0rqCwxE9yf;vIpfPndG5wNb<>U4{!KcAGgiwTgc63Nv=HwB3ghFx` z9aqtvFsh2D)eA}=X2V6`^oqM|HU)R!dZt1AxKIjC^ zrg^FH-kTC2gt+IF3u2{rt5QsPTb@0FMo11$AKS5fM#e&|5m4TLqFdVDKSQj#m=K{Nc@iA_T}EmqNI_w^*TP3Q@|datvB;Bsq5c~R-S zRqG!UuR6Y#Fere&W#pZlzZr;A$}tfb$`#99SFvEybCweUp+WXT?A9qS2T9152D`ca zceGEz(#F*<8toeC=N~1V&AuF!_nx0$+3lTfmHxVCH^@U|TVhQK+kPmL|6wQqM}}F* zbQ|NcEm0tz>i;%Y!M~N8=cFRB#CoILwE&tAd$Dqx}wX){bYPd~4s-3=N=72% zr=|wHXC>GkK~A;^s6)a@U8dqchDV6bG}gG))SQ2*Y*#^~Yn;Yx?IQ$)Y4vthHw84;s$Kt53!?6_}pq$(%0s)evfa9$bhu7U6HOE8bS zVRHp*?sVjDoyPm!IdIOa-R0ySCU`9ptB|fan|F-G5*<+>G94#;qLL_r6THuZ?`WWEFdg6 zn$GFAX`bU#0gj#1I1#Z|Xl%eas|L-eo>V`<_8$vbf5R;xaFW|Odw`8ZPQuPfwlG~8 z8i)r5SUPFW$jy(+Y1Uws=yy^nzQk3OL_(-*@!+-})DI8C=Bg3?Aal3aW1>mE_ZrCF zx%HR}4%ai+^w$3E$#fBuxkTkB9!ff~&=7t`YIVj`#B`-IazW$;LKj%C+g^Kxg6}+Y z_v26rq{UJ&A>fmy%BiY~x@~>LUf!c1*(VH(WKWHmeS2R%o(NKA4w?jcFrMc!G|5o_ zd@<%32UE^h23l6WF|>>s&6R+{2ckPR#A5t>{^YlYtGLm3X=gVY1*B*OU5xm{i513p zPO;RPV&;`BcSg3-y=S&woVhx;TZG zbHDpW8=>G<0nF;G4(SuL-5(i9vN3z3fS1U_jg46&&Q|=>cMNZK6N$R*u?QlLkZI08 zWn3UM(1aynrMsH+oOO9FEe)=w+c2&jFH4E`g>{5iUuW;;-RuV+AQoRrqt+9fENDnp zAG%41)+O&Idhzkv`d+F~d+r`PSb!)fmr|JdCd{3|_X(Y?$U z+sqy+Zghup701>~ceX!h1}(hQZ-(?dC#UwH>FO_fA$Bz0)ylb}dkKE+OzMRiPO;-~ zkFg|?-@X4q!{$U8ua(;^Mq=DPhp5dnK$Hlc!pvhgHSm*)W#-mKgV=V1s-LK@uH-8i_7|Iqq2x#yHTl+lfV~rW+It{!@I%!(Z}=`EFtVRvKV5LB-S%N~wlO;1>$!uj;`z^*I-iQ3 zX+7?ddX_d>e4Ll9dYKr|b`a4oPH9MMfP!bwQ0=H*ezkWsVKi8B?7ktf74;LyNe?)r z)hMuhP9vXPk=q?LH|?`%t%VpL9xNuXG9uGVzQ10AZ*)y&tP*-sPoUZDCS9Gnd$?z$ zJh*2o&zvU?tE_KRkOT(M)~4o=hUipc{R5f~p#YV=tnK2AO!?uuz({6o5A9j|e3`jm z9dnA9l91PZ#=YLOvCg0P1g+GkYi#`>k*Mbds;%3)Ve?Kz_^gxl`{u#^%de=?OOJu2 zx?aCo)TI3$WsUmaz1Q-If*R7fmJY~LYZzXrfDD;`NiK^fq@7rkIcDp^l|g1q+Qm4T z#V7Pvu|snsx_CO0BOaJsqmI-gl)7z(US3JNk8(WO;7^@x5Z%&0dxy|?jsJnG?H zMLILchq6dPz9*KT+bw(xv@YFpg-Zrd$=c@&*r}9e9;5G(K4&Xx8&`Q2K!Ki3q^2I? z(L_~xa2;BHw%%!**KME=I@;gLhmQ*8EI3P(IdU6g^Db1~o{^n>HLWPUAT{2Kw>2j< zkt$IRog*R%uHUiq9`9kjh(TzY55dA7lD_dw3;GIi;FsS)2SuJ-}()+ zv9KK8rscDxpWj>Hmtp79-{8=f9G!iF! z5tdbjUSLY;#KMY~d!u`_VySenk=&7QC{`PT+!;tMJzMW{*Y_dTXJRV5y;z2ApzhVC zKqV@r!auM#(jU-1=dWY{DupzTuWT3%`RAQi(jNGaAU7i_)m>gP9nK1UXx{wl{x$fH zvtU=bC?}@xabx;iWQy@_e7SO~5LWsjzx;Wbf7=Y2?WHds=1V43GPH-MBvZ=zxS|8; zXZwxwKZ4qJcxb8Ttb$NpaVf_4!8oXt+RN>9caIA4$J0~dCkVe}46I}Y`}NaQ z97>AV!?v*)Px%1-+aNH+^E^F3xWY~W4wm*gBM9I- z?D#Sg3@x@Dnf2VA)iIJ7NE5snyJ=g9np2ZYFG%~9C4k~1FPo7(LuRYz}EKSu`WVK~jKXOa54pQuZx-^=cMTl=@#8!H)zd^foB_G@V< zSFMf?!ee(<2^4xXC@D}Y%s$!N+wT^ADQeWRA)m_UNafw-iBiLivU^Snb(OSD`}fQmJASXs5v$< zi+s_Oke$(0h@{H>n*1YhK^z+4)`pI5QjE27RMUgvI{pwa!w8zXnO%?7DKg4$lf8FD z<8lWwUN#4o5Mi=GYS3NoM9!yPlK8J&hC_BA%j6E&O3L5+wuN))XT3&iDNE~YbjFk+ zP%QXm$6zjh>;48xM_24LYEr|MPGS_AI(H;Z&d3KdJ>B~bAO5o5Np_f9Y$tpPfx)oX zENr( z`?i8X7~nkf@z9L9DP_q&z*A(5SWO)Mv^l%h&=wYLqgIM;C06jV5KpY2GgQ!7uUYT( zUWACEWsxF;Hm0lfvF39;Q}4E0NYn)XZpG|4RDS_=m=OmNC8gX+{wWrM9R-yp&-LkIQO_Mu%Xk+Ed^gC3_}?!a;T}fH!Oz#5BUrs|c{#rG z-H>ik3_8xXHK93jR1&CcSxjHsCbuuEmej6Vhd#E`X2Ez}O?!bX9Xj{EPp@)cLCpL6R?(F=lo%aMiDHnLfPBhq~K?40;S8L zD+pzNZ)Wbq|3R{&7R>yz#*ifEOhRh$*rpB#GA+G$bLjES`vkPGIf?k6hidN63`&E2 z=ciBG&bJsh#>hAtWHt}&GUOeRvV>5H^%G9NTVCfFGKU=tgZ)QvD5RjUXNi?4TL5cG za5p$pHaF!TC7yaxs%?99+Glt5*b} zyO$%e6uTjr^tnY-_{*O^0-NW)`NbAVM(STky2WY;Z9Ked7n7`|chy0|0n{<=g>}Fw zkit(c-YCrq+w(c?J;6)m3xB3)Nw43Qxu>{O%3n;~>%BQic4tZd!mlHORlJnPAzEeK28O9JTD+^Tt&RYjp6j!PXv&x0LwBJ|zLqWaa)#bH^m0ZNh(b@Z5s()~ zmFjc!iVC#ev(RM4a{e~zO4;-=^_6~kLo~QR?&q(zGKzvt8J*@AB6W+K-|Nk1XC3z0 z*zlwl!;$tcV2@so1q}2$wu^ghbkrf696aB(v>Z=R#%9^)=(M$Ri^hMWZj(upCS7cN ztHE`0w{&WVyr4@dM)ns#opNmB8!O51TFQr$4@(z8*yEcr>5T8Z7K!e}&0M7&78;)@Psvx?2K48_e(kjfO8f5v zXePc0k&?fJ*m%wKQ9Ttm5FE1)SS!IVGv{gx*oAz2skj^jZbo^9jIHSI?x;q}jX22q z-n+$j`6CGbi{ygHkS#;o2i;aG^qPw6ooe6;>$LfPk6;~JU%*rbegr?ru{68PNykGT zudeAjDl>gCzubfxoi50&H#s@_`8obXHAb13U<_7F;S^Zx-*vjw+5bWdEj+cco~rPY zFiswSYX@83shEEeU=`MQ^SJ+lA<>#$S|+W%ehBE4awMZPQiDN)r8t^13N?d)&no%w z`&eWqzYD6DV!OQ)_!Em^@;%AtLIvG>2b?86VOBoL!Hzoius!^0D+1XHx z^OqARGI7CyQa#M!TdJy99ov#eB+mt%RBhyEaG$$79&BV#21s6f`?~yXBIH@-JbNQQ zsgG-gPP;Pf`}fwiG3pQL{!Plt`fXz?PjtmzeK;UG^Owv- zQXOsu+W!4k_(?x&8nbq^9OP6>M@OfKaO06*iwDa^@0s<6*Ns)Ud+E!;4t`Vm zXhd5W;N%9ilu;f4XV%xyZ@4E#@P)o2t6dh3Ve zddf6m@3T-^g%c?q7~tge9ry$^L{XSr)u2q+8N<y@t# z_ZG~=#A_1fCzpJ1N7EQ}j>UgH&;D7Ej_Q!t;2bKkgL%3o+fJv6xNs9-qaI9ItEBpZ z3hk<@J#1lOd&G5ew)83V&{wbA`l-omk93IR*@2;Z`5yvRHf*UAh$!qB`W+#ijmRG( zB6@nhx+QCgOH@+T;>npGd8AjC>287P9m;^7c=+nxuCcz}F(7PII!kZc_8hP2mWWMb z2|BQZ_S^wrl~zS3(&vuHJ&1yw28QH$$iawCg`)eUCI1V_tpie5`6HWrsjUO=d~2iu z3C59=dchZ8_kI2)(PGUSgh;A+>z#@-xvBORMU-%Wa=6Q&kku0@2>H+qXzwKHBTxE4 zG)8pMMrEa4nrauVKN|O+$E>%Rv$BUT;iTDrqR zS>z~xH<-TnzC8JTB|QN;>@};Q6W_@Rqv_Kqn)3w8_Fvw3*EUq8bBdooA5fsEA)1hgwkvQqr3MB2Wn5-KL;8`|n4 z&ok{TMCACVge!limv=O=ijBCumRZsE=O*+rXoLWeIElp?a+9o?qCpZdtgP^n43XzHcca+}mlYRTH-Fj(#UYO(sa z=h7JhlFBFdyV!DQ7TEsGP~V6Bpi#)A0YB|NPfRR5#WCr~dvQe{;WLb9vza@hjS3J) zEpEPCc>QyKYRvcR_uvPZ)r^Sb*CQojjc{kWnZcL4Hx9bz>bx}-qBGWpbdFmnV^-9s z6OOyN0)IHyW)`~Mwb>WnXQ{Z1UQN8@%9{~bl^1gRZ&Y4O1aMu?f8(3T)gG7GKN%ZJ z`m3&l4SPt8-1W1Fiqd<{uMed7^QrntZYogC{N$Wq%(?UXUzd0p((e)J@ew+ zrN>C>zf^#&nqTLI=4lJoTEpP1F$EK}!1Z_GGncJDKLMt#ap87a8JnyED=!WXQZU4C z77%n{u(-5AxFBA%0bJgdVkA(S2~e zas#f`vUVw%ac6-FN~TA-jbJ#>j#)KptRq#4@CWaQ7p>lj>{}eRP|aC7eNc=So_p1I za)eHh*ZS1r*9FJH>LmL4Zbn-VhXpVhNyS_7zH9v3nw4#W7|(!fV2`6Afk!`R_wCAYo}ajSGf9jFpcFb7r~MAPhF zZ8qP(k?s@OQHR6k{0IBQE;#g<7vda=eY$wO{(w`lGChcA_o&pjLl`$*#;>Ng%-W)x ze{i_PLOVqIuNGjep!BZkX&>v>!1PF?`^WY!I;KS(!kry8@!j_qtFqz>*8$;}?{7M? z!Sn;6iv_?um{7`Oq2CIrj|+^=2LVr^iq$o{Uf#DtE<0f_)c#axSAphti z+@|V59pgjcA*EHM!v@M+7FW}WW;NLU2&|e!*%1(N-&q;)QDYrN8i_=ZNq&|MYbE22 zt=H6U;H-;eW6A zZ#k{5y_(_ZLW~NFlnq{rMYBaDcklV6L#|e_|1HlV<2m)jtv2BIdB~>k!F(>-C;SQZ z)-5)9YNJ^bmY3yhivnM!#u$RVLmmH&_$s4Vtq>>c1S*}e(y(jUDm)Qz4FojiCK(N@ z$Hev@hp0L7W^Y6)?RWSPpK7<6%+61it?uWXx#L!ub(@9<8;eYGVu# zIDLyKht&B&Xtw9a%9b%H6S`gP=~+J_nDGgOsmX*+!Z9*ImV``M@Qs+;ckP`DqV9K+ zS#nr7yci%@?>LH1oZ%yd-w70KJPvzbF=)9cL2f5cqtxi=_wh-v%?HmT2HI{aCElEF znToWwo_t1WLO*reh%WvhYf523@$YQN46NQ0=CS1(TTO~iH8h!a&Rx)}u@rZ- zm(zLuGZ4-N%dGV14|noFuX7-ZvSc_hny&ax&Dko_^A%90UF z*LxA9vjJF*?ONF2Mvm|g?tk6UgtOgt4h+F@x^4&U*9<FtF0Q(z4A=jh*(2iduQ5ie=t*@K4q#kvUL@+fqS(3=m*V^>wIT%pVfXaOf<6P@d z^G*+YTJ@XZ-Qm@VMJu2>_navSu-a@D(-w|Q0qft&p)R)oy_LnHOy9FZ1t#--Pr4Cy z6YZK`F8!?YfNqR;2}&gX?q`&WMXio8lk zq595!Jcfaw=%(WRClv~RYSRV!sc8{Y6`9X|*}QgpOU55{ zb^x}-Qe~a^I{$?eoLT+xpP=nomfEmZwQKqBo38B{tc zC1smpGQM%e`I#CVd;Rqck7)xmUv$4xs^F+LK2_w-0z@FilYDLMko~mqcA#fEH6zzL zzJViwPI_C_)hi84C-wYwq;e0xk9f*&W>-SPOm??u&mMok_iV-2gl9^C4SRnlm45owEYl6WlUQDSetTa=VHxp^bKLIfL#jGoOLBAHt<@_c-8eru zyzljUiXkV__c2>$n^s-0^=_rk8{oG6Td-g5`m*i#5l$%;(kx3P^_c*j?AP2oNLIg) z+rSdOm0GbQRcR@PP@PVwR%u?bSr*$K;umegKc*^11!6_R=ik+0s4x6% zUEaT}mC@ZluxG{vr2-acIjHFN7%Ex#`OzfFmHrnTBzV13{_z+EP^i>@p*(7g;ce~h?L87! zonG;0cVe`Q_^+w@O0o&IVi5IQzF49%_-Gzx_rd*a8!oOc$;K7g-aGN#RITC7tB2Gz zB9#i_Q2&LqWftb1NL@77{tV(d;^6DMys}nw*Zy5;g#CqQ{N2fSVGKdSqaaqwCFFjy z>iv%L7iZDxygLId!}>BI?N4BWZnM!qm6Gq9SM1htjI&Bh&SB7s_mgW|2s*sDV@|A3 z0hL7En{EAg{I#ItrNTGBrcsI`7>Ful0`w`@e%hYyq4}aNYg5 ztfaz)yL+$j$n9H&a8#Gx)D0l1RaaMXFQV+_c#`v~jIk|;-K#9|@YrUa4q-5K)8!WCR(Lon=%Ai_=={=? z=C5I~-F#g#WV{vntBNaTM(kd(df;O|>+^flf4P$l>DFCkH%x8iY$|S>PhieT%~)Mz z(M#O>5U**yVs=IQ4!f#Jcr)tef{GuRpaG}2Z|0|D@Wy?h=$ z-T$ior*`nu)@@JgZzA0Y4b>!RsBFb*?!I-wKW8#GW_H|7eFcDyS3U-dU42-^OI|~^ z_xz%(+rs@W^4LGmlf<&V;N_*jAaa{xRmSimG{D09@3Ms5-Q!}Fl4kb+qTr9P|D&M5 zRJ1QhKaV!fH}hK6`Bxk^xAhM34vIB#^7kR@Lf~R}I=Z~(MkZ!<+V@SW3?LjXM zGabtmlvLTdv=vD7fzqUp4&>Tkm4X%ejV=LTP@CWit2;}xLi32fSW$c+ry@d<xSUU%8}f!l+< zgFlE`&t(%K#bISIkU2iB!n@pyRs^zfUc*Hn*TOx^b8lLy%gc&{YXy6poc!`3e9?>i zF7dMY|8Gr}y{&lE=}yeRm9)?S<#j=r$Lcd|WKKyZkh%x#%lJJ|+U#+Y6RTLoh;|PX z`gFb$-5iJ;K4entxPPdfGO_6Bdi09BqnU4`CA=7=Add)Rh;!9(p|Vi7`-oyqgJgr= zCFIa3hI2BMa7fw8$8l|)W@)IsYJhUcF;s?gE?J&ccG|(lBpne6A-qGnyCSfg7#RPf zwursXvz26+qU%A{^U|6+T8kXWoJOpSVc#p-y3>E zD+l(}F#Oj9Bcut$hv;GpANSb&(XS7Pq2kqXA@{yyC_)JJ^;zqE&r>EqUhAE|-)FN9 zsP^C#d)kg8EBL-%zZa0TxE+uYNzifxB`TDD#>F~=uN}`r20{L?|{G%;WRp4Z0VZy~$X<-K`Fy^@ia2=_) zT~@Qc@AF&4SAmvSsUR3Y`Gkg!M1%X87b}%W?~1R%=CUwBn|nV0tlH{~|FKXFmAvBi z9nF==)ruJ##l5j<4Uw_SNYIefgLAzx8l!pfDK>;XLWXd{vg$fqS0Vh2BUj zSzNh@rC^HWRufln^#YOAYL&Ma8qlN!O9-}+pAt~XOd`D^=V#an4%u`DN|vcG_(Cww^WKv+7C>E-icnhPBn0-e-cfumn<;9h@yV?4qt z5+6&G#L^NH@(Ig}iZh)x?9A49dl#LV>5 z8qUSOq|B}km1^{zPII_r>2b!{buwB_tN-qSWM#?x9d2gj@+rY&qtWw4!` zMlU%d13p-*68i)F%H3a88QtDI`#a}!@1g6dv&-_UwKKzUk(q}^rz0xemKLWIJWG$} zhmkOG$ZRfxm7jQ_W;*vC!@BZ(($Rafx0X8a(Dfnx-k3cz18C?n2lo2uIDA29VKb*J ze0*@k<8o{I-bwl49o7ASa*hnzB-YN~a#pSB6HyH|Gqufu!zwa^Q81R0(9)@bp!%yN zQv-OH=D;gVw*lAWOvq2J*|=*;FF#eqFVVRdeNwm0lQ<9{%qJ)A*`;=ILZU0HRCD7Am_e^(gx z^%A4I_WqGp_(mLM{6hrX)cf|&lmGk~_||&;%LNwT*Z=)Z(N*5Y|DXf^d~^Bwt4e43 zaSr1=_<~Dtrc#f_gaC!p33m>y`)@w5_yzah20xg&|4VQMxbCbYM5FJT;Bz7t{tvCi zN&w18_otMGPCE(?@a?}m#KXrI{sTr(9H~4=Q7`Xh0oMQmT8u+jcW=%1%;P|dn}2W$ z)HO`!{cK)=uJb2qTBA4misivCE3>>1*v_lXsQ>CAsH!4qG0RT}?8z%P}stf!RG zkKfjYyZ<3STnQCQRAqIxF!11UJq9=w;y^Ccf>fp7WvI+e^B?*y;Qj_^>xhC<>&oR! z^dAqWv2~YZclSfqM&Jy+KW9(?XXyRU8LFDz7ZaCgkAbp{pmuxdMF7n4-;=!2avUa& z`4<88eBz&7t&;J`%M^c+s+8F0RR`DJ%d&qg*1j|Absw-Z@v7cPGEoE6R+Vh5ffHxk zpr#`FHrrsnDZg5Dxwgvnzf@ScM9}aQjY-LjJ9g^4??vMAk{@uoaZY{+ffNV%xw|nI4bK*0%fOh?RJGviwTP1{P{J#Dt zC0zbpKE}a=JMUkzT$4}feE^{x&3$dtYgK`bkSpTTkHwt;xzEq|EncZ}6^Q9#7 zO*lUD_Q^-iiT#Py@r3sJ@K9fh-Z0A>(+&7zBX}y<6?Mz$FH>I}(xpK;W2#-S**d|Y zZL-tlA7eoeVS043`EX5{+TfIoZJx%-38 z9rz*m^56gYYK!r)wH60}28{b-Cw=fahUdSW4d798yl5-{U`SeoF@e~^#iH-r057~TL*rN3F47ht`T74i5sLJuoLg!Br@ z6(lewl7M&YHZ=aj@$|wuK3jU>Br(rcs56WP}ed|ym#HNiVg;|6h1h*0<`5V}aaA)yz0O{&4Bz5h+iDRlpZ`hVePJxtlI568^eilB~t*eRUB2)6kj+K zD`=c}yhS8`prx$ZfNmtb!?yHxmQ)|eZ!=-Q&@ZGn)tQ&CI7y)iK#-Xzv4`i}^37o4 zy)QuSLWir5@v#T!n&eHfpbRRwX>GAwYA@AWs^%kQL4m{%3u&3D5`KHQ7XXk50dst& zy%bHA1HKvj|8Ry9#1}?JM}Hc}RysKJE*X7r?gx8SgW%JG#Vez1qaYhNlgs=%Huz5V z-A)&V=UF)qdn^U{fJrLb#cV1e#-KYTK~WwX=LTqaEV_GtFB}}Wr9aRIFlTAE3K1R4 z4iCXUF6aJAJZA2$Z4 zU&hlhpAGC9b!obdW~>}0tILS+p-SoxQYX!(UL3N=h&2U_0Cr|Z+pPPl&p&$uDwl@P zjhG{z?1EgvKbri8EP8IQkLkNqht!9>aLKg6m;lHp)+F*g?{v5vvytm90p{`=z z#1}mzfmedz|HZaW>1ll`@2$G;;o2bFB?tVAHzC%KkZ=C?{u-_c>U_QzQIUU?}1}7L~FD0JGFhf6apb z_aR|+h3Z!_t)`5~R8q+bTL*A_=ARUKybntXQqg(w>!00k{_NHApFKHJgv+nLu3s7X zmfQ{;sxfK}bbP!XN&t10_Wx;=lS9>npIt-1Qyt`<;mZ*z z^t)U^keh9A?&n|a?+@Yj5#d`Rtwh~7T1}Wy)O`(Yh$ee~fYW@uo!swA~AuY-e^<+jO?gWw~nPaYDTWb-)AW>9O(ne)0@L zurZ;oFn0eyK#F&R7Z{6phWaIfm!u&GCBpgG`Zv?&K6hQ{h&zv-Yd{456?GhP#p zp^b?9k*X@7o%*0E=ev=*bn2y|W9appv@PmFZXyaXMm}3o8?RW}z%Y+3opIVbo1u2| zuhk}UHEY-Wzl?loS{(gkv~8!r$gn<1;j3sS?Jz&@x#(QS?U=s^6^XW>r}<1*|Ff6S zS#nDx&&BgmI0$?Clu;+*ZcsHI%J?nneIG z-?@*@4w!ql7})k6?rVsuD(18@>Jhb)Nn&+f z5|9_dhFwdn-wvHHZ;*8R{{GDbtKN6SF^&mI2DF@4WS4Pkk*=BELbj0q;D7B zw1{Y(??q%?VlUUizBpO@ z$LP_6RvseJ6O;2Ugo}-)|S1KLq3yWXv7*k=9y7EMUt z9Vp6D=72@w9fo?p> z;=84*T#M1Vi#$-%**U@mCEo`9o6%u@+hTIeJ;w=*GGXp@&F^d4#l}Oj1f-@k@2N14 zLs9M-2omS)>0~{9-K6w82fz1RT*N9uAw@l?{$La?QPi;1y$bafN}MUsWVH=&-DsZ1 zBskSIU;Vi-Pi*Y*wW@nV4#DpiKN5I>%=>%~4AHo@ellx6*poM7lYM7w@T?~2qk&3^ zv#-W_w?uZ2ZwLmuU6fWdujo_}tVTzBcsZ@o;-q~2CqBY%$@w`!+BYrU&3*J~>-Q%j zjKJPxoool7-)nj`6c^5S>8MP1_UaC|!wWK8#p8Jd5dGc2dvB;h25_O$&WZT)emJO)xN65Xc6x(nGklixksX9Cf-ht41v+p6yq z*bWaiKPKfqdr*eD|HJ%xl#p?nj9W3-v#Tk5pm0GE(Wm56g@pw~m7qS~Jrmf|bN;jSXe7)=he2s%BjM+45W_ z^NT;+YRU_C!!4DIZSKCYnrTg+4-XWYE(jW{=!=n;OAs>usG;^=<6*U~l#-mJq>TIP zU!W1{d}&;y4QRd2CjY)nj@3KSMbN&c=TtjSOH}7%`T!oYfa^aolE!m}xh7^7Q(Z%Dzd$OVxevT(R)sS? zsRz;K63rhfqz}M|5nHVtWOaHcUq{h5todc@-;Fy=tmCD<9uq=J6FmFvs4sq1iU^=2 zLMqb}N^$?y0*cJA%k6VK`>%*Y?1^(@_0}#Eb@&Q9{jWdUc_;(vXlUsi%*dP5!W0ba z+aEq`HS8^4&nXM^ZzN{rv!cZ|HVTK)X3~W-M^0pQg^`N%{--^)I$Q74Sg&04_Z4Hi zVeI<)7p$izf8jSRztHI=ZGa}Q@amot*lzD@?d~biDWM#PO0MUK>?Tg#xMuY@Y6o?+ z&t>b24IEC}WS~zP(c(vmpezs6FY_95|0u4Rr<^o z>dikaPb46#1S8tPR&-5#YIWV#g*P3~BCj&zaXYB#>wRoyAR;qtFT=p`@koF)d3hW* zb4_Z-D@T|-zhj9IS@^1Slm|^{c!y3SWtpwY_)DK|;~~ z^JKCuuFU;Z;M2T;fs34tkSryW1VBa_;px(%Ir+r0Hgo>6%C8QtW6GG#8K^U2H21l3 z{SI@AnnIJeExh#YGh1Ts#v_z8r;^Ow0Sb`QJV%85YY5imgD4MX)^ke{#38ktTM^pe zIb*P#RhOX{IeSuS6!`uvWP&wKIiGmM+Mz}3Wfhg)1Z^T_d)O~*a%jlFTl!F<8&dNE z9UdOSCet3n1uwo&VVFCJD zCa0^Xqg)V;@TWcTsTIF`vlSG(35@hq%5wE72y@aql#9ET!jxqA_D$@7g7;L2gw{x> zBcVV!)(r*gs9VT!zb_B+COq45d=ym~m0s_Q0U48*rzvdW9J>F$*)&cz`)` zsP?Aecm*L8lFKFZUTAeX$C??_4yI51KpdMzx)!`E6Vy-Y5s@uQ>dDiWvb>paP(06nh-Ksytrz3eCta>xDBsN2Aq_T-mdj8`` z^!VscFS9S{)y+9pLkDrpMUX=y6m+#E=xHBqQ;{Ib1?}yo&ndr(tGwi|BPzLm)Ha7b z^_;EGR(6`){25_jNp3g5>cLM=p-ViD!zqv48bpu|WP9ZGQujxjqXIlE-Wxk|j zCA_}DZm!Vubi$|aW>Wma_t46{>b>P~R1IOtgOs$Z=%kcQ%-cehHQ(ey!alr@*h^aQ zz_-;8%t02oAerk%>mBWDtb$Ib?MAg<1Ry=@^jg=I(krR~pxX=#x`iV1!D|YoMKQC; zS?7)AZyQs{1HW=x4x7*kU)q}tqWX-;P&-9_1pf`p7jQ>Dgn_xd=eF?Jr zd_68TX?%1oY`(tfq9t%pJOBFe>3K7iW+6+Lb}Qv-PY+A~D~H+wz5AYLIQj+@cPyLf zn`s)Vv?Q#2LP3>751L+~j1rxaN_!3F(E^8!BoRX$@|gayJ@xL?46kXeS` z06u?7((q#JSn{80^qS=d!NxHo*Qezx2d9Qlqi;U=b>Z_0-!pBqb9WS(q*i~3|0wQH z6vp03ecr*5(6^H+VYYHd^<&YD!Zndhsf@%1$GsDrt!IcPmmg(7tJ)pP^GyQjFMb-x zDD3sWg0@up&DUbiun#rZPdO6ieMY>eiK423Cqzp6uRJcW*QV+g)OzM@iWP;YCw)eJ z`ZcOiek2ONCvTc;M0tg#E}G#e7~Mqf>I2vLkV8w9_Gu8KP03Q})ZJxVWf~#8Ctf`& zn4X^QkPs*!Q(CWhx6%zvNIye0^h4bX_>du;(HME`Fg1IE|!oxOM| zmo{V;x*0=mB#+c`PANp|U=(5!=De^*wWE9uUS6nc-naLf2Fog5G}`uEO?Pl2o=%`V zQ$Mz{&M$aAoU~H=K^q=(=j-&E7W3s5TH*R72oy6%C}0f&c7*`_BA$SRM#QtKel7Eg zKjpRj0YjVB8unjHsCdjT>}bC^Bl~WO8nl<0)twuWjtH-9$p)odu*j0u3xf}$VpX$6 zO%`%2U^CU0X5}h9P^;#USQbc#{_bV9OO=Gcfkd{e*qN{C*$4(Jk>n?AnopHSZd6cC z9CC@aYjl2F&Dft@?1#RQHBYg1PpUeqL5g5TOBOnt9@+hjR-3ZVy%zr=;t=J$ptC@A z;YLx7sx}rF$MCmZJd6{(RvKS(iyt0}oB>7FNslfgqoA!x_t!0cksucFsqjPkw$K&> z^Yhfm8o=Ww%opn461>)^5*PltZNhi;psAJ%!_ng=G{c(He1>q_e?D=4qF+m;nG$V5 zvTHx{o|tvVEppI1(qS)p=Uiit{I#3s%OUIjn%vZse%nRo(wA=a!;8%3bno?b~{X!?~>1b;jne@rckVNGsmP?ou#ZksNuZ626#wX`aW=Lsukhik)GW3!j5vre=_tm%*1CDkMK8N))*YK_h@Eg2AF8FOiExhA3xG-?s}Z@xV*YB!x;rajR>Zai8NGT> z>PJ7TJCzQvcUs`BZ*?l)!_~Qyo^C!yc1y0hzGIRImnxtQD6LTQA4-5tpMHE`#2QT( zM9pfWDmK%)ymU4%?8|QH>y3sGdDa|BrAG6IeOqlj0_4-4BI9u^wwyBhq+s@1rp##|7^kcXJ#LSqSHG zN0k$kMR3~<>$pg(_7lYpcT3OuWL+93uf?;#@gAdZ-4<-7@*?%eX3QGF%Tt!sPPSx< zl+1DtGpw5?(VJOt3gk7Dp^Sp{A%1nWD?2vj+EVpske0h6?IL??5A8R>J?}AYgjs^x z@rU(n)dep}jXsGn0~>O4ei5n;gXlQnHiQUQ8Q0u$t0J^@q(OI58=A!SleP9sG?X4w zVa92F6Erl<(u;Owb#M*r#lJ)ON6RzKgZpZ?PE^Buul2XFupf^Q&#K&78&zQ%Jg}3a zU6&S%5yIx-E+1o5g)*^~%X0U4@4EuGLLZbZ=aOZFOd!F5Jxm0+G49P>bUK)a&5grf zciL^tkl*Numd9SkNPbcMzHfmi_?ux{#Fq)ofl5$_OUZhWyH&D%?5T=Oo?bprAettQAIBct& zn1}iXKx}o_ni87ZiJrcMiH)%7z9}y>NuNY0*#vJ9(|p9)B%HRAKAudVh+|nHTcy&o zI3JYQS`@_$_|knRCgkW|c`BjBEJqP_(uPaH5*Tq9iAB7#_hOp(&igkfZ@`2On#zSRDIko zk)xSO8a9|ALXVkk{M+eqJEyUr2$7fllx_5QC(M-6+DheQyajc~4xriESSxAPd~He& zGmF1~ypDQ?Knu}X-ToHFeO2{)xXbp`Yqq%L<7JT=n5p{ntpG~;%N;Bi$3To3zNdr0 zC<~pa2WD?`O~qwkZpiqo=YUoHFsK36B-``uBpgSy6^Tc_2_-1wKE;zZar|_!DwjSU z0*Ppq63EE53o`qjneT^G;(dq8-Qi-NtXUfh+$ABUFEOp*S>$f%(2MVCQoXGEbd(d6 zU*ZBPPu;GS4QPCpYdWGo_x z0fcbrH|VyD7@A*LAeHh%#yAC_9hb(HoH14hZ-YGhOXB#;*htAwT%FN=mE@IToIJ2o zORLr`?ssop-aPeO|7p#0-|=DrH-<`h2O%Irvy1VbYrT0J^*~y$5BlIvj-;!_t<}o* zyMD;C9u9)S**N01qY7_3w+p+AHVpwst6|{vghQgup7kXp4eae!fGKo*GQ7(q5+l`q zoX9j2JHkY&_p=H(>AM}~06E;1haHLYs)c9bF3eevgP8pKDLxO~u`(-f+nyYAX6 zZ+(dfZa+akk)ObI(o$bGaT>HA;snP1)tvxpJ}q3N1N3NjhP)H?AcayjI(J9srWYDI zhnrfbE_zO4+cL*?cs%(~`Gxv^)zS=k3qjniC9NTS_fOs8Q-{SIe@H`o>Y8N~LD@e2 zyd|WjG}2q#$2T6+*2*6j=gK4JA?qlmDQ2+=tII#-!fyusL^13a7kf1j6&r}flq=gt z_Un_2W|6Z*e>n!>+T~^M=O3YdS56Bn8wFiXdx2@8;VkH#rr}x2#7?XMbwjIH;n8*8 z8|#~9g3Kdr9nB22r0bP>;0L^g`_oR5#U$&-=buAbvEdRiJQ}(9J1s&koTh|B zH+gP5C^}FI@V|2$WVtmxU3Y)J{{aVici*g9-GY4zg^#iXDO^i%aieWf98hR5(O#L3 z{}@>J5weNPOHk6#En!c3*l}^aC%-5vHB$L>xhN=(G}+jrQ&(e@S67Y>!v_a|!){Z7 zyf=fNQ=;RcF_aJ~oWL8CUj@Ez!H`qO9FMN8@O|Gcsa^I5Wo|#JqkOH#wJhu%tZ(5e zpDeiOoEF=*PO0nM+@9je@ezIu-J00pzn380L6k-n7wL41w)SMEl!NP;9QK;614#yQEj?xi0`Bm=$=477jZiS?8canBSVD`&?W{5eV zv}aTmp4$5<+w_>}Dsv}eO(JXRBOOzSLQ_BAhcn`C&3p@<6zz4raom=sC`JF|K?yF3 z2o<#LOfD-EHg4GLM$RAgam^pcSe~8Y&7A7qPSpum&(nHxDT50IkkWsCE%pp|A z3GDrHaNOsRTkucLdY@MCzUjW8I=qEjw6*B+%V*AS&%>c-!5WRu-8~2zZ*ztCyMsvG zg93hWUSa*qlwUq4Jwiov@B{|LmiH$0)IPR=kN{CxK=X3?NPLC#Dua%nsC8@j=a3F; zxLfvJ-E7iZ$=;i``-5(+!=P738qaX2j;U^^_jYM};Yfuj8)xjv9K)8^LG!Sw<$#lS zIQOKK8I&bF9MRt57r25e%jM9V=ARDJDy@nZaC|ULR2_KD9YdO2{AjZyA?gVIX@cyy z8ttcubm9!LJxGx$^EF47ls$kY{d$}-j99DK)nB>3ebgwgNNNWs=diO2mM5xuUP-Zc`h2}3a*$N~`)8p`Thd3JbTAezr>6}qCRfWCuKhzI+#YL5e zOF!nH%E)fv6KkU#FW77(RA1>i%Xan<(uWLJeOR^mWm{SThRf+SQ^?15HFMEO5f7$$ z)*iIHggkGSmkyd3)<1!BvChigU4Jw$(DaF4`A(+hJ4pUQvR4&Mg|qPUi%yK#ROF%4 zv`}E1vaOx%V9GEj(u?V{YkKQipeBz{>F}jm%Nim?`3Trlv|)s+;&-;P zBy8%dCArC*Ktj`M`f<9E`W#cSWua-7zh$ou@FPrLwArn^Xl{Z^(?_25u?w#m7l+X} z7%SgW`(EzbBm@g!Q2BWLqO(}#wi-t#U{?D`%H~M|AWZ?r0{hX#RfnT7%sIn>tC{l@ z1zK8G#hD8ejiNMHydRq-Og54Z|p`^^Y_~cOwHlK!DNvZ7xd2&}85)hBPipht==7c>x9H**WM&uRc ziS)r+V@cCYth2)-u%!Yi7v0~>T(d*P-Xj6wW)-+yCDG{4ArW)~PfPDN#=ELMcM%z^ z0;$}F_#09;TsU`UWM$%;K-_hegog$C3hIuueM!T00zwToA$93YkGn&_v+SG3i;+pb zR1DuDck0f*mw1|(&x#26qQ^p;ko@$szb#6Cn zh0hgItNmj#R2@kE=Og^4?d!PK(e>tod4YR2=bJtyB#LEm){~-|<|(2ac5ZH5AD^vh zrHrN)EK6-F+!}Q-DFY!&UwUin`4DDiE-?jw>(*57%LrJXJQ`fS^G>`E%=7hnTk+wL z*ev<%EXYHlOB>EZA%DW7*(QCDiu(N{O#iw4urQcuppQ3paF@d`Fc3XMiKboyxAkQ5 z7qQCl;-AV~7k{p88UXgB=j(+&2%SHpG!cK4Wy?(!`YZ52rsmi0i!o4<_YUs~uUl&nUMqXQ78P!3 z3l>6=qF)XYA$_$?qLxY9icFDav-Q?P!KD!h1%dbAv$Xpb$W{|8Qq&x~6g{F;?|XG+ zJu!B0ni=1Y!+TBpGljZ7rNB7c?37&g`8<__@IE=M9(mFvAp0tZAno$gbH}P4sjzFO z^bR^9KrO$f9_OD;d1%_YuI?+%P`$!=00_X8D8ydZO(gV*HU+sQl#C+c3Bi;9XA!8cKlvg9_4cF`aIY=I@dkP%_KZ|gtg z@m3>j9p4kzf{Z6@)-V|H^S?0xDfB-evRyvkC3OB-S?EQf$)TH{DwIi!rRhk^+1s4FSGGc!sk!tx zMP`AL05;oG%IE5HM;+^dQhKtK`$&0c8=OzHa!UcAu4Twj62BILD~rrfPwPQag_e0N z`zjjWy+Y(7U?IlSW|PLPvW%P|)`ux9tT#hq$~4evcR9fttbp}XA-{6xlpW=wG<6j< z`lg>Vt0nlinR)ghXvf5{}Ev`SM7X-e*~U%jL_+ByAWeM38COOhoW(U@_tI)x0Q}>(Cd?|NbHrn z=B&wlYe_jC0T$bs3F9G!{H<7%;eg_XN-2C~R^^ENe(UKg+g%X1>vvlcRuL&Uiw)2= zE#7R#)~~w2c(}5j&@L&(M+~g82f%@r1Sj}(fp{I;S#JJN=vm6 zEu#ZYX>)7x;U3sjt_6-q>ljZ5oc7u$Uk&`_Z35d(!=j6*GKA=G=N}Lk%rl+w%BIM~z&M z&Zk$WHrsZ|8a@43=)1xk=_L0}J-pBbwoxB+Sxs>GUevX_OU`_o?y4H-;J7SVc+&FC z1lJ?22LG@6tDP3FO5@G87)m|6jT9_GeUo#wwk^qnh4r9#!>PNY9LUCHLSli76ehLUNJzxxA&z^_vLQLHJSB1DM%*xcD_tb zId7y(syF=I0YXk$e-qxaf>z9e>i3;Z38HX)p))u$>43mXfdTFQxZR~+2VPb2g94iJ z=aZ(nHWtd#$#o`<78`4uNg{-Nt8rzwByhUQLVF=`WNH{bM3?GXvnfZd zGu(6Z+0)VsST)%9pR{|T>WuSVf0+0>=QuN=PB>v}&vnfY$*32Amvq*MIP?z;)4+2P zEPTt|&kXDsWyAWP2o#@6?4B#4AsDkV5wyImjLdc@ly{YyYu z^{P?m!(erxB-actxrLx-XkIJ7O07re&X=lmI9%W}aAUEEB*hmA;HyyPyzT<`yr!`9966%BNr1vuy&#vN!L&!0zW#D$*mR zLboG+Ay08{c}5McBH)|cX&sZxm;L>sf{D*@okdIf_b`5I1Dd(PEwXFJ6GcPMNfQly zeNNvn`g+vbXZZ;Qxx!N7_UasenZ3w7@9~4`1v+)iLHJ#~Dd2p^{oe5`6029J$jx=} zQAo};ki{o^2sm%dkpv%)tP*1VEzIXcT%g@`rLjB*&I9c`!w*`3bz^XEc<*gIp>}6q zaNbVAeg5#f7ip#W1-AV*$Slwuc)DJ6LbAr@#BMWjn%{6pU!3i?o1gzYj+Z^S6@~*b zV+wi|pjh;;Qn0I9i>%R(SGs~}bRqLpm92rqs(~Lisdc1cdAeuynxsnxmhN2mAGr6v_rCY{{`qju=j`d{d1mIBnKSbpMvI#C!#3$pM;Lc5gX;Ku z8|f!@M@%#u)1=oKKSGYeGBk|{M_W%Sx$4?EWO)X<~)(n_7rJYuR7;ruRuC;a84Q$1a?B?9#pmXK0&=UF(VH=je;v zw=&L3DTGr}M}^Cf62ZPcG9;itV!JMrh)}{8g56~*GxWW_<-~TtTZ52RgB9Yljm*6N zcEL^NPeCpf)E?na9ka;(`yEo&NxSCm+uv(V`Gof$lLtODEGSVT>R6SgdT6ZRg_Uu! zfk!1^{W7$bX8R(F&|a3Y0Y=Z9UHfC=6|6rP=O`f5r7SyxcbLsJn^%SRqOJYWLEX`; zRyhK7g3)F0{f%4#X~zh>BYAqAqWV1Kjc1{n^Ltyd;~Eth?snq+&FO1 zlq+OVz2kR~s&>#G#(T-noWo<_nuKZjP)9S@Y7Y%exu+g@XU&V=53489?vOvGz+|GF zn>l>*ecp8QKn{{$O)jZghQNcE+azvV2eVzOKf@#sC2C)@_w?8`WN#fh^5AxV0vUh0 zW0=`KG?6|P_8ihffC>REzvcUi*Ut%IW_Sri{QFWM%jxr-XkmZNNf+9_Z8OrXaa&#Keen?xW_P-WzT(hJw07Ek>GxHnX&%kLMU#QY&Z=REu8x`8D92Y#E2Eda ztc71^O{XOBvut}{HP`nc)Y%~6*yWh?iy3&6In)nVTpV&w2mbCl!_ z?^O4;6AjB~H79uz`T6eBgTx}udKnPA#4$MqZqd{L`~wq^y2CMn_U8CAB)NxSE)jP4 zNUcS(=IA$mzrEmjPd-PmPi~syFBggW*h612ynvXn+9vyQ#+QQbZ;3LrJnN1R_!DB`?>PauX>CaB9FzfkFBN&xI$P^bJ!k2*88#1C;B~or!mS2 zH~HN!)s}iP!?`&kRif|1nCpFuU1~y76PbPAT1dS@Gh&R2Q+nP^zx{-*&iv4T<$yt` zQZWXIR^m<^n}M{>y!ixI)2Tlc6RJU$tZAp{*y(VdJL&T#`$PI@yo;i6QsHBR^T&6|-oN)#SaaciVXq_>wS)-84tkbdzk-^1jl} z!Iek1%nJMv>|dNwirRopyG&j=j)eaJwNrV&iIOU4ZBB_}GTpRTJ86nhDN_?`u2BHA zfm7ueW~|iKc2f1vBWe3f6+kxm`oOvPdt~|N?t&VJ)eV?V`ODSLuE+G3{7GFS(nmAK za$IczVEP*5R)BdP$(E}_3a;w)XFH*cY1oMM+St&BL>}T5fb&62F;SHas;nr^ah;jU zr|G)wd^H-30(`MzXWG+0x%avmmS2M@)C70)xSlfg%*ZBuqTRJ@*xpu{8RoNgc1*BG z+wrnQ=}{SAxHw9k^YvF+gxQXpUBNHK(d&uhk`&_4^|I*%pMtC;cQ7uP)aGGYGaE@n zJK$Ao5>SRy(UwlLX7XMy-*P$+c$$TGSz-N9BQV|>bwXL{qqYLc-RvCPJBejq3ojVu z8%fw2;{YPEnITIud6d)J^4p|c(_>!F-EUSHHM!)f<@Le8vlDmymaA&D;f?HIq@oYp%Aam9ez z37p8WK?M(qm%Fx?c|dUxsc)bdokOgoC?F?TWRO_=X-_Ivg_duG_Y=0G%Lu^k`hNCn zGD~ahauvR`6gw9d7?e&%dx$ z+-}=+j-J~^N3?CMa>fRcj<&}#j;M_zYt8!k$T%-wCD-jm9|jnYj?8OUE(*Ujtf~DA zJlJ;3k9V2|EIz>7GMA;04EsQPNHjYhF%t%rNFl&mL|VxsI*hIDt< zbc|+Re%r=$g{k$o@-1t_<9NCmR<6A^0=kal6XNw_bYo`KPnG>0)mHqL*)c)&^jYV_ z*@>j({oOu_4JlN{CX@v=im)p1f@~I zLn)tRU+F^;-{J0r3`j+da5aw5h&3k2({MJsu}&}BD<|EBy=4l2#XjL;S$n9AUhGLc zD_>Ja32QqfB29qUg>FI&-7zy`d|CUb6vmh^ZjsrJ$9EdPJ>%y7U8olQVg1}R?68AQ z{Z3*|KEKYmr1<3LQ1uD1M?_jWo@e&;pRBMFZI$EAi*@9Fx(}RkPaka`Pr2+nfSD{3 zRwc3!Z;WJ*mk^hYAi^AOf3m5QsWbu{G{0Gyx1M%$SP>Bx=-pQVAU}Yx2G`u}5keO+>BIK# z7$djJ9Fu02%Udz%+Odw9%$F*j0lpB{W!Dx0>QCCn;_&e9w3P#feEF69?~b!EgXJUn z_Xj`8jshuC)a=#2?YY?5;gWo5g^@BNarT<#jcxbxicE+h0lR zJ-t%fnuO;nY+mv8>%+Tz&qv-!5!5f&0bh18xGz6#1~?N2($&=3fjst;Vz9T6q(o#S zf#(?xpF3f8K^v;|0{yjyRb%)J&eSewji=rD_=59NR=&RI7&uryVlQUsEjRa<(MPbq z4Iixa>bnkGq8g9Va=!I}e71ctmh#$88iG!rZBF3%8=zow=obQ|yYSX-07)Xh|$IMVH7KqdEe zGBX~*UIE{)2(~rZb2jY|b>}^EjnnQZJ!>0dS66Y@SgymX)8vtDF@+;nH^f7ni<(2) z(~+*>3FkS4qqaB@Pzl6ty$2$|CY;|&}ra*O$ z;lq4}mKAKM5G!5z+eio=E;x#?dp?>b)OxIosGg5HLQiPvhgy2?o}MkGd)K;u*C+PG zux#s}!YS@e2BI38AEst9pCA*j>}C6o>L{1Owo_2nd1~h#szssLRv;E@XvIPLn+cHB z_9jIbxlcFU3s{BgBYuTF?9m|H`M}{i&$!fxrO_>-INk&*Fhj42sy(y$?>>1@f3DZ zwDTlA%v*Tk@Q&BoZN)vZbHE83OvE} zHe4@R^JH9KqGtVKAe(_f=hG)Lz?XOOt6#0jM}hU{5mCY@cOdg)#?*4tS8Trh9O`l~ za_NMun=Jj*-Ds4Ahc@Fe5W1yZk_Uv*`_+W%dnf3qPujiI4nm-6LLLXO=|ph?6~0a! z8r*=CVBs<~W`PVHqY(JV&@}9r4>52^T-g#nDf?WDO;$bXu{imQ!a&ZTx2I--__Tqi zdG^rOaT9j!Mm@`BOv{3%c69CYwP$E6nhu*wzGLO#CtFjqN_JxU!jn2q2jw~CZv1AM zbfvI1E|DYt(*|o{q&aCPOKlK{`;oQlJR!~@{8aQ9*Dklka$(no`d7^t5Kpw}1K(xz z;7LjLlBLG;OS{dt3W1g{*q?^!{Csh0j$Qg7DKgmC)#GX(13F185$x$gs-Vv~afK9c z#LBHXf-wuae}=!!b3P~pbK!)r`NqZQiJn8NSHPFnQ})TWQAI*L(>C(uo(r%o_xb(+ z3OCuyq1WaKTO&b$;#1>^>KL=)x6VgQlEjzuMB+x5dw3us-ZZK36FXlOCS5F)Md@5l z7zL)gL~->X@53eip8#guC3!c7yYwd#0n7oYO8l%G6-~_WIAJ zUsxy^z=1lu>AtP)uyD8%Pr^%A8wLdJq?P+4>t#egUA!^rUl4W&z5?wJ=STo5zqbKB z*RFTdFV^nvXyjjU0j4-)m^d}uM4gM=;7NReGz$77;e;gh+TsPi)A!>Ixo{2#g+no} zTm*55)FHK?_VEEjcm26@*6vK!W?*_*j`42)<)QWF;8rlsO0V()n{aw;gv6MLg$B7D zjLmHMQ+%hZdP@f!60v3?wq4VpT`Z$NJEFtSf2~*3%Sm3+7Zb|HL0g_q%S_qp(X$Yrcg?r_4{CQBgjr5u zYf4%P`s5T71CxmH>GY)5ee`tSJBE~DTI-%y9VxJ_kkoQLH$?ZPo@VF@`V@{4<%UMb618Q*SjHV`vP#t#Pd8; zk54nWSk9{42ttKQ3N&y~?hJQzBWJNJ zOwb_~y1d5a?C*B7F}a%(r7J-+K=!tX@pJPIAJsyo-P|A}0!Z(d^&_PYajb3UQ!gOW zTVHmph$F@^si>gW?KdUk>fz>8&1EZibeo*2mM~jKQAZIecTj({Klun^HPSw4Q%F?TzZXoO7lWT_?=eI0q;Y|D0ZrLWG&ZqIc2xB>!YLc-< z&>|9u_;pfX&R0L`<_}?AA&7BVVYZaebH4uyX#H6NLcQK&J>SL+S%mc|aKrVqZgNbJEPjj)tYV`6^0iTR zMF++3*y+MtZS9+KOAqsfWyfPoup)cQ_m{j==Tp<=mRcpqaH%T79?m)=xp;}uJoiti zWwaDiF?afN1_VlW870Vfa)t(^pcHIO6;~0Kl;6wf!t?pD2&AWEW#~+^MJA(mlMjWe z-LHxs=BrkwrZD~R@cg0dxqp$@7ivnpo83zYeB4N6y;?LP3<+(Q=Aci5pk6(g8D8z) z0$s9z1P1Mzo$k%yYr14}WFXn$3{EM1IcHHx53+I(B>5zwi*$CQ>ffWl=tprGRPhAT zs6*ekM~^-z{5WiGXIFscEEW!$<<X-I1&x6|n6zd(~%H@|kxb#c{ z6-OCYJ3B{(dnb8Hp61Q4t5rMey3JR+$gKgW&h%OF*y6cPc42+1i3I%5gmw6CPc);N zTB?qamVFJrhng!;#Q5QhW`PD{SIxWFpI)8;2yO;T8!=-llN+p%T$@+t9X<%YorIzA zt{KJaPueF;31Yq014x{^VEVD+HsBK#uF<|m*5zn`j<=GXXW(GCOBdnn7>x&zxA~1j zqN{@@9bzO~-=)>Si~P7kwxrcg@0Z_Qaf_4d?6p>>prO>J^~??vkljr}TH7`LboX_H zZ81xhdDCF+YFB)LVZK^+eO4D=frV)SrRay23EL1V;Y6cn^-=NMz=#1wj@IGU&5>}U zuI9k79HwI*A{M2}!W>8_DCiXGN1$aiT2xGG*^XCTAYpDG2l?%<0Uj1@uO9If z8Mq6L*85a`)#bdCyo;n336%yl89MD+*>_#hoXa_r3i+Q5Gb?i!$`$s}Z{Q}Z-A^rJ zCmhBR+K#`%uO3+;D%LrYN6$8`u}=?0@E{k$J$C-Pt0zumpL_w_1MGTo_G!G_XO=OV zEa`}0m!P;}dmL8S`Wc>MrxQ+re&5<*K>%R(iz{)FuxnJlE(BFCfZE5gEXtcka(VVw zbxY|!5<1{3K0tm$6rC|mUT9`r`_gr!>Py)@Y9nG0JBYrdp$+Q?ve+h}gb(ZY50V^3 zCD(ug?RjeT6QLfZ$K`@;01$(9SnT!_t(>RIX?9H{7FMpEzU4)fTHqco!(6cziBh^+ zS%p1AAk*Hz>9vYcSXf$@;?GW2LFJ>Ocp3C5LpcJH=HVnnm2K- zjCfSg>!Xm?d1nNzNR#61GYxph+wa-}F6q{-W6y&DIEmeE@tmJa0|^Nv#phArE6qc3 zu9^_3?M;F!E=UZXWn}ehzZmlJXG^6nPD^TP*#g!(3?AjV1h98s7o!ixp4STU3lkf4 zT?HMnun3I=e1sYr^%J1UfHn^|30~xI?eTQ6D-2_p5IEys^YSx~z%=j`*UE(^usK&g z?9sh;4SS>#ifF1esQZdgKS)^9h+ZsKQ83kJVPeuYtY71c+a#W5Q5&WDAksoSs!3-M zLP=8@nfZQ-c`b6*pOZ}`H6kocWbb}S^CD)j{On;cxI7iJ^g1G77^v;wWdLVM^_46o z1AbTZfYV^9)rK^9+S-aE2kMPO4qYZJN0VQQ&&othkH{7%i(7EX-aSW`$PoETqCh0Y zp0cQ^N*xd9KCN>D_x#n@oI=+HUJrrahxh>FT~}@#kAvmjk{V|vwoh-fk!+^#opDO{ zVqB+`%n_pWWe_Vv`&5~#_#)2EFbvyQ(F*eF_55u4C@Q*Yslf|Gr!5<*lney`KUAWD z0w^FHXlIe$MDD_Z|J_bCZDJ|dSj04obq=}q0DZd(k|w*RedvccZ5q=diBlf=ba-k4 z-Dzj=E-kI`&O3iU0p3)u1EXT#1eWl4}u( z@m|9+CzUWeIA9}GMq0{k^{o@Xz`j?7WK-CQm(A;E2*N}Y{Sq1=%B?< zVaJ~UJjQg^-z)Its4%^n?O`P3nUrWvnp)S3miNenJibnOv|UkBb`4Cn2}0BOt^Ly; z$2$-A#!dKfWy)T=)R4E0H54%l|8R++Zf}p!f)ksIYr31U{D$E-Eie2Q9A7Q z)@skR&?f15WiCl!IiNs>DHb(H^N5U0Pll55=tajp?FRs)(xYW6_J&5c*#@m{x6@zJ zikD0jCSydhMOHrGY`PQyVM*g(zdJZLNX=(Bp5jxXd!_on4Vqq1JpK@m-4jMkv;SSM zBcRhz_jw#KGu7IWQUVpelk!awyZ;7XnLVhfOt&et6NL43jam1OE#puwCVa0PQLMI;p^1R~B z->Y3E5slkVuaq31oj-&@EV8zGKBSwSsyHd@B700t>L_VsSpW+B3DG9!O+iCT(<%IT z?4cMmR6mbYD1jv8oVx6XzeDQOP!4;9syDNpG?-Bhk|Jp&Xw+ctxOfM4_oj5v6XIE2 zK(6E))rm<4jP$4u5PNI0K~n8Ud~3s-HO7Qffs#6hFYi+ciBG83?^S3+6`zad=vWX| zgHxTkIVpzlA99Im{5C;rE{-&OV2z&d`NZ}&H7B*x=hth`0ysU{x1vnCOt1_)8Wiq< z7oUi~LWuW9IJ8N6a=eif z&gf#j^jsr0RF`Rf{4%((b2a9`u*p!)_#4*!H>PZqokG8)9Bz^ot`d5X7f(oBCRrS_&(0gF06Ja#dax*=|JIi$_M4or z?OZnE{dyK}eRPffu!RZ&q8GFh@Y2W2`0_`xZ1W5?4TEtc=I~S$8{3azAKlR&>A^T% zGymlnqrpwfpyzG)-?NDZ8gkHOJzfv|jmO($1K`b!W*K6VBdI|JW`_*gGT05cY`1L_sU6BN~P9$;0G}S#%ZZ< z#J33FVp`22u%YHVKYYbfa`s`lcmY$7;ITUPtaaBe5|$Jg8b{WlxkN#{k;EZKP!=j6 zxX7)&peRj+9ew(#tcAx>YZJpVXo#4`#f3kLr<)A~b2c6kc(G02R>1AB+`(sH{5-{Z zu~hU2{PBJneZ{<1W9#RlQ7mxodRFTb%$k6k0#usT^^P z6_&k=E+mx=-GMi7S+h6Na=O#a)F*&Qz&c+du@?R~{(N2o| zhXQFrn{jl_+6IfTk-Wu?v7hFC@W+8Js?Fqb2G5$a9kMHq4odOQrFraEPXSOsdsd*F z&h=8xj%a>N|5QI((}7Q?htG$T8~(*^v_chG6|`R^K#ZjJ4kl7k0GTX>kJWLHM?}8x zKin6EfH=V8Z3QclCGbMH{3j&9Z6eD~;d`Pc1cYK<Tckmzdw2gz&=jr^~5B}<{CC1{){MUOBgGgh=*!= zD-~o1*YtNpG}0QC$_J%Y9lcyjchCy2i`o)a-t-?>*XyXE%^jvH_v$(7-ybN=&>%kY zAp`R+W5Lu&)F|T%b4!A&+DB04^Zsp5jrt@l^oSgoKv_fbp{*l#%@VLJdXj4UlUMTo z1E;mxKi%aPs<$Nh%Axj^jUd8hIGo0}yeZA_!$x&@jOu7J(FHpub6e1Wg@~3$J?AjX zRVxF1@vv-oSyX-tr)n1>z;Cz8e2{yA*jdAfVS1(?y_$!uH7ld+LULfi*}XJ6GEGC_ z$j2&+RT8D5=T{Uihwl5s^jWNUr_E9{Ygc{*yM@ok)YZ7yHgQeP*`QL;eN1FSGI_%{Q6K;AkJl#f!1z%u94C1R?g;~B5) z7OVZRUFGw{=+mlXq$7~STT{#as(8XBF1c|K1)&75QwwLYr#mh;_=bORT^^vD*E<=| ztM9m3E`-Ak%Rr-3!xmy~#BI8;+}V%ns9%Zdp%ApJiVcwDH?>mi`TD_ghmt6Pk=R8B z2sS4Xk}W?mAnk%BXRR%K@G%FVu@XLHsn-Eu1||7}&p{KRz5?y5(m(dWgGVtJvIg#( zplS7bLJ<6?VjL~*2b}@sQve@moF$DlF6@&p(&pP8*jHlQu-iAfiq;?C@x^JTGSW<^ zuTyDlMjy!%uj^s?rE>VX5V7ZC;SsAKiD>g=*EDFV!`9{07*y((MkZ9*^RYN0!DR9Z zkD#$l3?DQQ5A{udJNhc?SmLmg2uBiVRid}1s3+(*6UDZragk~IxI-z6)!5K{vta<~+SHF< zOicHsT)hU!xzqE{!f%5%{A1P{R*~e8)VFqdu*GUzx3)blcA}I`FF9cK+sko_iEN;_;O6D zwX*`~=wSU9eQ6>f&1rH*C*wMybfi^oG3D;(`fE!E=JQ`ZH#v94#y3fWNbbO zhdU6(QX!@pEV~)93X5WvFqNWTGR>*gDz8n3HwxBb(_|55!71Rg~<6v`c4eykSx!{{BdtCd=BTqkblSM*R_#$mUx5RL`mR3zt8R?n?5)IXZ zv5^ef7j;w^_OVvwTbk=q0S2*BC;-Swu=)>iJ;tt6hrwesl7qAlVW}Y(t&3F}Ub;{z zrId0N4Gc3$(0is@`Bu5sJCb{tYFTPtv__rIzy!@;r%hfkVwB( zmuXyYxWmvM5LT?nS(fhJ`-+R+RR$JZi1dkm++D4MNC>KmSmKK+ex7UkK^^v;vz$7Z zcWFwoUn}Ep+v;Wue5!JTt*hzEV=y^WvqyVRq!iq9gau=qBSsP`&oALC=Zk57e|>5+ z3}(-Akjx3$i!Vr)>?BM@^DBeK$EF4jp&T_Xt<@H(8#1ML%+b@?TDY3yT!@n!7(xO0IDIW7|6G9! zm9}piw+Jy7Eg~D7+YiMbW_8upDaeRwj;hsqaPo%RN3-Q4Bq!5hXvN{LXgDPWx@yyF zk_84mbE}{xW^@sw9?nK;o23>>`72K_Xyh2qJ#r&;mayKNO)a9#TNxCjRIuI6dTrlJ zSEUWUd8Q{q8$0IF>ZvgLW29w5bx+#tp%Iv0F_`L!llxz=l8=_rFt*fzn%@9GZ;x-( zYgrK)qpY2p3rXLj^%Vlm=^ouq?z13|4^&GUvh2ef4xzU6#{FF}6P0%#fG%G7T?Ei) zdFUTRvo0Q<-X)ba2b-ayQS*5&@j7dVXPCLkZ7BB2c}B>Xu2JIPx{~guhG|lfIyM%A zM$zK_sd%-*7a~Q0aDklC?+4Kx+;>U)icOe?&EQjn&Sa;43JVQI(ph39X^0o2IQ41A z3B3x5N!?@DUQ*jrrte}7*WN$C#ZyFH-VAI6>zGT)y(S3`5+i0`>@uRi*VzsG?ahx| zk@4FL(H^mWZPt<|?-xx*&s$u{rCo={TWXvpYnS6x%hj6_{v9v)gF)P+#c#_~rwFD~>}ySLxH!Vp-WERV;%y(92u8H+b&5xF?d+ZpbKH$mKYK9CMd~zFPwwEyhQ= ztYYMOl-AgSa`$E!WxHf9%T?EpD0l>OL-PWbW~jSf)sA{s?b+gFmr4k9&_CMUhfIwj ztI9Vp6G@|H4&n}6jWBH(2Q-uOInJ@Rdf`;~700YEZegvUp*5d2H01K>XxxT>(Nw=3 zvZi5cPGzCI>+`%#%zm4X-Azh!soJJdHKFE_g#wcDHiA8im*#!ed5MEmh)LxI)XZRO z{T*DQO5ogz-`RR?FfQ*vcKOSy#79ozl)Pm~A%(D$=$fMQQzlO7ZET0>SdOFW`XEm& zzMny7Ot`C6_m{$`#6rk;Z5KO$jhW^td>$#;1x@#I%L7FCq(mg-ZVV+R*G#Z<f7fmxI%8}i~Zkce8U=R6P$&yk;zu@aCh8Dpes{(T37XN%1FcUZ4 zQu&)ioD(+6@g_WfIv40k_j%-LkUd(CoSNQICxQShNZ2$6%VR)alT@emi78DrldkJu z;`MZn2w2k;odHMW*GP=zuR5!7U%IfXjNuQns6(&?7-ow@;T|-vaIw11WKXw zzfDEf+?gwC`cipWE8H028GTydzOaXaar`fCL1oCF-0GtZz5fLqful1hazW!qffvri zKX-8)qX3O?`V~pHhowdjgTF1=qs=3KF|Co4Bc`^Hwk?3!Pcwlk6j5KJ**pKW7Lcic zMi)gXcomT*c;y094=s}1P*jOB>hTIq#97BEC5$AW^P72Ki>+>rE4%$#))f)ehF96{ zpFVKQ1pJpTpyS@lE(emMZ;Idvmt%B3uE|jOo!UmKJG0}9Pj2s6?D^et;yT@-k=f%{ z6#~(|b3WS{w2LYnP3f^kbK-pZZMFQ>{!3Y-`(XK%uj|P=zan&vmJNoL$0+?F-JS~m zqjHh%@+(@BR)e;<6*%a$PiG>3y09yQD3qC5m$UIwJ;Gd*VlNKw@+(yeBOV?O6Kt})VM{Jij#&v&1i`{K#KAmkW-f%Oz_s0t@?~BBv@MIG< ziMoi&ZM(=+7i1I&BvHrL8ezD{f^245ES}QnlGK;9=(p~OR|Hz;z<@5jK zBY&loVNaOXSueZ#u*<(nXi%MoO^{SN($$D&Pb>5; zpIGH!@`a26Zr8`Nj7QSh!a)x4?|E7>@=#}q%ZiDjO`ABZHh0+m=6ef1got0gh^X$G z#{$$H6#x3uD_AvYoTBj?q$Y@2&h6}6%V?&+ETgVdO=xVyl-@35_S7cUeaKwmSSe@Q z?oJ3P#!<>;CTmro$KV-*8|;bJcDHpQrtOmqjdRX}BKQYLA^aAG=%$UjqUf{MhT4!P zw{>|ah=|}A=*B~*?jAOsx{d2J_S1tI%eI3Y{wvNL2&pRLMptb;9-~SbCRm@cHvIx3 z`-M^MM|!-I>9<$}%>CetOE z*kz-h2&wasbtwW(Y{7JdiZMACejy^rSw}|6Td57;eMBcjlT!Mja_>)u*N_Xs<-Yz2 zVcb6JVe$bbm=ndcLa@(1#$=;RDM5k}qPb>doF6{SeU<2r6)qJLVsti%{@Qxmt9XW^8yY(77l zd*8jvoMm8_`i|VOVr4xDDHZWmc+c1_Y2SW{cVf?fUNI{tG&Iv()t@G8VeR1vm06#y znNa+NK&no0 zyVPw;ND>INd12%x5@)!!&PLqbF%a2hBZ$iWSyxkXBpw-;pdzTex#U6*Cr(-4eo`6w z6|^@3J%l0781|2CzX{B}p243b)6dLse1U!=BXL%!*k|Y5c4pP=1+{LE3Xn!rr3yCp z@3%ui$gb&6QfBB`XGXHvA~d|d5f}iDr(uFN6H0vH!WI2XrR>z`;I9}-JbzCG zfM_f36nmK^fCfQEdN90#+_r7B=r6k!LZz3TM!E=Ato%72Ru}IlGU-|uGY|2oy3K~O zdT*KzSBgb3VCDRN6}TI9uTmG)LVZfq7WICWR)Tv+>%nzX48~fW?oC?^wz}P}QcGF) zB+L(I#~w0$+k~X9@@L8L_Lai89Hd;IOLe3@%}}Xwew>l;*{2DTo#r8XD>_@wCNVBY z>W)5nhsY!lu9-mc$TpB(Dbc?nK%704an_>E?-nZr2kTn(C`P(*ph7Pt)YA(uDByi| zm+TPr8Su-#57`kBn4gf}AFJPvWL$ z9UDQ`>-+U&?>l&YXuFC?x{DA>gx6fcx1Eq|w6ec=3C>_-HeHin(2xUNc6#e$$d6yP zDiP2Puo8dFPnROe7^*hUDsunS%qy_`;#ad#=Rw`PZzYin6+L|K8#73gE?w`F^FwfM z7PoMS^{C}hUr?|$2!0F7QTK$e?#zMaW$zQq${&iC4ACnWr6)m4{>=Rlb$7=qNZ1(c zZCfYaruNq2a8hR#rP;o#o*>AH{theDYIMYFIfM*f>pJ&mIhgXoJ zlv7`>N82XNMM$oKdF$@$to&jK9DeA(?p(Q=DMGVA!%Y33ztrCfN77#;Y*!knZRSbC zo>S6RXtsn7(Dgu?(|!<=;3}BnTl{hmHqk!{{Px!$z7G2uUC`OM!5*80C~AV5GOmiO zc2ZdfCv2-A>e6&+iBwrR9%-xh`Gu&opH8mp2BLViiw^VHm-(YGxfH(x7U8h@SvaMu zn#23@{6Ins#3>dQ8O~O|;cKICuLt(Y2};v5@)+n_?hwBTTxA?!2j8@fM(>$24GLEg zc%}kskI%-;!NDOu$jHr?T{APW-#~|`uKo9kDo+eM#J5Th^WNA`{e?G$Jik~BFcm*3 z6dbXYioLYqTG)~lRiq`3eM?v+F|vv6pgTL$?w`r@@etUNHH2NReEJLq4|1n(krO9EyH?cvsn+ge->#S13@v*0bcEfMQF826+-rUQXAQ}$2C zyc9DSbQD?lsJ}7C+-K6+Zm*1Ska>q&55!Vn%l)xmR?tGi z`JU?O`nz||3a7(sUG#@lZIJFW9`H}AJICLsvpQ zCC^|#A5NztjoWtt(cE@6y}{!FH2xc}jx#!sNi^in)WfR5gkYY}6A!1iZ0?LmKn@?j zcll6W4*b?0=?z%`gn6*?pQA9k{l{%+dw}oq1#;XRvd^|SwUnVzNAI~AWmEaqb{$c2 z7wIZDSI><7|E`8#Uws+ZJUllutUMtn9&q!r*ui*NvihieTe5g*`q9li;GV(ue~^^H zUoUc4J^twe=nj9ox=x{eSqJFTD#%5fmND?M_Wd%M>yN>I%|6N36A|}>X89cml0Vpf zsXjV2x0S75d-W(Fe}m0sDLVM*&w1+ROv0+Qbo21^#!Iz|ul}Jj+L|#DDl0r~3k~;^ z`r{=k_KH&cY{%T2G(=qJ!~1)6rz1Cbop#0rmc^PQw9V-4lP9a;7&wAVC--T3d3kyF zp8XzN8D5I}vIBu)s`nvBas5%NkC$-af%JF| z?u2gAr7_Q@d5%S-6IZGf#uebdwO8MlgWWp9hx-y&fE_@~TC1{nqJvkcfX!$2_kX6)zTTBq4}>FJ+wZ;oKY&nzgAhcMo)>j zeg*r`o85$ zG1fokrriAz0$pcgNYm7dzLR9BZ4bJ^B7Fthhkb!nojF3n4r)6D_J8#gAX5N}{i3OT zJ6e#zPG48|^FIvr>&Z?SzP;}~OQ0+3%s@yY z!&C*PRUEqRJ1HiEftl+i}-@B4o&;P%r&jt2W2exaj}DhUkU7)jHB zxk?TfGrTdnZVazQH;#`|;>Cut-y6OAKL1O5xiK4?V}_x((>V-ub<6(MP$@WQ=8lYC zPX|4Ls>eDZKz5T={vTI*Yf?$A1pwKVZO;ZsPMQBmPDIP_1FdL#VQT`=7x${o%Q!ZG zX#4)#Hgy)c#h>7I5^nrrnm|TK!mVL?5NH|4UuyQo4q(9Y05KSSk0|O=%kFISxxd45 z+F`uj&2@9_-v&7hTtc9Gedb-6UK zvB>mt)Lh7ZXY}}AAOBksy7s|H^Xhn@#}4%d0f!p@$D!XD3BF++(pp;(pUHu;oL)^$>2_)k-UF3^4gZTUZi;uB0{(T_(%+3klq1~ zf+KE}%D;OoSt{GwOJ$|Ep&>|5R`Y6N@GoD$_4DW4bahoA)|i~u=;+26f0O); zdN=R3My#7;WsHT@^GNjWm+N!?vi>@+NeA-(IW&RE`iDR0KOGD0t?Zi5)PmQ-VB&exFCfXA}p$kpAO^3F8O0-aCNxW!#-49 zOEadb<$OCR+-@axt?;$fBgxKr_q$aBSr}FRqcT0v6TkDR%1A)`?GB?&`_Ysxyetfu z-UGOe{p8j({!80eU#;YjURJh$O5x8M3NGleR{H}{`UH5-&~XIKuek&g+5woYe@cm) zp`PdRzlCm$`QHS>vE){DM|%4^?F1t+Q7l2w{ae?J@Rvd#xnElTR%*%bz_0+oDo+~u zAF{>to+=dEoDq--{}E4r+RE4T&B%+eV9Zz)5i$VYOX^?TdqeJ_@`LzAcOS5Q2TquD zx)u$8v-e*okja?-o5_dlB6j0+b#>eRHA)P<^QN)}w9aXZ5t%eT zU;;`HwYoqO!G0$ASIw2hFV;ViPStD_xt6hJe-Yy~P29|UpFEP9q*gIm#U4I3#p}J` zF|Dz^_Yj!fl&3{D{e5uk?lE*e`NC}P+9t+KtnWif)6Nc%ipBKs%UA6%4wW)-bdd3gRm_0MQ+8)07O*m56DQaPbYcCPtH<#+5Rm`S}XmHFV)zAiS%ECM8A?i zf6(WzEPWqP9Ioe$Ik*3kq+=>yf;kM{1I*{~S113IRWaBp zlWre<(p&BaXcZ5juK)OT{*=XxG65rTgGx5(EjO9zIn6nOs31?4K;CA5nNsvNr(R)S73M) z`)kyg-!*uHzx-Qz`a)QQ&^Cs86R@u}#NL}u`-ATOpuM^StOCChXAV}@H~;Ewnw5lB zImrinQUF0b_;0yTyy*7nUaZNr@cf@T0aKr^=~g??jc@+ljR4CpCSm7PMt%a03jN=s zfEl2gG=wYo*HrOeBP<2yUODKcv^Qq-kTr$=Kf2C4p6z!3|92N1R9i*WYOAVhRn@3@ zm#UVcs}Ix7 zAd<4ecRE==1b@VJ?ThH&?Aj+%KexKFu+KBPH~&ZAt%1$1ehOQwsa9SG)*H zkdtJua{KiK^`w8sK>re}>6V!7?Lc9gBEg;RLDT;3C2{n(HCdP?K%dE>|Mz~3+~6tn z{JZJbZ2Rx`uPOXJUHX?j`0v;6bmrPWmxTTb0S1?TEe$>W>(_s-8P!P0u*$LQb=O!Z z-#M+Yzqb6ZMTmbt0430K)@{g*z+cn){}&GSoc|VB@gnGd-rnqAYX|>pgd4fSQ!a(D zdAxl){E$={qlQewaR0jwZVT6iHEH zwIJtLVvgJY{ppyN*{@(8Go-bVxn)x`yIWBG>yRf%XYVvZFgxv_DKNC+H&!p{vZW`T z|H}i?V@lH#wD<|IK*ZhrfD@#pb`p+Fgq{&Fd$QIDhO&hw*3$Ewn7FvtuZ%37S^PsM zz!#|K(wnbc2>R4=NdM=Cs}{>XJf7L5%c_uv^lhI`4Q|WlFE3CwD)qck+C~3!B;8rr{I7(7KarjgXnXz8 zxae8)g2Q0#Yns-txa9(R-%G;$u}s>puaj}`%x8N`HKm6QJb#;BpGo~Y$}}I_6aO{4 zjNIWVH*WstN8;1{6HitC<70Wt;$-w+y4bIu?N5H3zIN_EJ`oY`&Z`~%xJr*0n*Tk< z{(T>0$(sg$Jh~!Bx1_(aq5m?r6wU-4vi^JecyT@4y!j95@~=QIf_{BSsu?}7_@BqB zPnPvXdtFoT_5Ky9{1PO8`I$fSG^H}{UfpZr+D(@5q4&55x=&UC$MlE zqJ1uu9xE8hd|1t?0G$yI5v7|yVEA9cg;usDdo0&bzv(~EHKcoCdZ0hzXn1&*sqf0) zWG+Od7hOA_$`w{T^&(!*6!1!=V|&QKae@s*Gf73(tn(uh{*a;us*cjh$a&9)QmY36GgpcX z36}>;sNw^I{rzmVMrC`iM<^YAT{GgiC%3F9XS9FbHdi2l1yo*ZH?+nCWYu*i!mc-0_#Gl@?^U7~&zq zUNaRf`;Kpe%?snm&cjCVp)BpJTdfA0YuY0W_rFloTKcjgs6o7grjWh2J3n7SejLPC z$<|x}N{??YZL!xJMl0y0ez;PsbL1%S%@(79R-3+p5UqpEmLJc)DW#t|qk~bku~a zywr-iK$k~lWFN2MdYHyyDq6n0_~_}UpqmU`T8i|T-S=V*uQ8N)hcHyZKV5+^y%+C{ z=^NGhxzg&HUh}@?fv)P%M4p-Sh@u{Hb`3!G=2(jnkibYcC!lFrtcY30XN~>MPviG}vvz8kaG%OSmFp2x zGlGZ2J9>OOHx32l(^-@P&6h_udegq?|33AX7VzRuyJ%Dfw(Fh7XOHiKkp`5rzZAk4 zx-uZ?!k~X&9jtic;#4G1%Pd7+%>W+f&Bg!=4eWMZKGl}*8hFx;&rO@l%kL1QCwmk2 z>r@8q7*c;vx?!daJxB`qzZQVg9o};CACd6X-y{?!x}f1%V=;^A01YP!ngdBxADZiO zcpwa>JZ|BmK&HvNz^N-cSCr86?2H3&c598eWqsNj3t=4IxL1W&vuWoWURLZVK~yHo&oA@djZZ*Q1ve9BTjUTUfX2NDlIER}gZqw_I#5(G*+ z;J`Usg9U*%QACWFBZ4(~FX#^lmtpB)RtJX?w1`Oz5cObcF#k;zJx`l9oe zirpT@mH0vVuGoqT6eYrI07T)F$3s<{ zeb!_>OWFt%pY=}8v+5|i6XSUUWo}aVoSeA~cN6gQ>TJ}n-@X>z7*tWPMd?7LrDS+% zmZXZxX}P-3N1wQO{c+=u*)5;(LTX^^3z;nA@Z&{0$hbq2N2Xs{)5niG1Jpsqqmcpc zR&Yi)gs4#Md^{Wl!75{TC#P!$V#nLkuz|K+Rhu7q_k3s8*RxSb$pOW|0~-IG(HGbC z%x4hazJhP^aB;7f7pA4gZBD_hTvXK`pn4&uQURSmX&a$Dn@-A8oTKu^QBF_HU^+Pw zq1hnrCW!`_?Dh3>SuS^v<82@1`i35DV;RL3&;Bayw(*(AP=#GXtw!0<`at4V^+a3% z$2YGfy|{icsBdhA|9*sMA#9lNnRm}j0scVt-qS+0q=avm;g+jARcd*760?bee9O5_ ztt<1iC-KV{Gf2(W#B;-5Z^z~lluabBRmxE*JI$i|iM(B)3m)lkedb)a`3`0UUzwPh z9_*i&_SRtDN8U4=%cr8{OzO%~&-Zg3<2_lJ8sC9%5c;gPNd1rM$2i3Bn=xqJCV)Z& zjPnpa_4W!e?*aDVyRWTEQDf6{&ntZytdzhDq6qWXitRRM-2yF&_g(fUBc!0vbziH+ zQWNc_(M<-ysDASYzAw5z_lA}kZTE!%)bDbB8Nq(YHckV)@6D1s9=3qMx7G;F)29J1 zfe8qgpk^%zLxaPv`_CukT&K}d8`n+VuB8WkKEI6(-`2CuDo5{3g9E?S)CqG^Zd5d~ znM&pRzxq(RH9Br4h*mj#`hxWCa}Sxx;U4GOcUQxe_X^(Ade+dZOxIt1pOC(AxV@Lv z%fQxaaOr|Z*i~Z(`I)9o;(@j5g^uz}q+qJt>85N!{x`2s&w@a=oY+j8E(_r=FOlvE zzA=FKY0JVd34Tw(U6Pu53Q&f*ZxX`fRLE~}t!MzRtY-`SU{qOws?pQa z!vK&D9BIE9Vs!^lXNRnc)qWoJOvo9%KQHp|*sG&bNrS$h*mW0np>Wbf22s75VB@1x zOH@Y_eZ+Ty+WZ}6GbpT8^e#5P+iO8b z)4-8qIq)Nt?P6Jr`{9g9rBKym=wJ%i1W8N^Ly#c12Y3dRj*cKa@jNP@37Bv3YflXh zMQ;@($0Z*h?#^f28eC2?i2ia^2_1bsE!x4lnd(!$J*6|f6F$>)6U89p)a4!Qhr+w0_d|}Se#azW#SIW|ghI6^hTBk;bG(wY27+xpu zVqG;%33|PTW!7yBcYTJ2%Bj+$&5E>5XmO z(eA=Jkk==-T0`l?Ica+Tn4DpnhhKpWHWkwiGccWp0WpMh_cK3^greH$RLs7$y*+6l)e)Q_u`%*RB)RZKWO;bw8-_CQsMRc8MMZLbO0Ks z?GGSN9vHLi+0;tTtz7v2%DoOs3JIy7o6td6PL#lSgz%5S{hWRe^UwT4UY>(SvcC0O z5*>`zgV;uj;j5CpWDMS#{9$#J0GkRh)u%t6-R=HjEclKgbVJU4TpHk5{X zOeJkQiOu@d`6R}09T{n<(RGR?a%0~1bgmfIWq;I2M*F(1`6q}=u1wA&9xN@ljsF?PsTcF=`HCS5~IYaAH2eH`5n8@Iz_l0KM&G)I;o+e zZulP6ytu!nJF$>JTePN%crpuqh1KTezr|I8!OQQ{JP0+KfEjWId}*fF<~R^PdPS$r zK{Lx7yEg3Wfr-1*SCpw>|5DB9L&xH!82{N^FD^4yJ|lg-4ZA?nw)N%&F#mJCg)(qe z$o`uQhceYIYDRv%cf%O&>0h6=eDTixFEBLM)@! z1j$GRF9$Gn|Insw+wi_!w;nmBy3PemYlX0+3+VF9*IU$N8R_#YW1YFAjzgqKV*@U9 zXGWCT%^ry(JpyQhkgx7n^OL*3MjBL}UqFw;Lqn!k16vCbZgpL^uBri>h zr|lu}3BrsKNvyUeR_JXr6KO}ewXIMdot<67$fw#BDfeP?JV$c?E>{AG-JNJ~LV%@+ zix{`G+tTPTo3)xi$C=N^E1#N|ky@y|fcf*UhGRuEG2tf3Gah;&hR1UgnE`$Qu!qS@ z)c28B6F#48o?*rPF?Daeo|@nkhKQI~xHhpe46C5*4BuH!Q~_E{edRM`kws5`xnI=s zyFzisbPdYrcfs$fnoK5|!Ye$~%yFx7o*?gZa|O_=-G}%@-CKt_u*NFd{(xv2l=lMC zCpoHeA77U8MzuHUJTamspSIA7PQze%doMe|-W`n+I^(XC-^zU0;gPsPMP$hy9*oCi z@e#IG>pZv5N!=3iT1t*im$e#MT(r&~1&XOV1hlo9(K}>1uMF`-B)3Wp4uxfrrn9TE19Wp@3U@*p!kLjwaJo? zAn#5-{o~`}7xCZex=Rv$x%1qhNAH~%6hA$BFD5zr@c zlRMn;eF7Isg^ys$?;QJGdJdJzI|TI$&LG$4yO*q^v`UtVb85nwzVpJil!{v(Ij3SUIM>K0^~ zRMFr!94xbfO(0!(V;+b=&pI%N~GKKj85 zd*mW_sMoXD^+(sVs9G=s$ChB~nF#3BL}t;dOFGnTRxO^S0fNSPh9tHv^|5ll5q%Zy ztoMiG{1XX2Q)OyVOZtbfqI5(_LElW>QsWzww3!nDwp>8Ui2ZT!_TJ|n2^>P00kP9w zBF?Qs20DvneDm$jm_NUJ6UxDI<=9MRHEwi+a6=--3xt_8frw=4u5PNh8FC5Gnr)V% zb<7Q`Y;4osmVFpR*taG~9x=wMi73}EJ(l;w;eG7$n|~i6wO| z*5`($cF@TicRf`re@lo>R?Xs5^qhUGSDL$vI=)F2E54?`JC8?pv9ra(7Gs}xft&NS z;IYnJ3fAmA4T_>bsPiQ3_(_0AO|GP1S1P5gw^QFZKkK3=?ETCx5-)CL+cq9!Lgp^5 zbUN%5*cTbkX*DpT@fVsDmI@?Y9%LqM#M>Y$I@dQ5utirrf)6jML%`ZPx8geW)V-l# zS;pcg8K}soiJCeurFrGX@7$Gol3i*r3aMQ}?8l>e zg#(%5-t{?^S{e7UBaVo>q5x{YH9QZO%H@J^o2hjO zT9p`m!31>Rd{Ca+a9z667+P09b@M7yrvJQ|OmrVK=8G3;EX;fgIj#1i(M(~3sHwh> z=FQE$^+f(fI`Co8Hnmqczwj<9ZnKp9Z4613ml+T?P8>$UAq6W6xxEgVR# zrtVYvG2w3dpUa6&LOb5u1_0K8#ruF)ev2_staQbY5`Fj3nAhOy#^bZzW%-_~N6mRy zY(zO@UAz(4oy!v9KF=#3=%_U8$$NYiaz|3?3JvyQnlE83GtC@bDdjvZa zX`_ibOq}jR>1bF3O=0xw7b4yt) zF|&pxnn7pB69j4dDgJ!P>5&C}cip7|Xv;n_JSCLStLEMvI=151Ox_iLp}8a|frDuW@my^4Q9y499L^7|1kVG*VvQ zawWF`$c;eJ7g-wI{pc%bX{@INPos!Ehe&M0K)qa;%;DxetCmf74K@7h$7Zfl!Rsr_2HlI_KNiw=uab5r0 z;^qjC$zi}VBqmOFz;~t}IKEy%>(2|inP3x(AriI2E)5PtKVTluvJT#mT$KFDNDrbk zS#a@g@L70X=&&1-QwvxttrjR-@k9qelyW}B6G~3 zeQl|qV=8a)NgQf^`^mA{t^AGN3$fE?>eC&5-rGZ#rEn&gL(!1)#bdKaQ^Y8u2zoQF zMf|~rOrHIdb1MACF?P|s-~N*pzDo#NCiMxlmv2HyoPsr zN5NtPddh{xYXcfB3_ObbgRF{1f!^4q@|}a{F+St#@)(sB@wE{J4-sb_%{~*h`N#o! zJJH*#@J|V#=&;g+CEDg0$2yi4_xG9mjnf=w4*0#(GOt<{s4r zJUUc7#xG0okx9?zGgF6xZkhGJ?D3K2nL<6Ow7mLsTGfg`?`G}z2tU4^Q;ET1L;~+} zOBX1JJ0?1IScP&#@$twjAH6`Zq`TS-c512P%}wPh+<(*Y)13RZz2j3)*r#l6*s(P| zs(zPrWFZMAUCN*~*-t1Rh-6S1;+Ca>U{QU}`B#^lP^;6XuWVUFvu!zJ#jnZcg<}N8 zEBZ$?Lw5VuN5)m~-0djFi}Ad_ITyQlkE~1ymplzeM%08UO`&G&%CJc9m8ho@`yHyn zQ64b?>yrn=wnUSIs~9RdgAa7`yiw27XyJObXdSi%J7`73ssPBRXX+oO zIaExBBm$>-Ecs;72FxZN>%oyL;$F7sDrCiPYGiz{R%~i@&=F3P8Ct|llbbG*JTpfr=xn-lY?oP z3H5GzOqZi6SAkSdlRLG_!7Zp$8$V~A{G*ztPmDVjXzWN*^Qmq)9uYTPO~AzwT();)Y|a^myi8^z|nI zkTrC)@l3NX?2W0p)8U~@Eu~>IEf(5vhy9+3;$C&OXQ0Nw=$QNpOl_RTHWK7YD?(-Z z2G-*wV@chWZ|Qbm@C>W4BKR^?xuin6xR8l5Z%=6&_M^v>SF!*ioMqqd&?1TZ1SW6bgb`!Fz}T&e z0QRaf8DH*aK7t>w+bZ;DRidP<_iJ;A&!w!OGL8-;hK~Ewxr0x&A4R-T3T`m&fL~M? zF^QET4BSW@6i%kiJmi%1=ypH!dH4`;+33sRA5asgj~?w?QJU{7DNP-c?%@FouwLGh zyuu`Lf?UhF7z=US+WRy2UCU(G^lJ_oPpmk8iM_!Mxt=c6lxJ%k)FoQEF}Wt4mO|Ki znp>KD)8CA)KB1CDN`{wWc(|h7up)l#Qo{ zL6*l|$F}R<`1KIW43(}{DPe3aR?;UC_RewF45zFD2Bgc@g4-AgK2kobd+M66s4Zh+ z=z6?+Q%GiKN_%os1Y*`xQ@s3&pqh>9fisRfQK4dQTu(4Ipz0uZF!oW41^*`%2m>y2 zQ#JhCh{3mq(rZ*t5}jL;5v26v@%k=nE9LE)5GL6rzc*E^Q19%jR2=b#sH$SNzmdh6 z$7da*X4&!v*u%=sI=i?i1kNI4_R*P{)LVD0PPwexMg)G;{k-NX_L{GF`zk!jHF`5~ z9C!AdEN@I?OHyIBce82cSH*1pKU&Drn3r)~i+iW*Uvr=BFlSXzc^j1{#x8tLp))wW zU@dFby)kG{&$W56_m;O?w1CRX#PeLkNZ=Hxt|J+9cUD z2cC@DKqVdHtvsk*;tHmV{ZlOoLNwwQR^Sb8d!&5{7^(fcAO1{;1$oL+_}g>17sG+R zgZOskxlf<8^{6kp9s)ni{06kBQk5dA)zfZgmY<7|#dMNN@31ZN2V6PW zjCG#z{A%C&o)T`P7q;O?4R^L$Q|A*jKc5|e)pWzD{Q6h>|yYm{;=tXHmBGV8u{o+ADC}N>$H}h|&-2gd(U*u1@QOksQ+t*2>D-Q5(km;s5L{C%xHU<<;GflYCclc)^Z!#P$Xjc=)0<6rtxf z=Gng(U?M2NxvKcf)b)ZE>za>0XXW~~a;{Q=bq%}P6+838*i}~Un zu$$QPq0{Qg?&6ZH&P!h_{!M0$sCXmFs ztRGth3h40QlQMm_8UN(HyqFj)d2)S`O>Mlbf>=$%orZ2t+Cg#ex*`rG^Kyznu8%iv zk}owSDg8Esd(ESHYVA?g(zd#iWAgEI05%=I+%ypXz1c2$Tdh>ZpOj?IP@Y1g+zeK>i(2vgxMhxb8*VR>?S3|6Df8ee|^*GoKksWOp&>)iemY@ zm*5b$z=C!9PRQr(@aIN4FFlS;xk08aVdi%Vv!X)dxZlS#@gB>HOZq`#`P8uT0%G`< zYB%K!YKef8ceDQ+v|-$q8M>kl0o5C>N(}( zd4s4x2_WW_PTA4IN8f-kdQKf8DeQ4-?@0$Qv{uCOGNkt4NoW5g#&Fe5KK9{=I+N&P zu9)at?!*{WIQ8@GM;D(n>sUs7tLn1bNU~LFRUC+-lVnD!2{X3&)>=_g6YQ@M9X55v zl<#NSlUK-Lr^(vw#GP1%;q}2HXRwa{iTH)R6QF7OJ}}$jpOcI+!o5+|yp=8Q?9b^U zVwRJ>U-QFX152-5t2_scF2%B$@{DAuRkJTGGHgUlr2~HFW4YtGTbJe^-U{sWKY~q} zwZCJW_7Fc`g0-yE02dYy2DY_SLtK1ug zV)V`IHN|ZiwojVP=g$Yu&1#AZ-7OWVzchiLW``KVYhV=zWwmvAUlW}LGLxqZyz%fW z+ArzIST(AX?&=kMrVxhvdsMC#d{kG=$M&QD=_dad0S=JObLY_&TNht*9)Iyp#;m;+ zzV?1OH|h&^{lsG17A^OVnZLrV%Jr(w=GA&fjB2H7T>7Kk>Z7k`uh9ose7kWDcYv7O z=OXnifra=A`QV?8y;C=lk$J7MCamSkPKJXkLc1-8i*QuvF~s)WDg z<@>%fdi?#ay>J=#ZEAKw{nJB^kdx#myr%~1>z^BRGnnc+FURbfXfiw(cXF?LD1W*~ zn3ugSl<;&doIjW{+dQaTqnm!pM56$GkcLv{FuWz#ikhl*L0$lxssgbOzE+DsbV%_} zTt^HPkzbg&O#S>CV|=-pV|a{f1L!Gy$GPf;I+f{rnI+fL;FlNtXPndtJR?F@FVsFf zx@RUa#lTd^#_4Oa{Sq&Z$*^~r531fKuM(<5K7yl*P3V%~>rHaqa!QhP! z)!wxa>Pf3TZ1n^o%SgroWYUMZ3(gxkC zo%4hXEtqo3o|vSPdHB}8R#w!-HO_As@r!M+=0w-MKN<`?nC%ZP>n#02d%zo4yOCgE z?l@8Y!)-hpX=_$WE%&DHF1&!fx!2`*lW3e#>)(f3Yh0QQsNW*?)}6);`+5Nu9`G(l zpu6%7+_Y1u^L$3E?84=j756uM)MYBLsuNRDB^)%+(okS_&ty+v_0}-J+`#eTaK*;! zBT90W^X;l9lauqeHkFXivS2ld06(K_XXcNCT!#rl5M!oASa}^lL)gU1#>~WiY;2I_ znwj34>y~`c8%e%bEct#Uvdnv9W`B*i>|K(V$x2Ulx%*xKG*);?HF1st2t=u}93ddb z3GKl2SZVwra;VChx})y1{bnLYUtH~Oi)S<=Vd*r_JTbUoq^;$I)kL>=9MVq5Vr<7v zjU8=n6j_<76bq+x88vUd`^h%@KJM!!7#~A#w{(t8Nnm;&JXI>pr<;X+Da(T zA9GlZyuu*rJgJ_#!(=Gyw$aajUZq{~uF^xcXha|=0E}3L{wD zow*O}d213LAiX^8C3a9cYhgG+5qYu*iuAkGz zD}K|=O{zb6D*gVN8>n$F1i@WEW_&Yxv_$UZZ47X?&Q=pjptMyDbyZXe(ltD>@~2XvE5WOWh}cDnI!_Ux9h(YwP&nx&^#WQ1}QVGJEEC% z86+=mk0^a11$v^78j-y#ED4>ePGZ}I2AGv*+CDCeH%-Q4E&og-QNzr6d(y}UEf)%> z)s|;#!+TSStMI#&JFaSs zVA%_WK5!z@-`}M$pq(e~nAQkJET%rDF4R-@1Fhdkf_FzZGZ7w^XJ9!mf>2`_k@YT| z>zyn7+(YxDm(DxO&FTOmDRmMVZa8q&Jcr;?2ulXHH5ab4XJAJ}%g zEX4>7u=!~oTOiq$?}O77N0qM42$Sw{xhv#IKe+s?%eT}wT61%Ob;5R9F7M1>Z|M8^&n;m=q^+g6m3#S4 z3ue86ekNo29e#~_xX1LyFvM>8%DzHyu~!=~Y(bg*4v%{40DkZ6`C*F1x6~e65kLF6 zc8h#!Sv`%-K-;8cXL&wP*{C*f{<7^?l-F(p*EqNV!2MB_?Rf8}D(Q~F#oFaP;i7Aj z^BV$W?ShF*Tu_(Eo3T}^zC^$#Z$TxB6S$nvyQFg+B%$M1V;DGH?xXBJc-o&ww80E> z(e9#1MX*t5U&C@M7}h6NdCG+0h0#v4fq}w(@am#9?ymT{jx)aD%mrKKY-Oqr58H$= zGVpdh=KG5k7!TXSnV7EW(*Y?1l65f`y-FKw{2Qv;N&_a)-~2?S9tov*9&aXN^rafI zuIli)i1X~uo6u{S4w5wQaG0E=OussCVG&D_gje4koUUxZt)c=O7h*NI0=SOVv!t4C zZPt)h7)xd9BHu8j?hdK4U~a^!>~P!G(_B}ij)^4j@uE}iT0D1=K>3TV_+7r2%7#59 zd}i?X<4KUaiVg&T_u|4CP4Mw_WC-hM=I#XN#*4x-g`XhX=3orKOxCbD_Z~Xqt2}_q zcdpNXAfo{xoxdP*VjlED9e~Q*ZO)hc$sEO-A>(GKt6XZPFk-9F;MvNy_cgv9S-;#? zE?BfWB+m<6!K}`y5e;nz@^_EdvJDJG0r$(Yj4{SEq<}EMe0xxe_Ng~L#$cnsYX*sP1*cRio3XO`c@(ejZbta?bWr;1lLlGmIDEA3-OFm3E$$AjuXUVx7yZW54Vm!@$Zoq_<&$YR zuHZB0G{&|^tdb%}9G_I3sb5A`q7<{I*~Z{gf+BuyC(or!kBbA3q_*WA7Dm0=q3}5z zWR9vHA&i@1TwUWlFa>ly)j+gZs3!4se?YB$g$;rQdz2;NQL3>MzvH0*n20f=2l4_R8L4sLAc`$2U>$@9N~Z0fXjKifFjeTztOu zy4Hn_CchWY=Z04OaER}r>E%>hug$JsZ*1i_jIzi^HcfHiGxj*1m$FYuUN4ndpz|uu zry;=4+pUh6x359Stw{6c`Pr%Ld7?U|4wp2wZo9(oTe0L-s_*QucfKEZe5AkV!X7JB z8`okt{4vyFM8*u9hPMmJ@-XI^h4{Jr`D-3dHxI_>{Vjo0QzZ7XX zlB2%G6+0}o)%9vle8}P_i)ZS}Ep)0yp@jn}a}&;d0Uh)PJK?Dz{Prg)<9=`Z(@Z)P zISdwA!J~r2mOB;OyiU8(3A_`xdEG{68QVYIq1^I|sirrSbEeF}8h&~e59p@7Wf!u$ z2eiNEbdWR_1z|iYm&e(SOsgcqosWX&E-5%$6_j($dHXXBAlm5EcE8T$4@E$$nq3Oe7l{brO=Dl}KeE6QV&AWnT0m-(1($Vfu#n|t(FF?6!YVFE#XdfWj8=vfNE_Ub8> zFww6r>4GAM`=4G?OI_mEzHb#Ktvg_5fKONa(pjldGTT%XSNX5PX&r+mo24wyQ-a(` zuUSDBr|afYjjJWPZNdy!r=8g2N@44snhx~MUs>x4pdO)1W`4V;b4K}P>0++09cji- zYMz1Px%kPOwrNU$X8T=UkA7LOTUB1O8)aJgf>kJsn5ek0=xcM%$Hz<~1}NI$UNZkh zd`MRiX|ibo>&z$hT0~C_aU%2?_*TC6aj4J4rlUKB3_u7PH_PcnAf^d>S}9VyJ(1NP zh?*kpowf3*&&v+LHD?-bER0;q<-NPZ2T-)}HPU^Y8R9!opXKaiujIBL z<}pBg$?KT?o-noa&gWETnE7%FwD?^(51ufqR|~C9!a}eWF~-0lkMpWq6dE0&14_ zb~ZSg+zlzMB(gK7toKN}kU`8{aRT6d%_T@~eqDJLejlUbvsS#hl8}%abGtmN0@OZs zpAfn#G-A(1WrkVr3UAiG5oyxJB7RJTclp*FU*j-V1}-Z1RhX1^yfUVd=@Wq7aKTgvJLW*U3%bfV8V z)vJTrb>|30-?zZR`_lAooLg)%eY=&iB1Y_^3xwQf?sl;ci|{qZZ#{})r}rkE0qAV{ z`PA)}WUDf;+!GgnskQ1@tPEez^&;TCbVg)YO{XV4Q(q^KWVkp~IFPUPo*hAcv06!$ zzAWD{Cq2$z%jdU_&u6U6QuOKk;@-T60~i43i#rd+q0Upo3(PAH+1t4sU_v^0g4tk1 zIrDai!(g%(!8;?Ccyz0F9lX1p4+OuGtg(N8E+DG%s%IDU`2Er6ROO&kUWo{sY^PdN_ z_GeURp9SYd!CGj!^YhEhyOqC@*H%1+C7%&?Yi!=cTpGNwlW&jXd^ZM=Zm8h8a;tu$ zAP#Wog{C!8)z3)#ua8UOIl?Oc7>!$2u5zEB_v#KqDR)+;m)xgBLWrR{1(^{ zy?9R-I5J`R$aZwSK66hJ`_zcW7;@&^sFokFUjVsrjQ(-Ceq4)1xwffZa?C9@iDgQ> z@zJA-Fx3SV6IAe($D7YasibGIYw8-39Exd-Le_(x-P>YtRKPrqkEFQz^~6o8Zh z7Anwf9PIXSeUJ89wxn6P*#R1ec9K2wEGG7?CD?Esgdg8 z?!I#4Yv1QZ_u}u59wkQ)^;a$2eme1ktPRZOIw$x-F^m>7HVIElOXIRWfCqS7DN1YE zGvD{7_w+u-r^c%?0OY=YgpYNszgI$!g0^bo^vAj%OjpVNGkMrgZ4U09jdnO#KMz?-43^wpvMFxcn6P~ zNPLH$Rf=Q`BLJ>+ec>B(OT77yjXLksT{h$<#{4$YU#Vypw<-ai(k-KxfCfZq0nJ4( z`MaHd*Gpv}gAj>t&dA8nO)iuY)Ru`@U$>vEudqM?g(kG_UPOyOSr|OC6@Mr|MMb&s zCwR^CC>q9k+#Az-pp+%=BW5V$+ma(ckMH?)aj3B)niUDFQ|=tD6saTg?)bBB=h1uD z(4${~QfApc{RExEwFHgd=-qrd1|(75cU;Abvy{O~heQssbakC>YL+RSLKnzwQV_&wgrDI5|%*SX@>kf@bz zCYnP{<6i!atidO6Iai&Iu(7C51u4-vgq=@XXYIsdO~X~CNw8YMATnrY5w<*-`|IST z;d#zpARFZA8*@(~Tyn=iVeU)!^ixW@Eu-qcU>EpB5UZfm=wNh2P%H9ircn%gh7b3t z4&<`d9YVhiuZ$>o%X^h3X2mb>%oRqO^K2J#M8p+ClB#}Hfxa;3R+LK`<}V@UXa+=T zULJJxpq;^Rh>@A3=^UgGS~Qt+21OLjbhq15>N!kb$52_#D2I>(N$4bD(KuFqR2o$oSu z(>;Ppe?D#?3T82s^;N_%nX)-Z$vW|i+kosixZ|f?)|d7{)dJc~Y9TA&KIO}fMJ~fI z+T=zDJy`+KE0i5mTZ1xeQsAKP+(*xi+&l|<@oJjvj(!5j zA(0MBxfqO4BwVHU3(_o)?z+k4wt7{3AGD!OhT0D9m1Q2g$#oE@IQ5PJsT7jrZPV`oVNZIwa|EqBergTeuizCgx*cV1R`O^wX9rybKnfA}qya$wp_BOPgsE7oiA#V&3e(l;{jr@Rb|koRY$e$E_V)HZ=E zZ7HN}_pRvfi|s3z&;2+cWqjrYSY--L;5)2qp}pB?{Ws3oj|YJ9&(DvyJd zB$i*L+J3FLLA!KWJcqo}9N^(T;N_JMhYj^VK$DgS!DK3VtyV_MGsz)LmwW-ognl?W zW_mC3)<8#~mm3Jk|dyHSM-pAIH};sV2`ehBCiM5@-J}NQ4d; zq7$g_oy>B_|BadW^B|5iNN1{~{1~okl+7~p^%c{ zKmQA0{_nr1hs^Yam`O4G2OjD#A`hL4(XH;O!@x@QXrr^K|3uXN2WdulU?TIxMa3`% zh5_%t!3`owT1r{bc_|YAjTquwPCK`p#aH?TNk_TWr2HM!;+^jD`43sHg2u>zUzPLH z|5Z63OQKx83OQ$9c@|G3GQxCB0mDBq1^-Ec`uPj`rQ~O`mLM_wKQA&lUA+7^ zkERrf!Ze9L9nF8?4|m)y67^-ih`cFFcWk(>TVsTxjiX@HKblx{fgnh+-f)2D=_v48AW9 z4Ii6G%J4al*Qp67C#kzp-{x!i?UJoU2zfJNR0@GnVOoNDFz&D!hNBpESo^TM>y%R9 zH1KhTf`olY4ahY=AKalnas3>f#ECl``^glM;>R0d2n*e*^&cmyjn*5OJ3E zgo?A?;yXKH?vDg@mQRN_g2z4|eiu+NR3OoNgU-alhUKt%wvt&tv$wiJM!u6R>5<)s z@GtIn%S{eST$Dy(bo@9`_cS&)Lz}4P(SSEdKGMAXCfSv?4iRGIpN9p!kFUdUJY+{WK=4UdX7e#M&pP$=Wx}Z1GRU}gG;Oh(e*A}04|Zh)Fd-W z57Vg>Y9;)0hiSR$=?H!&voSDHlyi{v4#UiccmZoRg>aC$MhYrMSlk=7LZg&wj}?bP z2FNBuYceSLnbA2bF$QuRVVvX66Y;pjYakC6Comh>`sxtSVpKiu60dFRxS4s94%M47 zoZh&*q&gd;SV+JY@Q8Vp z?pCG*!=b)in{X@hbMNQyy=z#+Jf)Tk+@-F@IvlvmP6s`L%FIfj)x@erq=u%KVXgPq zqD=JsI80#_7VJ^zs99P1tIFoAvV6py0{8N@9}kRpm|h?iATs%t9x|SYy=+a8-oX1@ ze|w++Vo<6CX-tsYc^j?7ap~qrI3u(}uWX6xmCArgLdTP3E#BSY<@) zxRs$V!<2Lxm~(LTuD7k+8(0Xbmz|+HVykB@$6T(Ei`qdO7HCFJQxu;458x(WSlZ)? sz=@CGE2A?oY;@Ev`YrI8m?a7U8dmu9<1aD4GYme#cxyufsW&i*H literal 0 HcmV?d00001 diff --git a/test/level.test.js b/test/level.test.js index b96b206..f4a6a18 100644 --- a/test/level.test.js +++ b/test/level.test.js @@ -89,7 +89,7 @@ test('legacy MCScript level.dat import', () => { }) test('generators produce valid levels', () => { - for (const name of ['empty', 'flat', 'pixel', 'space', 'ocean', 'island', 'terrain']) { + for (const name of ['empty', 'flat', 'pixel', 'space', 'ocean', 'island', 'terrain', 'biomes']) { const level = new Level({ name, width: 64, height: 32, length: 64 }) generators[name].generate(level, { seed: 1 }) assert.ok(level.spawn.y >= 0 && level.spawn.y <= 34, name) diff --git a/test/server.test.js b/test/server.test.js index 36a8c91..6659e4b 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -929,3 +929,93 @@ test('minigame arenas are remembered and loaded at startup', async (t) => { assert.doesNotMatch(await command(guest, '/zombie join', 400), /There is no/) assert.equal(server.findPlayerExact('Guest').level.name, 'arena') }) + +test('MCGalaxy shortcuts, /main , autoload and the new commands', async (t) => { + const { server, root } = await startServer({ owners: ['Boss'] }) + t.after(() => server.stop()) + const boss = await join(server, 'Boss') + const guest = await join(server, 'Guest') + + // plain shortcuts and shortcuts that carry arguments + assert.equal(server.commands.find('gen').name, 'newlvl') + assert.equal(server.commands.find('ld').name, 'lockdown') + assert.equal(server.commands.find('v').name, 'paste') + assert.equal(server.commands.find('pl').name, 'place') + assert.deepEqual(server.commands.aliasArgs('zadd'), ['add']) + assert.match(await command(boss, '/gen second 64 32 64 flat', 400), /created|Created/) + assert.match(await command(boss, '/zonelist'), /zone|Zone/) + assert.match(await command(boss, '/help zadd'), /\/zone/) + + // /main + assert.match(await command(guest, '/main second'), /Only admins/) + assert.match(await command(boss, '/main second'), /second is now the main level/) + assert.equal(server.levels.main.name, 'second') + assert.equal(JSON.parse(require('fs').readFileSync(require('path').join(root, 'config', 'server.json'), 'utf8')).mainLevel, 'second') + await command(boss, '/main main') + + // autoload + assert.match(await command(boss, '/autoload second'), /loaded at startup/) + assert.deepEqual(server.config.autoloadLevels, ['second']) + + // ratings show up in /mapinfo + await command(guest, '/like') + assert.match(await command(guest, '/mapinfo'), /1 likes/) + + // votes + await command(boss, '/vote Build a castle?') + assert.match(await command(guest, '/yes'), /voted &fyes/) + assert.match(await command(boss, '/vote end'), /1 yes .*0 no/) + + // pronouns in /whois + assert.match(await command(guest, '/pronouns they/them'), /they\/them/) + assert.match(await command(boss, '/whois Guest'), /Pronouns: &fthey\/them/) + assert.match(await command(guest, '/pronouns