diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example index fb1d805..0037417 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ +# Copy this file to .env to override config/server.json PORT=25565 -ONLINE_MODE=true \ No newline at end of file +ONLINE_MODE=true +# PUBLIC=true +# SERVER_NAME=My server +# OWNERS=YourName,FriendName +# DATABASE=sqlite diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b2c027 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf +*.cw binary +*.lvl binary +*.dat binary +*.png binary + +# left out of release archives (git archive) +/.github export-ignore +/test export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f95fddd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,41 @@ +name: Bug report +description: Something doesn't work the way it should +labels: [bug] +body: + - type: textarea + id: what + attributes: + label: What happened? + description: What you did, what you expected, and what happened instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: How to reproduce it + placeholder: | + 1. Start the server with ... + 2. Join and run /... + - type: input + id: version + attributes: + label: MCScript version + description: Shown when the server starts, or in /serverinfo. + validations: + required: true + - type: input + id: node + attributes: + label: Node.js version and OS + placeholder: Node 24.3.0 on Ubuntu 24.04 + - type: input + id: client + attributes: + label: Client + placeholder: ClassiCube 1.3.8 desktop / web client + - type: textarea + id: log + attributes: + label: Console output + description: Errors from the console or logs/. Remove IP addresses and tokens first. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..815ff7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security problem + url: https://github.com/perronosaurio/MCScript/security/advisories/new + about: Please report security problems privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3b507d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,15 @@ +name: Feature request +description: A command, plugin or setting you would like to see +labels: [enhancement] +body: + - type: textarea + id: idea + attributes: + label: What would you like? + description: If MCGalaxy or another server already does this, say how it works there. + validations: + required: true + - type: textarea + id: why + attributes: + label: Why is it useful? diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..322bd91 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..066f06e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## What does this change? + +## How did you test it? + +- [ ] `npm run lint` and `npm test` pass +- [ ] Tried it in game (if it changes something players see) +- [ ] Updated README.md, docs/ or CHANGELOG.md if needed diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b06d289..9d44443 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,17 +1,24 @@ -on: push -name: Publish Server +name: CI +on: + push: + pull_request: +permissions: + contents: read jobs: - FTP-Deploy-Action: - name: FTP-Deploy-Action + test: + name: Lint and test (Node ${{ matrix.node }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [22, 24, 26] steps: - - uses: actions/checkout@v2.1.0 - with: - fetch-depth: 2 - - name: FTP-Deploy-Action - uses: SamKirkland/FTP-Deploy-Action@3.1.1 - with: - ftp-server: sftp://boron.ca.eddyn.net:2422/ - ftp-username: waxtz.256fede4 - ftp-password: ${{ secrets.FTP_PASSWORD }} - git-ftp-args: --insecure + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm audit --audit-level=high diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..95bf30b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release +on: + push: + tags: ['v*'] +permissions: + contents: write +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - name: Check that package.json matches the tag + run: | + test "v$(node -p "require('./package.json').version")" = "$GITHUB_REF_NAME" \ + || { echo "package.json says $(node -p "require('./package.json').version"), tag is $GITHUB_REF_NAME"; exit 1; } + - name: Build the archive + run: | + git archive --format=zip --prefix="MCScript-${GITHUB_REF_NAME}/" -o "MCScript-${GITHUB_REF_NAME}.zip" "$GITHUB_REF_NAME" + git archive --format=tar.gz --prefix="MCScript-${GITHUB_REF_NAME}/" -o "MCScript-${GITHUB_REF_NAME}.tar.gz" "$GITHUB_REF_NAME" + - name: Take the release notes from CHANGELOG.md + run: node scripts/release-notes.js "${GITHUB_REF_NAME#v}" > notes.md + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + run: | + flags="" + case "$GITHUB_REF_NAME" in *-*) flags="--prerelease" ;; esac + gh release create "$GITHUB_REF_NAME" MCScript-*.zip MCScript-*.tar.gz \ + --title "MCScript ${GITHUB_REF_NAME#v}" --notes-file notes.md $flags diff --git a/.gitignore b/.gitignore index 8f87b49..c6a9f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,64 +1,18 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file +npm-debug.log* .env - -# next.js build output -.next -levels/*.dat -levels/level.dat -*.dat +*.tgz +coverage/ + +# created when the server runs +config/ +data/ +logs/ +levels/ +plugins/.removed/ + +# editors and OS files +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8230fd0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +## Unreleased + +## 2.0.0 + +MCScript is back after seven years. The server was rewritten from scratch; none of the 1.x code (`src/`, +`client.js`) is left, but an old `levels/level.dat` is still converted on the first start. + +### Added +- Own Classic protocol 7 and CPE layer with 38 extensions, including BlockDefinitions, ExtendedBlocks, + ExtEntityPositions, CustomModels and CustomParticles. +- The ClassiCube web client can connect on the same port. +- Several levels loaded at once, ClassicWorld (`.cw`) files, MCGalaxy `.lvl` import and seven generators. +- MCGalaxy style ranks, command and block permissions. +- A plugin system with events, hot reloading and per-plugin config and data, plus `/pinstall`. +- Bundled plugins: essentials, moderation, worlds, building, custom blocks, warps, zones, portals, bots, + physics, economy, minigames, effects, custom models, announcer, IRC and Discord relays, and a web panel. +- Optional SQLite player database. +- `/pdisable` and `/penable` (also on the web panel) turn bundled or installed plugins off and on, and the + choice is kept after a restart. +- The web panel got a new look based on the ClassiCube forum. +- The server checks GitHub for a newer release when it starts (`checkForUpdates`). + +### Changed +- Needs Node.js 22.13 or newer. No runtime dependencies. +- Settings moved to `config/server.json`, with `.env` overrides. +- Licensed under the MIT License. + +### Fixed +- Every player was treated as an operator. +- `/kick` without a reason crashed the server. +- The level was sent before the player was verified. +- Entity ids overflowed after many logins. + +### Security +- Player names and plugin data keys like `__proto__` can no longer pollute object prototypes. +- Limit on connections per IP address (`maxConnectionsPerIp`), stricter WebSocket frame handling, and an + error in a packet handler now kicks that player instead of stopping the server. +- Name verification now works on servers that are not public (the heartbeat still registers the salt). +- `/pinstall` only downloads over HTTPS, caps the download size and validates npm package names. +- New installs no longer make the original authors owners, and servers are not listed publicly until you + turn it on. + +## 1.0.1 + +The original MCScript. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..75d4d8d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing + +Bug reports, fixes and new plugins are welcome. + +## Setting up + +You need Node.js 22.13 or newer and git. + +```bash +git clone https://github.com/perronosaurio/MCScript.git +cd MCScript +npm install +npm test +npm start +``` + +Join with the ClassiCube client at `127.0.0.1:25565` (local connections skip name verification). + +## Before opening a pull request + +- `npm run lint` passes. The project uses [JavaScript Standard Style](https://standardjs.com). +- `npm test` passes, and new behavior comes with a test. Integration tests in `test/server.test.js` start a real + server with simulated clients, see `test/helpers/`. +- If you changed packets or anything the client sees, try it with the real ClassiCube client as well. +- Keep pull requests focused: one fix or feature each. +- Update `README.md`, `docs/` and `CHANGELOG.md` when you change how something is used. + +## Code layout + +- `lib/` is the core: protocol, levels, players, commands and the plugin system. Changes here affect every + plugin, so be careful with the public API described in `docs/PLUGINS.md`. +- `plugins/` holds the bundled plugins. Most new features belong here, not in `lib/`. +- The server has no runtime dependencies and that should stay that way. If you need a library, write a plugin + that users install on their own. + +## Commit messages + +A short summary in the imperative ("Fix /undo for pasted blocks"), then a blank line and a longer explanation +if it helps. + +## License + +By contributing you agree that your contribution is released under the [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c3d3bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2026 perronosaurio (Waxtz) and MCScript contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 28698f8..5f4ac32 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,297 @@ # MCScript -A Minecraft Classic server software in JavaScript + +[![CI](https://github.com/perronosaurio/MCScript/actions/workflows/main.yml/badge.svg)](https://github.com/perronosaurio/MCScript/actions/workflows/main.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +A ClassiCube / Minecraft Classic server written in JavaScript. It speaks the classic protocol plus CPE, runs +several levels at once, supports custom blocks and texture packs, and keeps most features in plugins, much like +[MCGalaxy](https://github.com/ClassiCube/MCGalaxy). + +It has no runtime dependencies, only Node.js. Both the desktop [ClassiCube](https://github.com/ClassiCube/ClassiCube) +client and the browser client work; the browser client connects over WebSocket on the same port. + +- [Requirements](#requirements) +- [Installing](#installing) +- [Your first server](#your-first-server) +- [Letting other people join](#letting-other-people-join) +- [Keeping it running](#keeping-it-running) +- [Updating](#updating) +- [Features](#features) +- [Bundled plugins](#bundled-plugins) +- [Writing plugins](#writing-plugins) +- [Troubleshooting](#troubleshooting) +- [Development](#development) + +## Requirements + +- [Node.js](https://nodejs.org) 22.13 or newer. The current LTS release is the safest choice. +- A ClassiCube account to join, from [classicube.net](https://www.classicube.net). + +## Installing + +With git: + +```bash +git clone https://github.com/perronosaurio/MCScript.git +cd MCScript +npm install +``` + +Without git, download the zip of the latest version from the +[releases page](https://github.com/perronosaurio/MCScript/releases), unpack it and run `npm install` inside the +folder. `npm install` only fetches the linter used for development; the server itself needs nothing else. + +## Your first server + +```bash +npm start +``` + +The first start creates `config/`, `levels/`, `data/` and `logs/` and generates a flat main level. Then: + +1. Stop the server with `stop` (or Ctrl+C). +2. Open `config/server.json`, set `name` and `motd`, and put your ClassiCube name in `owners`: + ```json + "owners": ["YourName"] + ``` +3. Start it again with `npm start`. +4. In ClassiCube pick **Direct connect**, enter your name and `127.0.0.1:25565`, and join. You get the Owner rank. + +The server console accepts commands, such as `/rank Someone Builder` or `/newlvl build 256 64 256 flat`. `stop` +saves everything and shuts down. + +Every setting is described in [docs/CONFIGURATION.md](docs/CONFIGURATION.md). You can also use environment +variables or a `.env` file (copy `.env.example`). + +## Letting other people join + +1. Forward TCP port `25565` on your router to the computer running the server, and allow it through the firewall. +2. Set `"public": true` in `config/server.json` and restart. +3. After a minute the server shows up on the [classicube.net server list](https://www.classicube.net/server/list/). + The console prints its link, which is also saved in `data/externalurl.txt`. + +Keep `verifyNames` on. The server then checks with classicube.net that every player really owns their name, +so nobody can join as you. Players must join through the server list or your server link; typing the IP by hand +is refused. Connections from `127.0.0.1` are always allowed. + +If you would rather keep the server off the public list, leave `public` off and share the link from +`data/externalurl.txt` with your friends. It works the same way. + +## Keeping it running + +On a VPS or a home server, run MCScript as a service so it restarts after crashes and reboots. + +**Linux with systemd.** Create `/etc/systemd/system/mcscript.service`, changing the user and path: + +```ini +[Unit] +Description=MCScript server +After=network-online.target + +[Service] +User=mcscript +WorkingDirectory=/home/mcscript/MCScript +ExecStart=/usr/bin/node index.js +Restart=on-failure +KillSignal=SIGINT + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable --now mcscript +journalctl -u mcscript -f # live log +``` + +`KillSignal=SIGINT` gives the server time to save the levels when the service is stopped. Don't run the +server as root. + +**Any system with pm2:** + +```bash +npm install -g pm2 +pm2 start index.js --name mcscript --kill-timeout 10000 +pm2 save && pm2 startup +``` + +The console isn't available as a service. Use the web panel plugin or join the game to run commands. + +### Backups + +Levels are backed up automatically to `levels/backups/`. `/restore` brings one back in-game. For a complete +backup, copy `config/`, `levels/` and `data/` while the server is stopped, or right after `/save all`. + +## Updating + +```bash +git pull +npm install +``` + +Then restart the server. Without git, download the latest zip from the +[releases page](https://github.com/perronosaurio/MCScript/releases) and unpack it over your server folder. +Your `config/`, `levels/` and `data/` folders are never touched by an update. Read the release notes (or +[CHANGELOG.md](CHANGELOG.md)) first; they list anything you need to change. The server tells you in the +console when a new release is out. + +Coming from MCScript 1.x: install 2.x in a new folder and copy your old `levels/level.dat` into its `levels/` +folder. It is converted to `levels/main.cw` on the first start. Players and ranks from 1.x were not saved, so +there is nothing else to carry over. + +## Features + +Protocol +- Classic 0.30 and 38 CPE extensions, including CustomBlocks, BlockDefinitions/BlockDefinitionsExt, ExtendedBlocks + (block ids up to 767), ExtEntityPositions (levels over 1023 blocks), BulkBlockUpdate, FastMap, EnvColors, + EnvMapAspect, EnvWeatherType, ExtPlayerList, ChangeModel, CustomModels, CustomParticles, CinematicGui, + SelectionCuboid, HackControl, MessageTypes, LongerMessages, FullCP437, TextColors, PlayerClick, VelocityControl, + EntityProperty, PluginMessages, NotifyAction and ToggleBlockList. +- 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). +- 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. + +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. + +Players and moderation: numeric rank permissions, draw and undo limits per rank, temporary and IP bans, mutes, +warnings, temporary ranks, reports, whitelist, moderated chat, staff chats, freeze, vanish, anti-spam and +anti-grief. + +## Bundled plugins + +| 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-blocks | `/gb /lb` | +| warps | `/warp /home` | +| zones | `/zone` (protected areas, shown in the client) | +| portals | `/portal /mb` (portals between levels, message and command blocks) | +| bots | `/bot` (NPCs with skin, model, scale and simple AI) | +| physics | `/physics` (falling sand, flowing water and lava, sponges, TNT) | +| economy | `/money /pay /baltop /eco /give /take /shop /buy` | +| minigames | `/parkour /tntwars /ctf /zombie` | +| effects | `/effect /cinematic /blocklist /explode /slap` | +| custom-models | `/cmodel` (example 3D models plus your own JSON ones, used with `/model`) | +| announcer | `/announcer` | +| relay-irc | IRC chat bridge (off by default) | +| relay-discord | Discord chat bridge, webhook or bot (off by default) | +| web-panel | admin panel in the browser (off by default) | +| example | a commented example plugin | + +Core commands: `/help /plugins /plugin /penable /pdisable /pload /punload /preload /pcreate /pinstall /puninstall /cmdset /blockset /abort /stop /restart`. + +Everything in this table can be turned off: use `/pdisable ` (and `/penable` to bring it back), the +switches in the web panel, or `disabledPlugins` in `config/server.json`. The choice survives restarts. +`relay-irc`, `relay-discord` and `web-panel` also need `"enabled": true` in their file in `config/plugins/` +before they do anything; after editing it, run `/preload `. + +### Minigames + +An operator turns a level into an arena, and players join with `/ join`. + +- Parkour: `/parkour setstart`, `/parkour addcheckpoint`, `/parkour setfinish` (mark the blocks you stand on). + Timer, checkpoints and records with `/parkour top`. +- TNT Wars: `/tntwars enable`, `/tntwars setspawn red|blue`. Right click to drop TNT. The first team to reach + the score limit wins. +- Capture the Flag: `/ctf enable`, `/ctf setflag red|blue`, `/ctf setspawn red|blue`. Bring the other team's + flag home; click an enemy to tag them. +- Zombie Survival: `/zombie enable`. One player starts as a zombie and infects others by touching them. + +The arena is restored after each round, and with the economy plugin the winners get coins. + +### Web panel + +With `web-panel` enabled it listens on `127.0.0.1:8080`. Open `http://127.0.0.1:8080/` and log in with the token +from `config/plugins/web-panel.json` to see players, levels, plugins, the live log and a console. The token +gives full console access. If you open the panel to the internet, put it behind HTTPS. + +## Writing plugins + +```bash +npm run plugin:create MyPlugin # or /pcreate MyPlugin in game +``` + +```js +module.exports = { + name: 'greetings', + version: '1.0.0', + defaultConfig: { message: '&aHi, {player}!' }, + load (ctx) { + ctx.command({ + name: 'hi', + usage: '/hi', + run (player) { player.message(ctx.config.message.replace('{player}', player.name)) } + }) + ctx.on('blockChange', (ev) => { + if (ev.block === 46) ev.cancel('&cNo TNT here') + }) + } +} +``` + +Other people's plugins can be installed with `/pinstall https://github.com/user/repo/blob/main/plugin.js` or +`/pinstall npm:package-name` (install scripts are not run). A plugin runs with the same rights as the server, +so only install code you trust. The full API is in [docs/PLUGINS.md](docs/PLUGINS.md). + +## Troubleshooting + +**"MCScript needs Node.js 22.13 or newer".** Install the current LTS from [nodejs.org](https://nodejs.org). +`node -v` shows the version you have. + +**`EADDRINUSE` on start.** Something else is using port 25565, often another server that's still running. +Stop it or change `port`. + +**"Login failed! Close the game and sign in again."** Name verification failed. Join from the server list or +the server link rather than direct connect. After a restart, wait for the first heartbeat (a few seconds) +before joining. + +**The server isn't on the list.** Check that `public` is `true` and look for heartbeat errors in the console. A +message about the port means it isn't reachable from outside: check the port forwarding and the firewall. + +**Friends on the same network can't join.** Local network addresses are verified like everyone else, so use +the server link. For a LAN-only game you can set `verifyNames` to `false`, but then anyone who can reach the +port can pick any name. + +**A plugin broke.** Its errors are in the console and in `logs/`. `/punload ` turns it off until the +next restart. Add it to `disabledPlugins` to keep it off. + +## Development + +```bash +npm test # unit and integration tests (simulated clients) +npm run lint # JavaScript Standard Style +``` + +``` +index.js entry point +lib/ + server.js server, entities, tab list, custom models and particles + player.js a connected player: login, CPE, level transfer, movement, blocks, chat + protocol/ classic + CPE packets and CP437 + network/ TCP and WebSocket + level/ levels, .cw format, importers, generators + commands/ command manager and core commands + plugins/ plugin loader, installer and template + storage/ player database (JSON or SQLite) +plugins/ bundled plugins +test/ tests +docs/ configuration and plugin API guides +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request, and [SECURITY.md](SECURITY.md) to +report a security problem privately. + +## License + +[MIT](LICENSE). MCScript isn't affiliated with Mojang, Microsoft or the ClassiCube project. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8f1fc7f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security + +## Reporting a problem + +Please don't open a public issue for security problems. Use GitHub's private vulnerability reporting instead +("Report a vulnerability" under the Security tab of this repository), and include: + +- what an attacker can do and what they need (a normal game client, a rank, access to the console...) +- steps or a small script to reproduce it +- the MCScript version or commit and your Node.js version + +You should get an answer within a week. Once a fix is released, you are credited in the changelog unless you +would rather not be. + +## Supported versions + +Only the latest release gets security fixes. MCScript 1.x is no longer maintained. + +## Running a server safely + +- Keep `verifyNames` on for any server reachable from the internet. Without it anyone can log in as any name, + including your owners. +- Keep Node.js up to date. MCScript supports the Node.js releases that are still maintained (22, 24 and newer). +- `/pinstall` runs third-party code with the same rights as the server. Only install plugins you trust. +- The web panel listens on `127.0.0.1` by default. If you expose it, put it behind HTTPS (a reverse proxy + such as Caddy or nginx) and keep the token secret. The token gives full console access. +- Only turn on `trustProxy` when the server sits behind your own reverse proxy. Otherwise players can fake + their IP address and get around IP bans. +- Don't run the server as root or Administrator. diff --git a/client.js b/client.js deleted file mode 100644 index 82ce28c..0000000 --- a/client.js +++ /dev/null @@ -1,36 +0,0 @@ -const { createServer } = require('minecraft-classic-protocol') -const { protocol } = require('minecraft-classic-protocol-extension') -const { join } = require('path') - -const EventEmitter = require('events').EventEmitter -const requireIndex = require('requireindex') - -module.exports.createServer = async (options = {}) => { - options.customPackets = protocol - - const server = new MCServer() - await server.connect(options) - - return server -} - -class MCServer extends EventEmitter { - constructor () { - super() - this._server = null - } - - connect (options) { - const modules = requireIndex(join(__dirname, 'src', 'modules')) - this._server = createServer(options) - - Object.keys(modules) - .filter(moduleName => modules[moduleName].server !== undefined) - .forEach(moduleName => modules[moduleName].server(this, options)) - - this._server.on('error', error => this.emit('error', error)) - this._server.on('clientError', error => this.emit('error', error)) - this._server.on('listening', () => this.emit('listening', this._server.socketServer.address().port)) - this.emit('asap') - } -} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..b04b9b9 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,123 @@ +# Configuration + +Everything lives in `config/`, which is created the first time the server starts. The files are plain JSON: +stop the server (or use the matching in-game command) before editing them by hand, otherwise the server may +write its own copy over your changes on shutdown. + +## config/server.json + +| Key | Default | What it does | +| --- | --- | --- | +| `name` | `MCScript Server` | Server name shown on the server list and the loading screen | +| `motd` | `Welcome! Server made in JavaScript` | Second line of the loading screen. Hack flags such as `-hax` or `+fly` go here too | +| `host` | `0.0.0.0` | Address to listen on. `0.0.0.0` means every network interface | +| `port` | `25565` | TCP port for both the desktop and the web client | +| `maxPlayers` | `20` | Operators and above can still join when the server is full | +| `maxConnectionsPerIp` | `5` | Open connections allowed from one address. `0` turns the limit off | +| `public` | `false` | List the server on classicube.net | +| `verifyNames` | `true` | Check with classicube.net that players are who they say they are | +| `heartbeatUrl` | classicube.net | Where heartbeats are sent | +| `heartbeatInterval` | `45` | Seconds between heartbeats | +| `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 | +| `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 | +| `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 | +| `maxClickDistance` | `5` | Reach in blocks (`/reach` can change it per player) | +| `chatColorsRank` | `Guest` | Lowest rank that can use `%` color codes in chat | +| `disabledPlugins` | `[]` | Plugins that are not loaded at startup | +| `customColors` | `{}` | Extra color codes, for example `{ "q": "#ff8800" }`. Managed with `/ccols` | +| `database` | `json` | `json` or `sqlite`. See below | +| `checkForUpdates` | `true` | Look for a newer MCScript release on GitHub at startup and say so in the console | +| `logToFile` | `true` | Write logs to `logs/` | +| `debug` | `false` | Extra log output | + +### Environment variables + +Environment variables, or a `.env` file next to `package.json`, override `server.json`. That is handy for +containers and hosting panels. + +| Variable | Key | +| --- | --- | +| `PORT` | `port` | +| `HOST` | `host` | +| `SERVER_NAME` | `name` | +| `MOTD` | `motd` | +| `MAX_PLAYERS` | `maxPlayers` | +| `PUBLIC` | `public` (`true` or `1`) | +| `VERIFY_NAMES` or `ONLINE_MODE` | `verifyNames` | +| `MAIN_LEVEL` | `mainLevel` | +| `OWNERS` | `owners` (comma separated) | +| `DATABASE` | `database` | +| `DEBUG` | `debug` | + +## Name verification + +With `verifyNames` on, the server sends a heartbeat to classicube.net even when `public` is `false`. The +heartbeat registers a secret salt; the game client gets a matching key when you click the server on +classicube.net, and the server checks it on login. That stops people from joining under someone else's name. + +Connections from `127.0.0.1` skip the check, so you can always join your own server locally. Players who type +the IP address by hand, without going through classicube.net, are refused. Turn `verifyNames` off only for +private LAN games. With it off, anyone can join as anyone, including as one of the `owners`. + +## Ranks (config/ranks.json) + +Each rank has a numeric `permission`. Commands, blocks and levels ask for a minimum rank, and anyone with that +number or higher passes. + +```json +{ "name": "Builder", "permission": 30, "color": "&2", "prefix": "", "drawLimit": 50000, "maxUndo": 20000 } +``` + +- `drawLimit`: the most blocks one `/cuboid`, `/fill`, `/paste` and so on may change. +- `maxUndo`: how many of their own block changes a player can undo. +- A rank with a negative permission is banned. + +The default ranks are Banned (-20), Guest (0), Builder (30), AdvBuilder (50), Operator (80), Admin (100) and +Owner (120). You can rename them, add more or change the numbers. The `Operator` and `Owner` names are used as +defaults for some commands, so keep ranks with those names or adjust the command ranks with `/cmdset`. + +## Command and block permissions + +- `config/commands.json`: set with `/cmdset `. +- `config/blockperms.json`: set with `/blockset `. Covers placing and deleting. + +## Turning plugins on and off + +Every feature beyond the core commands is a plugin, including the `core-*` ones, so you can switch off anything +you don't want: + +- In game or in the console: `/pdisable ` and `/penable ` (Owner only). `/plugins` shows what + is on, off or broken. +- In the web panel: the Turn off / Turn on links in the Plugins block. +- By hand: list the plugin names in `disabledPlugins` in `server.json` while the server is stopped. + +All three do the same thing: the plugin is unloaded right away and stays off after a restart. Plugins that +depend on another plugin are turned off with it. Commands from a plugin that is off simply don't exist, so +`/help` never shows them. + +## Plugin settings + +Each plugin writes its defaults to `config/plugins/.json` the first time it loads. Edit the file and +run `/preload ` to apply it. Plugin data (warps, balances, records) lives in `data/plugins//`. + +## SQLite + +The default `players.json` is fine for a few thousand players. For more, set `"database": "sqlite"`. It uses +the `node:sqlite` module that ships with Node.js, so there is nothing to install. The existing `players.json` is +imported the first time. `data/players.db` is then the file to back up. + +## Files you should back up + +- `config/`: settings, ranks and permissions +- `levels/` (including `levels/backups/`) +- `data/`: players, custom blocks, plugin data and block history + +`logs/` and `plugins/.removed/` can go. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 0000000..a54e50b --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,178 @@ +# Writing plugins + +A plugin is either a file `plugins/.js` or a folder `plugins//index.js` that exports an object: + +```js +module.exports = { + name: 'my-plugin', // required + version: '1.0.0', + description: 'What it does', + author: 'You', + depends: ['warps'], // plugins that must be loaded first (optional) + defaultConfig: { ... }, // saved to config/plugins/my-plugin.json + load (ctx) { ... }, // required + unload (ctx) { ... } // optional +} +``` + +Every plugin in `plugins/` is loaded at startup, except the ones listed in `disabledPlugins` in +`config/server.json`. In game you have `/plugins`, `/penable ` and `/pdisable ` (on or off for good), +`/pload ` and `/punload ` (until the next restart), `/preload ` (reloads the code and the +config) and `/pcreate ` (creates one from the template). `/plugin ` works +too, like in MCGalaxy. + +Anything you register through `ctx` (commands, event handlers, timers) is removed when the plugin is unloaded, +so reloading a plugin never leaves duplicates behind. + +## The context (`ctx`) + +| Property / method | | +| --- | --- | +| `ctx.server` | the server (see below) | +| `ctx.config` | plugin settings (`defaultConfig` merged with `config/plugins/.json`) | +| `ctx.saveConfig()` | writes `ctx.config` back to disk | +| `ctx.log.info/warn/error/debug(...)` | logging, prefixed with the plugin name | +| `ctx.command(def)` | registers a command (see below) | +| `ctx.on(event, handler, { priority })` | listens to an event; returns a function that stops listening | +| `ctx.setInterval(fn, ms)` / `ctx.setTimeout(fn, ms)` / `ctx.clearTimer(t)` | timers that are cleared on unload | +| `ctx.loadData(file, defaults)` / `ctx.saveData(file, data)` | JSON files in `data/plugins//`. Loaded objects have no prototype, so player-chosen keys such as `constructor` are safe to use | +| `ctx.onUnload(fn)` | extra cleanup on unload | +| `ctx.getPlugin(name)` | another plugin's API (its `module.exports.api`) | +| `ctx.text` | text helpers: `wrap`, `sanitize`, `stripColors`, `parseColor`, `parseDuration`, `formatDuration`... | +| `ctx.Blocks` | block ids and names, `parse`, `coreDefinition`, collide/draw/sound constants | +| `ctx.CommandError` | throw `new ctx.CommandError('message')` in a command to show it in red | + +## Commands + +```js +ctx.command({ + name: 'heal', + aliases: ['h'], + category: 'essentials', // essentials, chat, moderation, building, world, blocks, server, other + rank: 'Builder', // lowest rank that can use it (default: the default rank) + usage: '/heal [player]', + description: 'Shown in /help', + help: ['Extra lines for /help heal'], + inGame: true, // can't be used from the console + async run (player, args, { raw, label, usage }) { + if (!args[0]) return usage() // prints "Usage: ..." + const target = ctx.server.findPlayer(args[0], player) + if (!target) return // findPlayer already told the player + target.message('&aHealed!') + } +}) +``` + +`run` can be `async` (for example to wait for the player to mark blocks). Owners can change the rank of any +command with `/cmdset `. + +## Events + +```js +ctx.on('playerChat', (ev) => { + if (ev.message.includes('badword')) ev.cancel('&cWatch your language') +}, { priority: 'high' }) +``` + +Priorities run in this order: `critical`, `high`, `normal`, `low`, `monitor`. Once an event is cancelled only +`monitor` handlers still run (handy for logging or chat relays; check `ev.cancelled`). + +| Event | Fields | Cancel | +| --- | --- | --- | +| `playerConnecting` | `name, ip, player` | yes, `ev.cancel('reason')` disconnects them | +| `playerJoin` | `player` | | +| `playerLeave` | `player, reason` | | +| `playerSpawn` | `player, level, from` | | +| `playerChat` | `player, message` (can be changed), `format` (optional full line) | yes | +| `playerCommand` | `player, label, command, args, raw` | yes | +| `playerMove` | `player, from, to, yaw, pitch` (positions in 1/32 of a block) | yes, moves them back | +| `playerClick` | `player, button, action, yaw, pitch, targetEntity, target {x,y,z,face}` | | +| `playerChangeLevel` | `player, from, to` | yes | +| `playerRankChange` | `name, from, to, by` | | +| `playerAbort` | `player` (used /abort) | | +| `blockPermission` | `player, level, x, y, z, oldBlock, block, placing, allowed, message` | set `ev.allowed` to allow or deny (runs before `blockChange`) | +| `blockChange` | `player, level, x, y, z, oldBlock, block` (can be changed), `placing` | yes, the block is reverted | +| `drawOperation` | `player, level, changes` (can be filtered), `name` | yes | +| `levelLoad` / `levelSave` | `level` | | +| `levelUnload` | `level` | yes | +| `tabListEntry` | `player, listName, groupName, groupRank` (can be changed) | | +| `heartbeat` | `params` (URLSearchParams) | yes | +| `explosion` | `level, x, y, z, radius` (physics plugin) | | +| `pluginMessage` | `player, channel, data` (64 byte Buffer, CPE PluginMessages) | | +| `notifyAction` | `player, action, value` or `position` (CPE NotifyAction: `blockListSelected`, `levelSaved`, `thirdPersonChanged`...) | | +| `pluginLoad` / `pluginUnload` | `plugin` | | +| `serverStart` / `serverStop` | `server` | | + +## The server (`ctx.server`) + +- `server.online`: connected players. `server.findPlayer(name, askedBy)` and `server.findPlayerExact(name)`. +- `server.broadcast(message, filter?, type?)`. +- `server.levels`: `main`, `get(name)`, `load`, `create(name, { width, height, length, type, seed })`, `save`, + `unload`, `delete`, `copy`, `rename`, `resize`, `backup`, `listFiles()`. +- `server.ranks`: `get(name | number)`, `all`, `default`, `permissionOf(rank)`, `save()`. +- `server.playerDB`: `get(name)`, `getOrCreate`, `find(partial)`, `all()`, `save()`. +- `server.setRank(name, rank, by)`. +- `server.commands.execute(player, '/command args')`. +- `server.blockPerms.canPlace(player, block)` / `canDelete`. +- Custom blocks: `server.setGlobalBlock(def)`, `server.removeGlobalBlock(id)`, `server.setLevelBlock(level, def)`, + `server.removeLevelBlock(level, id)`. +- Entities (NPCs): `server.createEntity({ name, skin, model, level, x, y, z, yaw, pitch, scale })`, + `moveEntity`, `rotateEntity`, `updateEntity`, `removeEntity`, `entitiesIn(level)`. +- 3D models: `server.defineModel({ name, parts, ... })` (the format is described in + `plugins/custom-models/index.js`) and `server.removeModel(name)`. +- Particles: `server.defineParticle(name, { tint, count, size, speed, gravity, lifetime, ... })` and + `server.spawnParticles(level, name, x, y, z)`. +- `server.createConsoleActor(name, onMessage)`: runs commands with console rights and captures what they print. +- `server.log.subscribe(fn)`: receives every log line. +- `server.config` and `server.saveConfig()`. + +## Levels + +- `level.name, width, height, length, spawn, env, motd, buildRank, visitRank`. +- `level.getBlock(x, y, z)`, `level.setBlock(x, y, z, block)` (sent to the players), + `level.setBlocks([[x, y, z, block], ...])` (uses BulkBlockUpdate), `level.getAt(index)` / `level.index(x, y, z)`. + Block ids go from 0 to 767 (ExtendedBlocks); older clients get the fallback block. +- `level.owners` (lowercase names) and `level.canBuild(player)`. +- `level.parseBlock('stone' | '1')`, `level.blockName(id)`, `level.getBlockDef(id)`. +- `level.meta` is free for your own data and is saved inside the `.cw` file (zones, portals and bots use it). +- `level.players`; set `level.dirty = true` to make sure it gets saved. +- After changing `level.env`, call `p.sendEnv()` for every player in the level. + +## Players + +- `player.name, rank, permission, level, record` (saved data), `data` (session only). +- `player.message(text, type?)`. Types: `chat`, `status1-3` (top right), `bottom1-3` (bottom right), + `announce`, `bigAnnounce`, `smallAnnounce` (middle of the screen). +- `player.teleport(x, y, z, yaw?, pitch?)` (in blocks), `teleportTo(other)`, `changeLevel(level)`, `blockPos`, + `feetPos`, `previousPosition`. +- `player.kick(reason)`. +- `await player.selectBlocks(n, label)` asks the player to mark n blocks (throws if they use /abort); + `player.addMark(x, y, z)` adds a mark from code. +- CPE: `supports('ExtName')`, `setModel(model, persist = true)`, `setSkin`, `holdBlock`, `setHotbar`, `setReach`, + `setHacks`, `setVelocity`, `setSpawnpoint`, `showSelection(id, label, p1, p2, [r, g, b, a])`, `hideSelection`, + `setTextHotKey`, `setCinematic({ hideHotbar, hideHand, hideCrosshair, color, barSize })`, `toggleBlockList(open)`, + `sendPluginMessage(channel, data)`, `sendEnv`, `pingMs`. + +## APIs of the bundled plugins + +`ctx.getPlugin(name)` gives you what another plugin exports (add it to `depends` if you always need it): + +| Plugin | API | +| --- | --- | +| `economy` | `balance(name)`, `add(name, n)`, `take(name, n)` (returns `false` if they can't afford it), `setBalance`, `currency` | +| `physics` | `explode(level, x, y, z, radius)`, `schedule(level, x, y, z)`, `modeOf(level)` | +| `zones` | `zonesAt(level, x, y, z)`, `canBuild(player, level, x, y, z)` | +| `core-building` | `apply(player, level, changes, name)` (draws with permission checks and undo), `history` | +| `minigames` | `games.tntwars / ctf / zombie` and their rounds (`rounds`, `roundOf(player)`) | + +## Installing other people's plugins + +`/pinstall ` downloads a single file plugin over HTTPS (GitHub page links are turned into raw links) and +`/pinstall npm:package` installs one from npm into `plugins//`. `/puninstall ` moves it to +`plugins/.removed`. Only Owners can use them. Only install code you trust. + +## Plugins from MCScript 1.x + +Old plugins (`module.exports.server = (server, settings) => {}`) don't work anymore and are skipped with a +warning. To port one, move the body of `server(...)` into `load(ctx)` and replace the per player handlers with +events (`playerJoin`, `playerChat`...). diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..82d761a --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,39 @@ +# Releasing a new version + +Every version is published as a GitHub release with a zip and a tar.gz of the server, the way MCGalaxy does +it. The release workflow (`.github/workflows/release.yml`) does the work once a version tag is pushed. + +## Version numbers + +MCScript uses [semantic versioning](https://semver.org): + +- **patch** (2.0.1): bug fixes only. +- **minor** (2.1.0): new commands, plugins or settings that don't break anything. +- **major** (3.0.0): changes that break plugins or need server owners to change their config. + +Pre-releases use a suffix, for example `2.1.0-beta.1`. They are marked as pre-releases on GitHub and the +update check doesn't announce them to servers on a final release. + +## Steps + +1. Make sure CI is green on `master`. +2. In `CHANGELOG.md`, rename the `## Unreleased` section to the new version and date, for example + `## 2.1.0 (2026-11-02)`, and start a new empty `## Unreleased` section above it. +3. Update the version in `package.json` and `package-lock.json`: + ```bash + npm version 2.1.0 --no-git-tag-version + ``` +4. Commit both files with the message `Release 2.1.0` and push to `master`. +5. Tag the commit and push the tag: + ```bash + git tag v2.1.0 + git push origin v2.1.0 + ``` + +The workflow then runs the lint and tests, checks that the tag matches `package.json`, builds +`MCScript-v2.1.0.zip` and `.tar.gz` (without the tests and GitHub files) and creates the release with the +changelog section as its notes. If something fails, fix it, delete the tag (`git push --delete origin v2.1.0`, +`git tag -d v2.1.0`) and tag again. + +Servers running an older version print a message in the console with a link to the new release when they start +(unless `checkForUpdates` is off). diff --git a/index.js b/index.js index 25de30f..3c6fa8a 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,25 @@ -const { createServer } = require('./client.js') +'use strict' -createServer({ - 'port': process.env.PORT, - 'name': 'Waxtz\'s freebuild [Made using JavaScript]', - 'motd': 'Server made in JavaScript!', - 'max-players': 20, - 'public': true, - 'online-mode': true, - 'disable-op-command': false, - 'ops': [ 'Waxtz', 'VenkSociety' ], - 'plugins': { test: {} } +const { createServer } = require('./lib') + +// Values used only the first time, to create config/server.json. +// After that, edit config/server.json (or use environment variables / .env). +const initial = { + name: 'MCScript Server', + motd: 'Welcome! Server made in JavaScript', + maxPlayers: 20, + public: false, + verifyNames: true, + owners: [] +} + +createServer({ initial, console: true }).then(server => { + const shutdown = () => server.stop().then(() => process.exit(0)) + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + // a forgotten await in a plugin should be logged, not crash the server + process.on('unhandledRejection', err => server.log.error('Unhandled promise rejection:', err)) +}).catch(err => { + console.error('Failed to start the server:', err) + process.exit(1) }) diff --git a/levels/level.dat b/levels/level.dat deleted file mode 100644 index a8b4c15..0000000 Binary files a/levels/level.dat and /dev/null differ diff --git a/lib/block-permissions.js b/lib/block-permissions.js new file mode 100644 index 0000000..7e6f3d9 --- /dev/null +++ b/lib/block-permissions.js @@ -0,0 +1,49 @@ +'use strict' + +const fs = require('fs') + +// Which rank may place / delete each block. Stored in config/blockperms.json as +// { "7": { "place": "Operator", "delete": "Operator" }, ... }. Missing blocks use the default rank. +const DEFAULT_PERMS = { + 7: { place: 'Operator', delete: 'Operator' }, // bedrock + 8: { place: 'AdvBuilder' }, // water + 10: { place: 'AdvBuilder' }, // lava + 9: { place: 'Builder' }, // still water + 11: { place: 'Builder' }, // still lava + 46: { place: 'Builder' } // TNT +} + +class BlockPermissions { + constructor (server, file) { + this.server = server + this.file = file + if (file && fs.existsSync(file)) { + this.perms = JSON.parse(fs.readFileSync(file, 'utf8')) + } else { + this.perms = JSON.parse(JSON.stringify(DEFAULT_PERMS)) + if (file) this.save() + } + } + + save () { + if (this.file) fs.writeFileSync(this.file, JSON.stringify(this.perms, null, 2)) + } + + required (block, action) { + const entry = this.perms[block] + return this.server.ranks.permissionOf(entry && entry[action] ? entry[action] : null) + } + + canPlace (player, block) { return player.permission >= this.required(block, 'place') } + + canDelete (player, block) { return player.permission >= this.required(block, 'delete') } + + set (block, action, rank) { + this.perms[block] = this.perms[block] || {} + this.perms[block][action] = rank + this.save() + for (const p of this.server.players) if (p.spawned) p.sendBlockPermissions() + } +} + +module.exports = { BlockPermissions, DEFAULT_PERMS } diff --git a/lib/blocks.js b/lib/blocks.js new file mode 100644 index 0000000..200273c --- /dev/null +++ b/lib/blocks.js @@ -0,0 +1,256 @@ +'use strict' + +// Block ids, names, CPE fallbacks and custom block definitions (BlockDefinitions CPE extension). + +const NAMES = [ + 'Air', 'Stone', 'Grass', 'Dirt', 'Cobblestone', 'Wood', 'Sapling', 'Bedrock', 'Water', 'Still water', + 'Lava', 'Still lava', 'Sand', 'Gravel', 'Gold ore', 'Iron ore', 'Coal ore', 'Log', 'Leaves', 'Sponge', + 'Glass', 'Red', 'Orange', 'Yellow', 'Lime', 'Green', 'Teal', 'Aqua', 'Cyan', 'Blue', + 'Indigo', 'Violet', 'Magenta', 'Pink', 'Black', 'Gray', 'White', 'Dandelion', 'Rose', 'Brown mushroom', + 'Red mushroom', 'Gold', 'Iron', 'Double slab', 'Slab', 'Brick', 'TNT', 'Bookshelf', 'Mossy rocks', 'Obsidian', + // CustomBlocks (CPE) + 'Cobblestone slab', 'Rope', 'Sandstone', 'Snow', 'Fire', 'Light pink', 'Forest green', 'Brown', 'Deep blue', 'Turquoise', + 'Ice', 'Ceramic tile', 'Magma', 'Pillar', 'Crate', 'Stone brick' +] + +const MAX_CLASSIC = 49 +const MAX_CPE = 65 +const MAX_BLOCK = 767 // 0x2FF, the highest id with ExtendedBlocks + +// CustomBlocks -> classic replacement for clients without the extension +const CPE_FALLBACK = { + 50: 44, 51: 39, 52: 12, 53: 0, 54: 10, 55: 33, 56: 25, 57: 3, 58: 29, 59: 28, 60: 20, 61: 42, 62: 49, 63: 36, 64: 5, 65: 1 +} + +// Default textures [top, side, bottom] in the terrain atlas, used when copying a core block +const TEXTURES = { + 1: [1, 1, 1], + 2: [0, 3, 2], + 3: [2, 2, 2], + 4: [16, 16, 16], + 5: [4, 4, 4], + 6: [15, 15, 15], + 7: [17, 17, 17], + 8: [14, 14, 14], + 9: [14, 14, 14], + 10: [30, 30, 30], + 11: [30, 30, 30], + 12: [18, 18, 18], + 13: [19, 19, 19], + 14: [32, 32, 32], + 15: [33, 33, 33], + 16: [34, 34, 34], + 17: [21, 20, 21], + 18: [22, 22, 22], + 19: [48, 48, 48], + 20: [49, 49, 49], + 37: [13, 13, 13], + 38: [12, 12, 12], + 39: [29, 29, 29], + 40: [28, 28, 28], + 41: [24, 40, 56], + 42: [23, 39, 55], + 43: [6, 5, 6], + 44: [6, 5, 6], + 45: [7, 7, 7], + 46: [9, 8, 10], + 47: [4, 35, 4], + 48: [36, 36, 36], + 49: [37, 37, 37], + 50: [16, 16, 16], + 51: [11, 11, 11], + 52: [25, 41, 57], + 53: [50, 50, 50], + 54: [38, 38, 38], + 55: [80, 80, 80], + 56: [81, 81, 81], + 57: [82, 82, 82], + 58: [83, 83, 83], + 59: [84, 84, 84], + 60: [51, 51, 51], + 61: [54, 54, 54], + 62: [86, 86, 86], + 63: [26, 42, 58], + 64: [53, 53, 53], + 65: [52, 52, 52] +} +for (let id = 21; id <= 36; id++) TEXTURES[id] = [id + 43, id + 43, id + 43] + +const COLLIDE = { walkthrough: 0, swim: 1, solid: 2, ice: 3, slipperyice: 4, water: 5, lava: 6, climb: 7, rope: 7 } +const DRAW = { opaque: 0, transparent: 1, transparentinside: 2, translucent: 3, gas: 4 } +const SOUNDS = { none: 0, wood: 1, gravel: 2, grass: 3, stone: 4, metal: 5, glass: 6, cloth: 7, sand: 8, snow: 9 } + +function coreDefinition (id) { + const tex = TEXTURES[id] || [1, 1, 1] + const def = { + id, + name: NAMES[id] || `Block ${id}`, + collide: COLLIDE.solid, + speed: 1, + textures: { top: tex[0], bottom: tex[2], left: tex[1], right: tex[1], front: tex[1], back: tex[1] }, + transmitsLight: false, + sound: SOUNDS.stone, + fullBright: false, + draw: DRAW.opaque, + sprite: false, + min: [0, 0, 0], + max: [16, 16, 16], + fogDensity: 0, + fogColor: [0, 0, 0], + fallback: id <= MAX_CPE ? (CPE_FALLBACK[id] ?? id) : 1 + } + if ([6, 37, 38, 39, 40, 51, 54].includes(id)) { def.sprite = true; def.collide = COLLIDE.walkthrough; def.draw = DRAW.transparent; def.transmitsLight = true; def.sound = SOUNDS.grass } + if ([8, 9].includes(id)) { def.collide = COLLIDE.water; def.draw = DRAW.translucent; def.max[1] = 16; def.fogDensity = 11; def.fogColor = [5, 5, 51] } + if ([10, 11].includes(id)) { def.collide = COLLIDE.lava; def.fullBright = true; def.fogDensity = 229; def.fogColor = [153, 25, 0] } + if ([18].includes(id)) { def.draw = DRAW.transparentinside; def.sound = SOUNDS.grass } + if ([20].includes(id)) { def.draw = DRAW.transparent; def.sound = SOUNDS.glass } + if ([44, 50].includes(id)) def.max = [16, 8, 16] + if (id === 53) { def.max = [16, 2, 16]; def.collide = COLLIDE.walkthrough; def.sound = SOUNDS.snow } + if (id === 60) { def.draw = DRAW.translucent; def.collide = COLLIDE.ice; def.sound = SOUNDS.glass } + if (id === 51) def.collide = COLLIDE.climb + if (id >= 21 && id <= 36) def.sound = SOUNDS.cloth + if ([2, 18].includes(id)) def.sound = SOUNDS.grass + if ([12, 13].includes(id)) def.sound = SOUNDS.sand + if ([5, 17, 47, 64].includes(id)) def.sound = SOUNDS.wood + if ([41, 42].includes(id)) def.sound = SOUNDS.metal + if (id === 62) def.fullBright = true + return def +} + +// Fill in any missing fields of a (possibly user edited) definition +function normalize (def) { + const base = coreDefinition(1) + const out = { ...base, ...def } + out.textures = { ...base.textures, ...(def.textures || {}) } + out.min = (def.min || base.min).map(v => clampInt(v, 0, 16)) + out.max = (def.max || base.max).map(v => clampInt(v, 0, 16)) + out.fogColor = (def.fogColor || base.fogColor).map(v => clampInt(v, 0, 255)) + out.id = clampInt(def.id, 1, MAX_BLOCK) + out.fallback = clampInt(def.fallback ?? 1, 0, MAX_CPE) + return out +} + +function speedToByte (speed) { + const v = Math.round(128 + 64 * Math.log2(Math.max(0.25, Math.min(3.96, speed || 1)))) + return clampInt(v, 0, 255) +} + +function fogToByte (density) { + return clampInt(density, 0, 255) +} + +// Build the packet (name + data) used to send a definition to a client +function definitionPacket (def, useExt) { + def = normalize(def) + const common = { + block: def.id, + blockName: def.name, + collide: def.collide, + speed: speedToByte(def.speed), + topTex: def.textures.top, + bottomTex: def.textures.bottom, + transmitsLight: def.transmitsLight ? 1 : 0, + walkSound: def.sound, + fullBright: def.fullBright ? 1 : 0, + draw: def.draw, + fogDensity: fogToByte(def.fogDensity), + fogR: def.fogColor[0], + fogG: def.fogColor[1], + fogB: def.fogColor[2] + } + if (def.sprite || !useExt) { + return ['defineBlock', { + ...common, + sideTex: def.textures.left, + shape: def.sprite ? 0 : Math.max(1, def.max[1]), + // for sprites the draw field is the sprite offset + draw: def.sprite ? 0 : def.draw + }] + } + return ['defineBlockExt', { + ...common, + leftTex: def.textures.left, + rightTex: def.textures.right, + frontTex: def.textures.front, + backTex: def.textures.back, + minX: def.min[0], + minY: def.min[1], + minZ: def.min[2], + maxX: def.max[0], + maxY: def.max[1], + maxZ: def.max[2] + }] +} + +function clampInt (v, min, max) { + v = Math.round(Number(v) || 0) + return v < min ? min : v > max ? max : v +} + +const nameIndex = new Map(NAMES.map((n, i) => [n.toLowerCase().replace(/[\s_-]/g, ''), i])) +// common aliases +Object.entries({ + planks: 5, + wood: 5, + activewater: 8, + stillwater: 9, + activelava: 10, + stilllava: 11, + wool: 36, + log: 17, + trunk: 17, + slab: 44, + stair: 44, + stairs: 44, + doublestair: 43, + mossy: 48, + mossycobblestone: 48, + cobble: 4, + brickblock: 45, + yellowflower: 37, + redflower: 38, + flower: 37, + shroom: 39, + goldblock: 41, + ironblock: 42 +}).forEach(([k, v]) => { if (!nameIndex.has(k)) nameIndex.set(k, v) }) + +function coreName (id) { + return NAMES[id] || null +} + +// Parse a block by name or id. `customDefs` is a lookup function id -> def (level + global). +function parse (input, customDefs) { + if (input === undefined || input === null || input === '') return null + const str = String(input).toLowerCase().replace(/[\s_-]/g, '') + if (/^\d+$/.test(str)) { + const id = Number(str) + if (id > MAX_BLOCK) return null + if (id <= MAX_CPE) return id + return customDefs && customDefs(id) ? id : null + } + if (customDefs) { + for (let id = 1; id <= MAX_BLOCK; id++) { + const def = customDefs(id) + if (def && def.name.toLowerCase().replace(/[\s_-]/g, '') === str) return id + } + } + return nameIndex.has(str) ? nameIndex.get(str) : null +} + +module.exports = { + NAMES, + MAX_CLASSIC, + MAX_CPE, + MAX_BLOCK, + CPE_FALLBACK, + COLLIDE, + DRAW, + SOUNDS, + coreDefinition, + coreName, + normalize, + definitionPacket, + speedToByte, + parse +} diff --git a/lib/commands/core.js b/lib/commands/core.js new file mode 100644 index 0000000..ed392a9 --- /dev/null +++ b/lib/commands/core.js @@ -0,0 +1,300 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const pluginTemplate = require('../plugins/template') +const installer = require('../plugins/installer') +const { CommandError } = require('./manager') + +const CATEGORIES = { + essentials: 'Basic commands', + chat: 'Chat and messages', + moderation: 'Moderation', + building: 'Building and drawing', + world: 'Levels and environment', + blocks: 'Custom blocks', + server: 'Server management', + other: 'Other' +} + +// Commands that belong to the server itself (they can't be unloaded) +module.exports = function registerCoreCommands (server) { + const { commands, plugins, ranks } = server + + commands.register({ + name: 'help', + aliases: ['commands', 'cmds', 'cmdlist'], + category: 'essentials', + usage: '/help [command|category|all]', + description: 'Shows the commands you can use', + run (player, args) { + const query = (args[0] || '').toLowerCase() + const available = commands.available(player) + + if (!query) { + player.message('&eCommand categories:') + for (const [key, desc] of Object.entries(CATEGORIES)) { + const n = available.filter(c => c.category === key).length + if (n) player.message(` &f/help ${key} &7- ${desc} (${n})`) + } + player.message('&eUse &f/help &e for details, or &f/help all&e for everything.') + return + } + + if (query === 'all' || CATEGORIES[query]) { + const list = query === 'all' ? available : available.filter(c => c.category === query) + if (!list.length) return player.message('&cNo commands available in that category.') + player.message(`&e${query === 'all' ? 'All commands' : CATEGORIES[query]}:`) + player.message('&f' + list.map(c => { + const rank = ranks.get(commands.permissionFor(c)) + return (rank ? rank.color : '&f') + c.name + }).join('&7, ')) + return + } + + const cmd = commands.find(query) + if (!cmd) return player.message(`&cNo command named "${query}".`) + const rank = ranks.get(commands.permissionFor(cmd)) + player.message(`&e${cmd.usage}`) + if (cmd.description) player.message(`&7${cmd.description}`) + if (cmd.help) for (const line of [].concat(cmd.help)) player.message(`&7${line}`) + const extra = [] + if (cmd.aliases.length) extra.push(`aliases: /${cmd.aliases.join(', /')}`) + if (rank) extra.push(`rank: ${rank.color}${rank.name}&7+`) + if (cmd.owner) extra.push(`plugin: ${cmd.owner}`) + if (extra.length) player.message(`&7(${extra.join(' &8| &7')})`) + } + }) + + commands.register({ + name: 'plugins', + aliases: ['pl', 'plist'], + category: 'server', + usage: '/plugins', + description: 'Lists loaded plugins', + run (player) { + const loaded = plugins.list() + const all = [...plugins.discover().keys()] + player.message(`&ePlugins (${loaded.length}/${all.length} loaded):`) + player.message(all.sort().map(name => { + const p = plugins.get(name) + if (p) return `&a${p.name}${p.module.version ? '&7 v' + p.module.version : ''}` + return plugins.isDisabled(name) ? `&8${name} (off)` : `&c${name}` + }).join('&7, ')) + player.message('&7Green: loaded, red: failed or unloaded, grey: turned off with /pdisable.') + } + }) + + commands.register({ + name: 'pload', + category: 'server', + rank: 'Owner', + usage: '/pload ', + description: 'Loads a plugin from the plugins folder', + run (player, args, { usage }) { + if (!args[0]) return usage() + const p = plugins.load(args[0]) + player.message(`&aLoaded plugin ${p.name}.`) + } + }) + + commands.register({ + name: 'punload', + category: 'server', + rank: 'Owner', + usage: '/punload ', + description: 'Unloads a plugin', + run (player, args, { usage }) { + if (!args[0]) return usage() + plugins.unload(args[0]) + player.message(`&aUnloaded plugin ${args[0]}.`) + } + }) + + commands.register({ + name: 'preload', + category: 'server', + rank: 'Owner', + usage: '/preload ', + description: 'Reloads a plugin (applies code and config changes)', + run (player, args, { usage }) { + if (!args[0]) return usage() + const p = plugins.reload(args[0]) + player.message(`&aReloaded plugin ${p.name}.`) + } + }) + + commands.register({ + name: 'pcreate', + category: 'server', + rank: 'Owner', + usage: '/pcreate ', + description: 'Creates a new plugin from a template in plugins//index.js', + run (player, args, { usage }) { + const name = args[0] + if (!name) return usage() + if (!/^[A-Za-z][A-Za-z0-9_-]{1,31}$/.test(name)) return player.message('&cInvalid plugin name.') + const dir = path.join(plugins.dir, name) + if (fs.existsSync(dir) || fs.existsSync(dir + '.js')) return player.message('&cA plugin with that name already exists.') + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'index.js'), pluginTemplate(name, player.isConsole ? 'console' : player.name)) + player.message(`&aCreated plugins/${name}/index.js. Edit it, then use &f/pload ${name}&a.`) + } + }) + + commands.register({ + name: 'pinstall', + category: 'server', + rank: 'Owner', + usage: '/pinstall [noload]', + description: 'Installs a plugin from a URL (GitHub links work) or from npm, and loads it', + help: ['npm packages are installed without running their install scripts.'], + async run (player, args, { usage }) { + const source = args[0] + if (!source) return usage() + player.message('&eInstalling...') + const name = source.startsWith('npm:') + ? await installer.installFromNpm(plugins.dir, source.slice(4)) + : await installer.installFromUrl(plugins.dir, source) + server.log.info(`${player.name} installed plugin ${name} from ${source}`) + if ((args[1] || '').toLowerCase() === 'noload') return player.message(`&aInstalled ${name}. Use &f/pload ${name}&a to start it.`) + const p = plugins.load(name) + player.message(`&aInstalled and loaded ${p.name}${p.module.version ? ' v' + p.module.version : ''}.`) + } + }) + + commands.register({ + name: 'puninstall', + category: 'server', + rank: 'Owner', + usage: '/puninstall ', + description: 'Unloads a plugin and moves it to plugins/.removed', + run (player, args, { usage }) { + if (!args[0]) return usage() + const loaded = plugins.get(args[0]) + if (loaded) plugins.unload(loaded.name) + installer.uninstall(plugins.dir, loaded ? path.basename(loaded.file) === 'index.js' ? path.basename(path.dirname(loaded.file)) : path.basename(loaded.file, '.js') : args[0]) + player.message(`&aRemoved ${args[0]} (a copy was kept in plugins/.removed).`) + } + }) + + commands.register({ + name: 'cmdset', + category: 'server', + rank: 'Owner', + usage: '/cmdset ', + description: 'Changes the minimum rank needed to use a command', + run (player, args, { usage }) { + const cmd = commands.find(args[0]) + const rank = ranks.get(args[1]) + if (!cmd || !rank) return usage() + commands.setOverride(cmd.name, rank.name) + player.message(`&a/${cmd.name} can now be used by ${rank.color}${rank.name}&a+.`) + } + }) + + commands.register({ + name: 'blockset', + category: 'server', + rank: 'Owner', + usage: '/blockset ', + description: 'Changes the minimum rank needed to place or delete a block', + run (player, args, { usage }) { + const level = player.level + const block = level.parseBlock(args[0]) + const action = (args[1] || '').toLowerCase() + const rank = ranks.get(args[2]) + if (block === null || !['place', 'delete'].includes(action) || !rank) return usage() + server.blockPerms.set(block, action, rank.name) + player.message(`&a${rank.color}${rank.name}&a+ can now ${action} ${level.blockName(block)}.`) + } + }) + + commands.register({ + name: 'abort', + aliases: ['a'], + category: 'building', + usage: '/abort', + description: 'Cancels the current block selection or toggle mode', + inGame: true, + run (player) { + const cancelled = player.cancelSelection() + server.events.fire('playerAbort', { player }) + player.message(cancelled ? '&eSelection cancelled.' : '&eAll modes reset.') + } + }) + + // MCGalaxy style: /plugin [name] + commands.register({ + name: 'pdisable', + category: 'server', + rank: 'Owner', + usage: '/pdisable ', + description: 'Turns a plugin off and keeps it off after restarts', + run (player, args, { usage }) { + if (!args[0]) return usage() + try { plugins.disable(args[0]) } catch (err) { throw new CommandError(err.message) } + player.message(`&ePlugin ${args[0]} turned off. &f/penable ${args[0]}&e turns it back on.`) + } + }) + + commands.register({ + name: 'penable', + category: 'server', + rank: 'Owner', + usage: '/penable ', + description: 'Turns a plugin back on', + run (player, args, { usage }) { + if (!args[0]) return usage() + let p + try { p = plugins.enable(args[0]) } catch (err) { throw new CommandError(err.message) } + player.message(`&aPlugin ${p.name} turned on.`) + } + }) + + const PLUGIN_SUBS = { list: 'plugins', load: 'pload', unload: 'punload', reload: 'preload', enable: 'penable', disable: 'pdisable', create: 'pcreate', install: 'pinstall', uninstall: 'puninstall' } + commands.register({ + name: 'plugin', + category: 'server', + usage: `/plugin <${Object.keys(PLUGIN_SUBS).join('|')}> [name]`, + description: 'Manages plugins (same as /plugins, /pload, /punload...)', + async run (player, args, { usage }) { + const target = PLUGIN_SUBS[(args[0] || 'list').toLowerCase()] + if (!target) return usage() + await commands.execute(player, `/${target} ${args.slice(1).join(' ')}`) + } + }) + + commands.register({ + name: 'restart', + category: 'server', + rank: 'Owner', + usage: '/restart [reason]', + description: 'Saves everything and starts the server again', + async run (player, args) { + const reason = args.length ? args.join(' ') : 'Server is restarting' + server.broadcast(`&e${reason}...`) + await server.stop(reason) + if (server.config.exitOnStop === false) return + const { spawn } = require('child_process') + spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit', cwd: process.cwd(), detached: true }).unref() + process.exit(0) + } + }) + + commands.register({ + name: 'stop', + aliases: ['shutdown'], + category: 'server', + rank: 'Owner', + usage: '/stop [reason]', + description: 'Saves everything and stops the server', + async run (player, args) { + const reason = args.length ? args.join(' ') : 'Server is shutting down' + await server.stop(reason) + if (server.config.exitOnStop !== false) process.exit(0) + } + }) +} + +module.exports.CATEGORIES = CATEGORIES diff --git a/lib/commands/manager.js b/lib/commands/manager.js new file mode 100644 index 0000000..65abddf --- /dev/null +++ b/lib/commands/manager.js @@ -0,0 +1,120 @@ +'use strict' + +const fs = require('fs') + +class CommandError extends Error {} + +// Registry of commands. Plugins register commands through their context (ctx.command(...)), +// which removes them automatically when the plugin is unloaded. +class CommandManager { + constructor (server, overridesFile) { + this.server = server + this.commands = new Map() // name -> def + this.aliases = new Map() // alias -> name + this.overridesFile = overridesFile + this.overrides = {} + if (overridesFile && fs.existsSync(overridesFile)) { + try { this.overrides = JSON.parse(fs.readFileSync(overridesFile, 'utf8')) } catch (err) {} + } + } + + register (def) { + if (!def || !def.name || typeof def.run !== 'function') throw new Error('Command needs a name and a run(player, args) function') + const name = def.name.toLowerCase() + if (this.commands.has(name)) throw new Error(`Command /${name} is already registered (by ${this.commands.get(name).owner || 'core'})`) + const cmd = { + aliases: [], + rank: null, + usage: `/${name}`, + description: '', + category: 'other', + inGame: false, + ...def, + 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) + } + return () => this.unregister(name) + } + + unregister (name) { + name = name.toLowerCase() + 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) + return true + } + + removeOwner (owner) { + for (const cmd of [...this.commands.values()]) if (cmd.owner === owner) this.unregister(cmd.name) + } + + find (name) { + if (!name) return null + name = name.toLowerCase() + return this.commands.get(name) || this.commands.get(this.aliases.get(name)) || null + } + + // minimum permission number required to use a command + permissionFor (cmd) { + const override = this.overrides[cmd.name] + return this.server.ranks.permissionOf(override !== undefined ? override : cmd.rank) + } + + canUse (player, cmd) { + return player.permission >= this.permissionFor(cmd) + } + + setOverride (name, rank) { + this.overrides[name] = rank + if (this.overridesFile) fs.writeFileSync(this.overridesFile, JSON.stringify(this.overrides, null, 2)) + } + + available (player) { + return [...this.commands.values()].filter(c => this.canUse(player, c)).sort((a, b) => a.name.localeCompare(b.name)) + } + + // Executes "/name args..." (the leading slash is optional) + async execute (player, line) { + line = String(line).trim().replace(/^\//, '') + 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 args = raw.length ? raw.split(/\s+/) : [] + + const cmd = this.find(label) + const ev = this.server.events.fire('playerCommand', { player, label, command: cmd, args, raw }) + if (ev.cancelled) { + if (ev.cancelReason) player.message(ev.cancelReason) + return + } + if (!cmd) return player.message(`&cUnknown command "/${label}". Type &f/help&c for a list of commands.`) + if (!this.canUse(player, cmd)) { + const rank = this.server.ranks.get(this.permissionFor(cmd)) + return player.message(`&cOnly ${rank ? rank.color + rank.name : 'higher ranks'}&c+ can use /${cmd.name}.`) + } + if (cmd.inGame && player.isConsole) return player.message(`&c/${cmd.name} can only be used in-game.`) + + if (!player.isConsole) { + this.server.log.info(`${player.name} used /${label}${raw ? ' ' + raw : ''}`) + player.data.lastCommand = { line: `/${label}${raw ? ' ' + raw : ''}`, at: Date.now() } + } + try { + await cmd.run(player, args, { raw, label, cmd, usage: () => player.message(`&cUsage: &f${cmd.usage}`) }) + } catch (err) { + if (err instanceof CommandError) { + player.message('&c' + err.message) + } else { + player.message(`&cAn error occurred while running /${cmd.name}: ${err.message}`) + this.server.log.error(`Error in /${cmd.name}:`, err) + } + } + } +} + +module.exports = { CommandManager, CommandError } diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..f6be627 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,98 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +const DEFAULTS = { + name: 'MCScript Server', + motd: 'Welcome! Server made in JavaScript', + host: '0.0.0.0', + port: 25565, + maxPlayers: 20, + maxConnectionsPerIp: 5, + public: false, + verifyNames: true, + heartbeatUrl: 'https://www.classicube.net/server/heartbeat/', + heartbeatInterval: 45, + allowWebClient: true, + trustProxy: false, + mainLevel: 'main', + defaultRank: 'Guest', + owners: [], + welcomeMessage: '&eWelcome to the server, &f{player}&e! Type &f/help&e for commands.', + rules: [ + '&f1. &7Be respectful to other players.', + '&f2. &7No griefing other people\'s builds.', + '&f3. &7No spamming or advertising.' + ], + defaultTexture: '', + autosaveMinutes: 5, + backupMinutes: 30, + backupsToKeep: 10, + maxClickDistance: 5, + chatColorsRank: 'Guest', + disabledPlugins: [], + customColors: {}, + database: 'json', // 'json' or 'sqlite' + checkForUpdates: true, + logToFile: true, + debug: false +} + +// Environment variables that map onto config keys +const ENV_MAP = { + PORT: ['port', Number], + HOST: ['host', String], + SERVER_NAME: ['name', String], + MOTD: ['motd', String], + MAX_PLAYERS: ['maxPlayers', Number], + PUBLIC: ['public', v => v === 'true' || v === '1'], + ONLINE_MODE: ['verifyNames', v => v === 'true' || v === '1'], + VERIFY_NAMES: ['verifyNames', v => v === 'true' || v === '1'], + MAIN_LEVEL: ['mainLevel', String], + OWNERS: ['owners', v => v.split(',').map(s => s.trim()).filter(Boolean)], + DATABASE: ['database', String], + DEBUG: ['debug', v => v === 'true' || v === '1'] +} + +// Tiny .env loader (KEY=value per line), existing variables win +function loadDotEnv (file) { + if (!fs.existsSync(file)) return + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/) + if (!m) continue + let value = m[2] + if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1) + if (process.env[m[1]] === undefined) process.env[m[1]] = value + } +} + +// `initial` is only used to create config/server.json the first time, `overrides` always wins +function loadConfig ({ root = process.cwd(), initial = {}, overrides = {}, env = process.env, write = true } = {}) { + const dir = path.join(root, 'config') + const file = path.join(dir, 'server.json') + let fileConfig = {} + if (fs.existsSync(file)) { + fileConfig = JSON.parse(fs.readFileSync(file, 'utf8')) + } else if (write) { + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(file, JSON.stringify({ ...DEFAULTS, ...initial }, null, 2)) + } + const config = { ...DEFAULTS, ...fileConfig, ...overrides } + for (const [key, [prop, parse]] of Object.entries(ENV_MAP)) { + if (env[key] !== undefined && env[key] !== '') config[prop] = parse(env[key]) + } + config.root = root + config.file = file + return config +} + +// Writes the known keys back to config/server.json, keeping any extra keys the file has +function saveConfig (config) { + let out = {} + if (fs.existsSync(config.file)) out = JSON.parse(fs.readFileSync(config.file, 'utf8')) + for (const key of Object.keys(DEFAULTS)) out[key] = config[key] + fs.writeFileSync(config.file, JSON.stringify(out, null, 2)) +} + +module.exports = { DEFAULTS, loadConfig, saveConfig, loadDotEnv } diff --git a/lib/cpe-extras.js b/lib/cpe-extras.js new file mode 100644 index 0000000..ad3a160 --- /dev/null +++ b/lib/cpe-extras.js @@ -0,0 +1,134 @@ +'use strict' + +// Helpers for CustomModels (v2) and CustomParticles. +// Model definitions use "pixels" (1/16 of a block) like Blockbench; they are converted to blocks here. + +const FACES = ['top', 'bottom', 'front', 'back', 'left', 'right'] +const ANIMS = { + none: 0, + head: 1, + leftLegX: 2, + rightLegX: 3, + leftArmX: 4, + leftArmZ: 5, + rightArmX: 6, + rightArmZ: 7, + spin: 8, + spinVelocity: 9, + sinRotate: 10, + sinRotateVelocity: 11, + sinTranslate: 12, + sinTranslateVelocity: 13, + sinSize: 14, + sinSizeVelocity: 15, + flipRotate: 16, + flipRotateVelocity: 17, + flipTranslate: 18, + flipTranslateVelocity: 19, + flipSize: 20, + flipSizeVelocity: 21 +} +const AXES = { x: 0, y: 1, z: 2 } +const px = v => (Number(v) || 0) / 16 + +// Returns the packets that define a model on a client +function modelPackets (id, model) { + const f = model.flags || {} + const flags = (f.bobbing !== false ? 1 : 0) | (f.pushes !== false ? 2 : 0) | (f.usesHumanSkin !== false ? 4 : 0) | (f.calcHumanAnims ? 8 : 0) + const [cx, cy, cz] = model.collision || [8, 28, 8] + const pickMin = model.pickMin || [-4, 0, -4] + const pickMax = model.pickMax || [4, 32, 4] + const parts = model.parts || [] + const packets = [['defineModel', { + modelId: id, + modelName: model.name, + flags, + nameY: px(model.nameY ?? 32.5), + eyeY: px(model.eyeY ?? 26), + collisionX: px(cx), + collisionY: px(cy), + collisionZ: px(cz), + pickMinX: px(pickMin[0]), + pickMinY: px(pickMin[1]), + pickMinZ: px(pickMin[2]), + pickMaxX: px(pickMax[0]), + pickMaxY: px(pickMax[1]), + pickMaxZ: px(pickMax[2]), + uScale: model.uScale || 64, + vScale: model.vScale || 64, + numParts: parts.length + }]] + for (const part of parts) { + const uv = Buffer.alloc(48) + FACES.forEach((face, i) => { + const [u1, v1, u2, v2] = (part.uv && part.uv[face]) || [0, 0, 0, 0] + uv.writeUInt16BE(u1, i * 8) + uv.writeUInt16BE(v1, i * 8 + 2) + uv.writeUInt16BE(u2, i * 8 + 4) + uv.writeUInt16BE(v2, i * 8 + 6) + }) + const anims = Buffer.alloc(68) + ;(part.anims || []).slice(0, 4).forEach((a, i) => { + const o = i * 17 + const type = typeof a.type === 'number' ? a.type : (ANIMS[a.type] || 0) + anims[o] = (type & 0x3F) | ((AXES[a.axis] ?? 0) << 6) + anims.writeFloatBE(a.a || 0, o + 1) + anims.writeFloatBE(a.b || 0, o + 5) + anims.writeFloatBE(a.c || 0, o + 9) + anims.writeFloatBE(a.d || 0, o + 13) + }) + const [x1, y1, z1] = part.min || [0, 0, 0] + const [x2, y2, z2] = part.max || [0, 0, 0] + const [ox, oy, oz] = part.origin || [0, 0, 0] + const [rx, ry, rz] = part.rotation || [0, 0, 0] + packets.push(['defineModelPart', { + modelId: id, + minX: px(x1), + minY: px(y1), + minZ: px(z1), + maxX: px(x2), + maxY: px(y2), + maxZ: px(z2), + uv, + originX: px(ox), + originY: px(oy), + originZ: px(oz), + rotX: rx, + rotY: ry, + rotZ: rz, + anims, + flags: (part.fullbright ? 1 : 0) | (part.firstPersonArm ? 2 : 0) + }]) + } + return packets +} + +// Particle effect definition -> DefineEffect packet data +function effectPacket (id, e) { + const [u1, v1, u2, v2] = e.texture || [0, 0, 7, 7] + const [r, g, b] = e.tint || [255, 255, 255] + return { + effectId: id, + u1, + v1, + u2, + v2, + r, + g, + b, + frameCount: e.frames || 1, + particleCount: e.count || 10, + size: Math.round((e.size ?? 0.25) * 32), + sizeVariation: Math.round((e.sizeVariation ?? 0.2) * 10000), + spread: Math.round((e.spread ?? 0.5) * 32), + speed: Math.round((e.speed ?? 1) * 10000), + gravity: Math.round((e.gravity ?? 0) * 10000), + baseLifetime: Math.round((e.lifetime ?? 1) * 10000), + lifetimeVariation: Math.round((e.lifetimeVariation ?? 0.3) * 10000), + // bit 0: expires on touching the ground, 1: solid, 2: liquid and 3: leaves stop it + collideFlags: (e.collide === false ? 0 : 0x0E) | (e.expireOnGround ? 1 : 0), + fullBright: e.fullBright ? 1 : 0 + } +} + +module.exports = { modelPackets, effectPacket, ANIMS, FACES } diff --git a/lib/events.js b/lib/events.js new file mode 100644 index 0000000..ec63df6 --- /dev/null +++ b/lib/events.js @@ -0,0 +1,76 @@ +'use strict' + +// Event bus with priorities and cancellable events. +// +// const off = server.events.on('playerChat', (ev) => { if (bad(ev.message)) ev.cancel() }, { priority: 'high' }) +// off() // unsubscribe +// +// Priorities run in this order: critical, high, normal, low, monitor. +// 'monitor' handlers should only observe (logging, relays) and never modify the event. + +const PRIORITIES = { critical: 0, high: 1, normal: 2, low: 3, monitor: 4 } + +class EventBus { + constructor (log) { + this.log = log + this.handlers = new Map() + } + + on (name, handler, opts = {}) { + const priority = PRIORITIES[opts.priority || 'normal'] + if (priority === undefined) throw new Error(`Unknown priority ${opts.priority}`) + const list = this.handlers.get(name) || [] + const entry = { handler, priority, owner: opts.owner || null } + list.push(entry) + list.sort((a, b) => a.priority - b.priority) + this.handlers.set(name, list) + return () => this.off(name, handler) + } + + once (name, handler, opts) { + const off = this.on(name, (ev) => { off(); return handler(ev) }, opts) + return off + } + + off (name, handler) { + const list = this.handlers.get(name) + if (!list) return + const i = list.findIndex(e => e.handler === handler) + if (i !== -1) list.splice(i, 1) + } + + // Fires an event. `data` becomes the event object; it gains cancel()/cancelled. + // Handlers of a cancelled event still run if they are 'monitor' handlers. + fire (name, data = {}) { + const ev = data + ev.type = name + ev.cancelled = false + ev.cancelReason = null + ev.cancel = (reason) => { ev.cancelled = true; if (reason) ev.cancelReason = reason } + const list = this.handlers.get(name) + if (!list) return ev + for (const entry of [...list]) { + if (ev.cancelled && entry.priority !== PRIORITIES.monitor) continue + const report = (err) => { + const who = entry.owner ? ` (plugin ${entry.owner})` : '' + if (this.log) this.log.error(`Error in ${name} handler${who}:`, err) + } + try { + const result = entry.handler(ev) + // async handlers can't cancel the event, but their errors still get logged + if (result && typeof result.catch === 'function') result.catch(report) + } catch (err) { + report(err) + } + } + return ev + } + + removeOwner (owner) { + for (const list of this.handlers.values()) { + for (let i = list.length - 1; i >= 0; i--) if (list[i].owner === owner) list.splice(i, 1) + } + } +} + +module.exports = { EventBus, PRIORITIES } diff --git a/lib/heartbeat.js b/lib/heartbeat.js new file mode 100644 index 0000000..6cd6b19 --- /dev/null +++ b/lib/heartbeat.js @@ -0,0 +1,80 @@ +'use strict' + +// Announces the server on the ClassiCube server list (classicube.net). +class Heartbeat { + constructor (server) { + this.server = server + this.timer = null + this.url = null + this.lastError = null + } + + start () { + this.stop() + this.beat() + this.timer = setInterval(() => this.beat(), (this.server.config.heartbeatInterval || 45) * 1000) + this.timer.unref() + } + + stop () { + clearInterval(this.timer) + this.timer = null + } + + params () { + const s = this.server + const cfg = s.config + return new URLSearchParams({ + name: cfg.name, + port: String(s.port), + users: String(s.online.filter(p => !p.hidden).length), + max: String(cfg.maxPlayers), + public: cfg.public ? 'True' : 'False', + salt: s.salt, + software: `&bMCScript &f${s.version}`, + web: cfg.allowWebClient ? 'True' : 'False', + version: '7' + }) + } + + async beat () { + const server = this.server + const ev = server.events.fire('heartbeat', { params: this.params() }) + if (ev.cancelled) return + try { + const res = await fetch(server.config.heartbeatUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: ev.params.toString(), + signal: AbortSignal.timeout(15000) + }) + const body = (await res.text()).trim() + if (body.startsWith('{')) { + const json = JSON.parse(body) + const err = json.errors && json.errors[0] && json.errors[0][0] + if (err && err !== this.lastError) { + this.lastError = err + server.log.warn(`Heartbeat error: ${err}`) + if (/port/i.test(err)) server.log.warn(`Port ${server.port} does not seem to be open. You may need to port forward it.`) + } + return + } + this.lastError = null + if (body && body !== this.url) { + this.url = body + server.log.info(`Server list URL: &b${body}`) + try { + require('fs').writeFileSync(require('path').join(server.root, 'data', 'externalurl.txt'), body) + } catch (err) {} + } + } catch (err) { + const msg = err.name === 'TimeoutError' ? 'timed out' : err.message + if (msg !== this.lastError) { + this.lastError = msg + server.log.warn(`Heartbeat failed: ${msg}`) + } + } + } +} + +module.exports = { Heartbeat } diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..06e7ac5 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,28 @@ +'use strict' + +const { MCScriptServer } = require('./server') +const { loadConfig, loadDotEnv } = require('./config') + +// Creates and starts a server. Options are merged over config/server.json. +async function createServer ({ root = process.cwd(), initial = {}, overrides = {}, console: useConsole = false } = {}) { + const [major, minor] = process.versions.node.split('.').map(Number) + if (major < 22 || (major === 22 && minor < 13)) { + throw new Error(`MCScript needs Node.js 22.13 or newer (you have ${process.version}). Get the LTS release from https://nodejs.org`) + } + loadDotEnv(require('path').join(root, '.env')) + const config = loadConfig({ root, initial, overrides }) + const server = new MCScriptServer(config) + await server.start() + if (useConsole) server.startConsole() + return server +} + +module.exports = { + createServer, + MCScriptServer, + Blocks: require('./blocks'), + text: require('./util/text'), + packets: require('./protocol/packets'), + Level: require('./level/level').Level, + generators: require('./level/generators').generators +} diff --git a/lib/level/generators.js b/lib/level/generators.js new file mode 100644 index 0000000..b38de65 --- /dev/null +++ b/lib/level/generators.js @@ -0,0 +1,228 @@ +'use strict' + +// World generators. Each generator fills level.blocks and may set the spawn point. + +function mulberry32 (seed) { + let a = seed >>> 0 + return function () { + a = (a + 0x6D2B79F5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// 2D value noise with smooth interpolation and octaves +function makeNoise (rand) { + const SIZE = 256 + const perm = new Uint8Array(SIZE * 2) + const values = new Float32Array(SIZE) + for (let i = 0; i < SIZE; i++) { perm[i] = i; values[i] = rand() * 2 - 1 } + for (let i = SIZE - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)) + const tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp + } + for (let i = 0; i < SIZE; i++) perm[i + SIZE] = perm[i] + + const lattice = (x, z) => values[perm[(perm[x & 255] + z) & 255]] + const smooth = t => t * t * (3 - 2 * t) + + function noise (x, z) { + const x0 = Math.floor(x); const z0 = Math.floor(z) + const fx = smooth(x - x0); const fz = smooth(z - z0) + const a = lattice(x0, z0); const b = lattice(x0 + 1, z0) + const c = lattice(x0, z0 + 1); const d = lattice(x0 + 1, z0 + 1) + return (a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fz + } + + return function octaves (x, z, count = 4, persistence = 0.5) { + let total = 0; let amp = 1; let freq = 1; let max = 0 + for (let i = 0; i < count; i++) { + total += noise(x * freq, z * freq) * amp + max += amp + amp *= persistence + freq *= 2 + } + return total / max + } +} + +function fillLayer (level, y, block) { + const { width, length } = level + const start = y * width * length + level.blocks.fill(block, start, start + width * length) +} + +const generators = { + empty: { + description: 'Empty world with a bedrock floor', + generate (level) { + fillLayer(level, 0, 7) + level.spawn = { x: level.width / 2, y: 2, z: level.length / 2, yaw: 0, pitch: 0 } + } + }, + + flat: { + description: 'Flat grass world (half the height is ground)', + generate (level) { + const ground = Math.floor(level.height / 2) + for (let y = 0; y < ground; y++) { + let block = 1 + if (y === 0) block = 7 + else if (y === ground - 1) block = 2 + else if (y >= ground - 4) block = 3 + fillLayer(level, y, block) + } + level.spawn = { x: level.width / 2, y: ground + 1, z: level.length / 2, yaw: 0, pitch: 0 } + } + }, + + pixel: { + description: 'White walls for pixel art, bedrock floor', + generate (level) { + const { width, height, length } = level + fillLayer(level, 0, 7) + for (let y = 1; y < height; y++) { + for (let x = 0; x < width; x++) { + level.setBlockRaw(x, y, 0, 36); level.setBlockRaw(x, y, length - 1, 36) + } + for (let z = 0; z < length; z++) { + level.setBlockRaw(0, y, z, 36); level.setBlockRaw(width - 1, y, z, 36) + } + } + level.spawn = { x: width / 2, y: 2, z: length / 2, yaw: 0, pitch: 0 } + } + }, + + space: { + description: 'Obsidian floor and walls, black sky', + generate (level) { + generators.pixel.generate(level) + const { width, height, length } = level + for (let y = 1; y < height; y++) { + for (let x = 0; x < width; x++) { level.setBlockRaw(x, y, 0, 49); level.setBlockRaw(x, y, length - 1, 49) } + for (let z = 0; z < length; z++) { level.setBlockRaw(0, y, z, 49); level.setBlockRaw(width - 1, y, z, 49) } + } + fillLayer(level, 0, 49) + level.env.skyColor = '000000' + level.env.fogColor = '000000' + level.env.cloudColor = '000000' + level.env.cloudsHeight = -16 + } + }, + + ocean: { + description: 'Endless water with a small sand island', + generate (level, opts) { + const water = Math.floor(level.height / 2) + fillLayer(level, 0, 7) + for (let y = 1; y < water; y++) fillLayer(level, y, y < water - 6 ? 12 : 9) + generators.island.island(level, opts, water, 12) + } + }, + + island: { + description: 'Terrain island surrounded by water, with trees', + generate (level, opts) { + generators.terrain.generate(level, { ...opts, island: true }) + }, + island (level, opts, water, block) { + const cx = level.width / 2; const cz = level.length / 2 + const r = Math.min(level.width, level.length) / 8 + for (let x = 0; x < level.width; x++) { + for (let z = 0; z < level.length; z++) { + const d = Math.hypot(x - cx, z - cz) + if (d > r) continue + const top = water + Math.floor((1 - d / r) * 4) + for (let y = 1; y <= top; y++) level.setBlockRaw(x, y, z, block) + } + } + level.spawn = { x: cx, y: water + 6, z: cz, yaw: 0, pitch: 0 } + } + }, + + terrain: { + description: 'Natural rolling hills with water, beaches and trees (seeded)', + generate (level, opts = {}) { + const rand = mulberry32(opts.seed ?? Date.now()) + const noise = makeNoise(rand) + const detail = makeNoise(rand) + const { width, height, length } = level + const water = Math.floor(height / 2) + const heights = new Int16Array(width * length) + + for (let x = 0; x < width; x++) { + for (let z = 0; z < length; z++) { + let n = noise(x / 64, z / 64, 5) * 0.8 + detail(x / 16, z / 16, 2) * 0.2 + if (opts.island) { + const dx = (x / width) * 2 - 1; const dz = (z / length) * 2 - 1 + const edge = Math.min(1, Math.sqrt(dx * dx + dz * dz)) + n = n * 0.6 + 0.35 - edge * 0.9 + } + const h = Math.max(1, Math.min(height - 2, Math.floor(water + n * height * 0.35))) + heights[x + z * width] = h + for (let y = 0; y <= h; y++) { + let block = 1 + if (y === 0) block = 7 + else if (y === h) block = h < water + 2 ? 12 : 2 + else if (y > h - 4) block = h < water + 2 ? 12 : 3 + level.setBlockRaw(x, y, z, block) + } + for (let y = h + 1; y < water; y++) level.setBlockRaw(x, y, z, 9) + if (h < water && level.getBlock(x, h, z) === 2) level.setBlockRaw(x, h, z, 3) + } + } + + // trees + const trees = Math.floor(width * length / 700) + for (let i = 0; i < trees; i++) { + const x = 2 + Math.floor(rand() * (width - 4)) + const z = 2 + Math.floor(rand() * (length - 4)) + const h = heights[x + z * width] + if (level.getBlock(x, h, z) !== 2 || h + 7 >= height) continue + tree(level, x, h + 1, z, rand) + } + + // flowers + for (let i = 0; i < trees * 2; i++) { + const x = Math.floor(rand() * width); const z = Math.floor(rand() * length) + const h = heights[x + z * width] + if (level.getBlock(x, h, z) === 2 && level.getBlock(x, h + 1, z) === 0) level.setBlockRaw(x, h + 1, z, rand() < 0.5 ? 37 : 38) + } + + // spawn on the highest point near the center + let best = { x: width / 2, z: length / 2, h: 0 } + for (let dx = -16; dx <= 16; dx++) { + for (let dz = -16; dz <= 16; dz++) { + const x = Math.floor(width / 2) + dx; const z = Math.floor(length / 2) + dz + if (x < 0 || z < 0 || x >= width || z >= length) continue + const h = heights[x + z * width] + if (h >= water && (best.h < water || Math.abs(dx) + Math.abs(dz) < Math.abs(best.x - width / 2) + Math.abs(best.z - length / 2))) best = { x, z, h } + } + } + level.spawn = { x: best.x + 0.5, y: Math.max(best.h, water) + 2, z: best.z + 0.5, yaw: 0, pitch: 0 } + } + } +} + +function tree (level, x, y, z, rand) { + const trunk = 4 + Math.floor(rand() * 2) + for (let dy = trunk - 2; dy <= trunk + 1; dy++) { + const r = dy >= trunk ? 1 : 2 + for (let dx = -r; dx <= r; dx++) { + for (let dz = -r; dz <= r; dz++) { + if (Math.abs(dx) === r && Math.abs(dz) === r && rand() < 0.5) continue + if (level.inBounds(x + dx, y + dy, z + dz) && level.getBlock(x + dx, y + dy, z + dz) === 0) level.setBlockRaw(x + dx, y + dy, z + dz, 18) + } + } + } + for (let dy = 0; dy < trunk; dy++) level.setBlockRaw(x, y + dy, z, 17) +} + +// aliases +generators.natural = generators.terrain +generators.hills = generators.terrain +generators.water = generators.ocean + +module.exports = { generators, mulberry32, makeNoise } diff --git a/lib/level/level.js b/lib/level/level.js new file mode 100644 index 0000000..dc327c6 --- /dev/null +++ b/lib/level/level.js @@ -0,0 +1,451 @@ +'use strict' + +const zlib = require('zlib') +const crypto = require('crypto') +const nbt = require('../util/nbt') +const Blocks = require('../blocks') + +const { t } = nbt + +const MAX_DIM = 8192 + +const ENV_COLORS = ['skyColor', 'cloudColor', 'fogColor', 'shadowColor', 'sunColor', 'skyboxColor'] +// ids for SetMapEnvProperty +const ENV_PROPS = { + sideBlock: 0, + edgeBlock: 1, + edgeHeight: 2, + cloudsHeight: 3, + maxFog: 4, + cloudsSpeed: 5, + weatherSpeed: 6, + weatherFade: 7, + expFog: 8, + sidesOffset: 9, + skyboxHorSpeed: 10, + skyboxVerSpeed: 11 +} + +function defaultEnv () { + return { + texture: '', + skyColor: null, + cloudColor: null, + fogColor: null, + shadowColor: null, + sunColor: null, + skyboxColor: null, + weather: 0, + sideBlock: 7, + edgeBlock: 8, + edgeHeight: null, + cloudsHeight: null, + maxFog: 0, + cloudsSpeed: 1, + weatherSpeed: 1, + weatherFade: 1, + expFog: false, + sidesOffset: -2, + skyboxHorSpeed: 0, + skyboxVerSpeed: 0, + lighting: null + } +} + +class Level { + constructor ({ name, width, height, length }) { + if (!(width >= 1 && height >= 1 && length >= 1 && width <= MAX_DIM && height <= MAX_DIM && length <= MAX_DIM)) { + throw new Error(`Level dimensions must be between 1 and ${MAX_DIM}`) + } + this.name = name + this.width = width + this.height = height + this.length = length + this.blocks = new Uint8Array(width * height * length) + this.upper = null // high byte of block ids above 255 (ExtendedBlocks), created on demand + this.spawn = { x: width / 2, y: height / 2 + 2, z: length / 2, yaw: 0, pitch: 0 } + this.env = defaultEnv() + this.motd = 'ignore' // 'ignore' = use server motd; can hold hack flags like "-hax +fly" + this.buildRank = null // null = default rank can build + this.owners = [] // lowercase names that can always build here (personal maps) + this.visitRank = null + this.blockDefs = {} // level specific custom blocks: id -> definition + this.meta = {} // free storage for plugins (saved with the level) + this.uuid = crypto.randomBytes(16) + this.createdBy = null + this.generator = null + this.createdAt = Date.now() + this.dirty = false + this.server = null + } + + get volume () { return this.width * this.height * this.length } + + index (x, y, z) { return (y * this.length + z) * this.width + x } + + unpack (index) { + const x = index % this.width + const z = Math.floor(index / this.width) % this.length + const y = Math.floor(index / (this.width * this.length)) + return { x, y, z } + } + + inBounds (x, y, z) { + return x >= 0 && y >= 0 && z >= 0 && x < this.width && y < this.height && z < this.length + } + + // Levels too big for clients without ExtEntityPositions (coordinates would overflow) + get needsExtPositions () { return Math.max(this.width, this.height, this.length) > 1023 } + + getBlock (x, y, z) { + if (!this.inBounds(x, y, z)) return 0 + return this.getAt(this.index(x, y, z)) + } + + // Block at a raw index + getAt (i) { + return this.upper ? this.blocks[i] | (this.upper[i] << 8) : this.blocks[i] + } + + setAt (i, block) { + this.blocks[i] = block & 0xFF + if (block > 0xFF) { + if (!this.upper) this.upper = new Uint8Array(this.blocks.length) + this.upper[i] = block >> 8 + } else if (this.upper) { + this.upper[i] = 0 + } + } + + setBlockRaw (x, y, z, block) { + this.setAt(this.index(x, y, z), block) + } + + // Sets a block and broadcasts the change to players in this level + setBlock (x, y, z, block) { + if (!this.inBounds(x, y, z)) return false + const i = this.index(x, y, z) + if (this.getAt(i) === block) return false + this.setAt(i, block) + this.dirty = true + if (this.server) this.server.broadcastBlockChanges(this, [[i, block]]) + return true + } + + // Sets many blocks at once: changes = [[x, y, z, block], ...]. Uses BulkBlockUpdate when possible. + setBlocks (changes) { + const applied = [] + for (const [x, y, z, block] of changes) { + if (!this.inBounds(x, y, z)) continue + const i = this.index(x, y, z) + if (this.getAt(i) === block) continue + this.setAt(i, block) + applied.push([i, block]) + } + if (applied.length) { + this.dirty = true + if (this.server) this.server.broadcastBlockChanges(this, applied) + } + return applied.length + } + + // First y above the highest non-air block in a column (useful for spawn points) + highestFreeY (x, z) { + for (let y = this.height - 1; y >= 0; y--) { + if (this.getBlock(x, y, z) !== 0) return y + 1 + } + return 1 + } + + isOwner (player) { + return !!player.name && this.owners.includes(player.name.toLowerCase()) + } + + // Build rank or ownership + canBuild (player) { + if (player.isConsole || this.isOwner(player)) return true + const ranks = this.server && this.server.ranks + return !ranks || player.permission >= ranks.permissionOf(this.buildRank) + } + + getBlockDef (id) { + if (this.blockDefs[id]) return this.blockDefs[id] + if (this.server) return this.server.blockDefs[id] || null + return null + } + + blockName (id) { + const def = this.getBlockDef(id) + if (def) return def.name + return Blocks.coreName(id) || `Unknown (${id})` + } + + parseBlock (input) { + return Blocks.parse(input, id => this.getBlockDef(id)) + } + + isValidBlock (id) { + return id <= Blocks.MAX_CPE || !!this.getBlockDef(id) + } + + get players () { + return this.server ? this.server.players.filter(p => p.level === this) : [] + } + + // Blocks for the map transfer, translated for one client: each array is prefixed with the + // 4 byte big endian volume (classic format). `upper` is only returned when a block id > 255 is sent. + serializeForClient (translate) { + const volume = this.volume + const lower = Buffer.alloc(4 + volume) + lower.writeInt32BE(volume, 0) + let upper = null + const up = this.upper + for (let i = 0; i < volume; i++) { + const b = translate[up ? this.blocks[i] | (up[i] << 8) : this.blocks[i]] + lower[4 + i] = b & 0xFF + if (b > 0xFF) { + if (!upper) { upper = Buffer.alloc(4 + volume); upper.writeInt32BE(volume, 0) } + upper[4 + i] = b >> 8 + } + } + return { lower, upper } + } + + resolvedEnv () { + const env = { ...this.env } + if (env.edgeHeight === null) env.edgeHeight = Math.floor(this.height / 2) + if (env.cloudsHeight === null) env.cloudsHeight = this.height + 2 + return env + } + + // ClassicWorld (.cw) + + toCW () { + const env = this.env + const colorTag = hex => { + if (!hex) return { R: t.short(-1), G: t.short(-1), B: t.short(-1) } + const n = parseInt(hex, 16) + return { R: t.short((n >> 16) & 255), G: t.short((n >> 8) & 255), B: t.short(n & 255) } + } + const blockDefs = {} + for (const def of Object.values(this.blockDefs)) { + const d = Blocks.normalize(def) + blockDefs['Block' + d.id] = t.compound({ + ID: t.byte((d.id & 0xFF) << 24 >> 24), + ID2: t.short(d.id), + Name: t.string(d.name), + Speed: t.float(d.speed), + CollideType: t.byte(d.collide), + Textures: t.bytes(Buffer.from([d.textures.top, d.textures.bottom, d.textures.left, d.textures.right, d.textures.front, d.textures.back])), + TransmitsLight: t.byte(d.transmitsLight ? 1 : 0), + WalkSound: t.byte(d.sound), + FullBright: t.byte(d.fullBright ? 1 : 0), + Shape: t.byte(d.sprite ? 0 : d.max[1]), + BlockDraw: t.byte(d.draw), + Fog: t.bytes(Buffer.from([d.fogDensity, ...d.fogColor])), + Coords: t.bytes(Buffer.from([...d.min, ...d.max])) + }) + } + + const mcscript = { + env: this.env, + motd: this.motd, + buildRank: this.buildRank, + owners: this.owners, + visitRank: this.visitRank, + blockDefs: this.blockDefs, + meta: this.meta + } + + const root = { + FormatVersion: t.byte(1), + Name: t.string(this.name), + UUID: t.bytes(this.uuid), + X: t.short(this.width), + Y: t.short(this.height), + Z: t.short(this.length), + Spawn: t.compound({ + X: t.short(Math.floor(this.spawn.x)), + Y: t.short(Math.floor(this.spawn.y)), + Z: t.short(Math.floor(this.spawn.z)), + H: t.byte(this.spawn.yaw << 24 >> 24), + P: t.byte(this.spawn.pitch << 24 >> 24) + }), + BlockArray: t.bytes(Buffer.from(this.blocks.buffer, this.blocks.byteOffset, this.blocks.length)), + BlockArray2: this.upper && this.upper.some(v => v) ? t.bytes(Buffer.from(this.upper.buffer, this.upper.byteOffset, this.upper.length)) : null, + TimeCreated: t.long(Math.floor(this.createdAt / 1000)), + CreatedBy: t.compound({ Service: t.string('MCScript'), Username: t.string(this.createdBy || '') }), + MapGenerator: t.compound({ Software: t.string('MCScript'), MapGeneratorName: t.string(this.generator || '') }), + Metadata: t.compound({ + CPE: t.compound({ + EnvMapAppearance: t.compound({ + ExtensionVersion: t.int(1), + TextureURL: t.string(env.texture || ''), + SideBlock: t.byte(env.sideBlock << 24 >> 24), + EdgeBlock: t.byte(env.edgeBlock << 24 >> 24), + SideLevel: t.short(env.edgeHeight === null ? -1 : env.edgeHeight) + }), + EnvColors: t.compound({ + ExtensionVersion: t.int(1), + Sky: t.compound(colorTag(env.skyColor)), + Cloud: t.compound(colorTag(env.cloudColor)), + Fog: t.compound(colorTag(env.fogColor)), + Ambient: t.compound(colorTag(env.shadowColor)), + Sunlight: t.compound(colorTag(env.sunColor)) + }), + EnvWeatherType: t.compound({ ExtensionVersion: t.int(1), WeatherType: t.byte(env.weather) }), + BlockDefinitions: t.compound({ ExtensionVersion: t.int(1), ...blockDefs }) + }), + MCScript: t.compound({ Data: t.string(JSON.stringify(mcscript)) }) + }) + } + return zlib.gzipSync(nbt.write('ClassicWorld', root)) + } + + static fromCW (buf, name) { + const { value: root } = nbt.read(zlib.gunzipSync(buf)) + const get = (c, k) => c && c[k] ? c[k].value : undefined + const width = get(root, 'X'); const height = get(root, 'Y'); const length = get(root, 'Z') + const level = new Level({ name: name || get(root, 'Name') || 'level', width, height, length }) + const blocks = get(root, 'BlockArray') + if (!blocks || blocks.length !== level.volume) throw new Error('Invalid BlockArray in .cw file') + level.blocks.set(blocks) + const upper = get(root, 'BlockArray2') + if (upper && upper.length === level.volume) level.upper = new Uint8Array(upper) + if (get(root, 'UUID')) level.uuid = get(root, 'UUID') + const spawn = get(root, 'Spawn') + if (spawn) { + level.spawn = { + x: get(spawn, 'X') + 0.5, + y: get(spawn, 'Y'), + z: get(spawn, 'Z') + 0.5, + yaw: (get(spawn, 'H') || 0) & 255, + pitch: (get(spawn, 'P') || 0) & 255 + } + } + const created = get(root, 'TimeCreated') + if (created !== undefined) level.createdAt = Number(created) * 1000 + const createdBy = get(root, 'CreatedBy') + if (createdBy) level.createdBy = get(createdBy, 'Username') || null + const gen = get(root, 'MapGenerator') + if (gen) level.generator = get(gen, 'MapGeneratorName') || null + + const metadata = get(root, 'Metadata') || {} + const own = get(metadata, 'MCScript') + if (own && get(own, 'Data')) { + const data = JSON.parse(get(own, 'Data')) + level.env = { ...defaultEnv(), ...(data.env || {}) } + level.motd = data.motd ?? 'ignore' + level.buildRank = data.buildRank ?? null + level.owners = data.owners || [] + level.visitRank = data.visitRank ?? null + level.blockDefs = data.blockDefs || {} + level.meta = data.meta || {} + } else { + level._importCPEMetadata(get(metadata, 'CPE') || {}) + } + return level + } + + // Read environment/blocks written by ClassiCube or other server software + _importCPEMetadata (cpe) { + const get = (c, k) => c && c[k] ? c[k].value : undefined + const app = get(cpe, 'EnvMapAppearance') + if (app) { + this.env.texture = get(app, 'TextureURL') || '' + if (get(app, 'SideBlock') !== undefined) this.env.sideBlock = get(app, 'SideBlock') & 255 + if (get(app, 'EdgeBlock') !== undefined) this.env.edgeBlock = get(app, 'EdgeBlock') & 255 + const side = get(app, 'SideLevel') + if (side !== undefined && side >= 0) this.env.edgeHeight = side + } + const colors = get(cpe, 'EnvColors') + if (colors) { + const read = c => { + if (!c) return null + const r = get(c, 'R'); const g = get(c, 'G'); const b = get(c, 'B') + if (r < 0 || g < 0 || b < 0 || r > 255 || g > 255 || b > 255) return null + return [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('') + } + this.env.skyColor = read(get(colors, 'Sky')) + this.env.cloudColor = read(get(colors, 'Cloud')) + this.env.fogColor = read(get(colors, 'Fog')) + this.env.shadowColor = read(get(colors, 'Ambient')) + this.env.sunColor = read(get(colors, 'Sunlight')) + } + const weather = get(cpe, 'EnvWeatherType') + if (weather) this.env.weather = get(weather, 'WeatherType') || 0 + const defs = get(cpe, 'BlockDefinitions') + if (defs) { + for (const [key, tag] of Object.entries(defs)) { + if (!key.startsWith('Block') || tag.type !== 'compound') continue + const c = tag.value + const id = get(c, 'ID2') !== undefined ? get(c, 'ID2') : get(c, 'ID') & 255 + if (id < 1 || id > Blocks.MAX_BLOCK) continue + const tex = get(c, 'Textures') || Buffer.from([1, 1, 1, 1, 1, 1]) + const fog = get(c, 'Fog') || Buffer.alloc(4) + const coords = get(c, 'Coords') || Buffer.from([0, 0, 0, 16, 16, 16]) + const shape = get(c, 'Shape') ?? 16 + this.blockDefs[id] = Blocks.normalize({ + id, + name: get(c, 'Name') || `Block ${id}`, + speed: get(c, 'Speed') || 1, + collide: get(c, 'CollideType') ?? 2, + textures: { top: tex[0], bottom: tex[1], left: tex[2], right: tex[3], front: tex[4], back: tex[5] }, + transmitsLight: !!get(c, 'TransmitsLight'), + sound: get(c, 'WalkSound') || 0, + fullBright: !!get(c, 'FullBright'), + sprite: shape === 0, + draw: get(c, 'BlockDraw') || 0, + fogDensity: fog[0], + fogColor: [fog[1], fog[2], fog[3]], + min: [coords[0], coords[1], coords[2]], + max: [coords[3], coords[4], coords[5]] + }) + } + } + } + + // imports + + // MCGalaxy / MCForge .lvl + static fromLvl (buf, name) { + const data = zlib.gunzipSync(buf) + if (data.readUInt16LE(0) !== 1874) throw new Error('Not a .lvl file') + const width = data.readUInt16LE(2); const length = data.readUInt16LE(4); const height = data.readUInt16LE(6) + const level = new Level({ name, width, height, length }) + level.spawn = { + x: data.readUInt16LE(8) + 0.5, + z: data.readUInt16LE(10) + 0.5, + y: data.readUInt16LE(12), + yaw: data[14], + pitch: data[15] + } + const volume = level.volume + const raw = data.subarray(18, 18 + volume) + let unknown = 0 + for (let i = 0; i < volume; i++) { + let b = raw[i] + // 163 = "custom block" marker in MCGalaxy; physics/op blocks (>= 66) are converted to stone + if (b > Blocks.MAX_CPE) { b = 1; unknown++ } + level.blocks[i] = b + } + level.generator = 'import:lvl' + level.importWarnings = unknown ? [`${unknown} MCGalaxy special/custom blocks converted to stone`] : [] + return level + } + + // Legacy MCScript level.dat (gzip: 4 byte volume + raw 256x64x256 blocks) + static fromLegacyDat (buf, name, dims = { width: 256, height: 64, length: 256 }) { + const data = zlib.gunzipSync(buf) + const volume = data.readInt32BE(0) + if (volume !== dims.width * dims.height * dims.length) throw new Error('Unexpected legacy level size') + const level = new Level({ name, ...dims }) + level.blocks.set(data.subarray(4, 4 + volume)) + const cx = Math.floor(dims.width / 2); const cz = Math.floor(dims.length / 2) + level.spawn = { x: cx + 0.5, y: level.highestFreeY(cx, cz), z: cz + 0.5, yaw: 0, pitch: 0 } + level.generator = 'import:legacy' + return level + } +} + +module.exports = { Level, ENV_COLORS, ENV_PROPS, defaultEnv, MAX_DIM } diff --git a/lib/level/manager.js b/lib/level/manager.js new file mode 100644 index 0000000..e20d349 --- /dev/null +++ b/lib/level/manager.js @@ -0,0 +1,245 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const { Level, defaultEnv } = require('./level') +const { generators } = require('./generators') + +const NAME_RE = /^[A-Za-z0-9_.+-]{1,32}$/ + +class LevelManager { + constructor (server, opts) { + this.server = server + this.dir = opts.dir + this.backupDir = path.join(this.dir, 'backups') + this.deletedDir = path.join(this.dir, 'deleted') + this.backupsToKeep = opts.backupsToKeep ?? 10 + this.loaded = new Map() + this.main = null + fs.mkdirSync(this.dir, { recursive: true }) + } + + static isValidName (name) { return NAME_RE.test(name || '') } + + defaultEnv () { return defaultEnv() } + + file (name) { return path.join(this.dir, `${name}.cw`) } + + // Case-insensitive lookup of a level file name on disk + resolveName (name) { + if (!name) return null + const lower = name.toLowerCase() + for (const l of this.loaded.values()) if (l.name.toLowerCase() === lower) return l.name + for (const f of this.listFiles()) if (f.toLowerCase() === lower) return f + return null + } + + listFiles () { + return fs.readdirSync(this.dir) + .filter(f => f.endsWith('.cw')) + .map(f => f.slice(0, -3)) + .sort((a, b) => a.localeCompare(b)) + } + + get (name) { + return name ? this.loaded.get(name.toLowerCase()) || null : null + } + + exists (name) { return this.resolveName(name) !== null } + + _register (level) { + level.server = this.server + this.loaded.set(level.name.toLowerCase(), level) + this.server.events.fire('levelLoad', { level }) + this.server.log.info(`Loaded level &a${level.name}&f (${level.width}x${level.height}x${level.length})`) + return level + } + + load (name) { + const existing = this.get(name) + if (existing) return existing + const real = this.resolveName(name) + if (!real) throw new Error(`Level "${name}" does not exist`) + const level = Level.fromCW(fs.readFileSync(this.file(real)), real) + return this._register(level) + } + + create (name, { width = 128, height = 64, length = 128, type = 'flat', seed, creator } = {}) { + if (!LevelManager.isValidName(name)) throw new Error('Invalid level name (use letters, numbers, _ . + -)') + if (this.exists(name)) throw new Error(`Level "${name}" already exists`) + const gen = generators[type] + if (!gen) throw new Error(`Unknown generator "${type}". Available: ${Object.keys(generators).join(', ')}`) + const level = new Level({ name, width, height, length }) + level.createdBy = creator || null + level.generator = type + gen.generate(level, { seed: seed ?? Math.floor(Math.random() * 2 ** 31) }) + this.save(level) + return this._register(level) + } + + // Import a foreign map file (.lvl from MCGalaxy, legacy MCScript .dat) found in the levels folder + import (fileName, newName) { + const src = path.join(this.dir, path.basename(fileName)) + if (!fs.existsSync(src)) throw new Error(`File ${path.basename(fileName)} not found in ${this.dir}`) + const name = newName || path.basename(fileName).replace(/\.[^.]+$/, '') + if (!LevelManager.isValidName(name)) throw new Error('Invalid level name') + if (this.exists(name)) throw new Error(`Level "${name}" already exists`) + let level + if (src.endsWith('.lvl')) level = Level.fromLvl(fs.readFileSync(src), name) + else if (src.endsWith('.dat')) level = Level.fromLegacyDat(fs.readFileSync(src), name) + else if (src.endsWith('.cw')) level = Level.fromCW(fs.readFileSync(src), name) + else throw new Error('Supported formats: .cw, .lvl (MCGalaxy), .dat (old MCScript)') + this.save(level) + this._register(level) + return level + } + + save (level) { + const data = level.toCW() + const file = this.file(level.name) + const tmp = file + '.tmp' + fs.writeFileSync(tmp, data) + fs.renameSync(tmp, file) + level.dirty = false + this.server.events.fire('levelSave', { level }) + } + + saveAll (onlyDirty = true) { + let n = 0 + for (const level of this.loaded.values()) { + if (onlyDirty && !level.dirty) continue + try { this.save(level); n++ } catch (err) { this.server.log.error(`Failed to save ${level.name}:`, err) } + } + return n + } + + unload (name, { save = true } = {}) { + const level = this.get(name) + if (!level) return false + if (level === this.main) throw new Error('The main level cannot be unloaded') + const ev = this.server.events.fire('levelUnload', { level }) + if (ev.cancelled) return false + for (const p of level.players) p.changeLevel(this.main, { silent: true }) + if (save && level.dirty) this.save(level) + this.loaded.delete(level.name.toLowerCase()) + this.server.log.info(`Unloaded level ${level.name}`) + return true + } + + copy (name, newName) { + const real = this.resolveName(name) + if (!real) throw new Error(`Level "${name}" does not exist`) + if (!LevelManager.isValidName(newName)) throw new Error('Invalid level name') + if (this.exists(newName)) throw new Error(`Level "${newName}" already exists`) + const loaded = this.get(real) + if (loaded && loaded.dirty) this.save(loaded) + fs.copyFileSync(this.file(real), this.file(newName)) + } + + rename (name, newName) { + const real = this.resolveName(name) + if (!real) throw new Error(`Level "${name}" does not exist`) + if (this.main && real.toLowerCase() === this.main.name.toLowerCase()) throw new Error('The main level cannot be renamed') + if (!LevelManager.isValidName(newName)) throw new Error('Invalid level name') + if (this.exists(newName) && newName.toLowerCase() !== real.toLowerCase()) throw new Error(`Level "${newName}" already exists`) + const loaded = this.get(real) + if (loaded) { + if (loaded.dirty) this.save(loaded) + this.loaded.delete(real.toLowerCase()) + loaded.name = newName + this.loaded.set(newName.toLowerCase(), loaded) + } + fs.renameSync(this.file(real), this.file(newName)) + const oldBackups = path.join(this.backupDir, real) + if (fs.existsSync(oldBackups)) fs.renameSync(oldBackups, path.join(this.backupDir, newName)) + if (loaded) this.save(loaded) + } + + // Changes the size of a level, keeping the blocks that still fit + resize (level, width, height, length) { + const resized = new Level({ name: level.name, width, height, length }) + for (let y = 0; y < Math.min(height, level.height); y++) { + for (let z = 0; z < Math.min(length, level.length); z++) { + for (let x = 0; x < Math.min(width, level.width); x++) { + const b = level.getBlock(x, y, z) + if (b) resized.setBlockRaw(x, y, z, b) + } + } + } + level.width = width + level.height = height + level.length = length + level.blocks = resized.blocks + level.upper = resized.upper + const s = level.spawn + if (!level.inBounds(Math.floor(s.x), Math.floor(s.y), Math.floor(s.z))) { + level.spawn = { x: width / 2, y: level.highestFreeY(Math.floor(width / 2), Math.floor(length / 2)), z: length / 2, yaw: 0, pitch: 0 } + } + level.dirty = true + for (const p of level.players) p.changeLevel(level, { force: true, silent: true }) + } + + // Deleted maps are moved to levels/deleted so they can be recovered + delete (name) { + const real = this.resolveName(name) + if (!real) throw new Error(`Level "${name}" does not exist`) + if (this.main && real.toLowerCase() === this.main.name.toLowerCase()) throw new Error('The main level cannot be deleted') + if (this.get(real)) this.unload(real, { save: false }) + fs.mkdirSync(this.deletedDir, { recursive: true }) + fs.renameSync(this.file(real), path.join(this.deletedDir, `${real}-${Date.now()}.cw`)) + } + + backup (level) { + const dir = path.join(this.backupDir, level.name) + fs.mkdirSync(dir, { recursive: true }) + const existing = this.listBackups(level.name) + const next = existing.length ? existing[existing.length - 1] + 1 : 1 + fs.writeFileSync(path.join(dir, `${next}.cw`), level.toCW()) + const all = [...existing, next] + while (all.length > this.backupsToKeep) { + const old = all.shift() + fs.rmSync(path.join(dir, `${old}.cw`), { force: true }) + } + return next + } + + listBackups (name) { + const dir = path.join(this.backupDir, name) + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir) + .filter(f => /^\d+\.cw$/.test(f)) + .map(f => Number(f.slice(0, -3))) + .sort((a, b) => a - b) + } + + restore (level, backup) { + const file = path.join(this.backupDir, level.name, `${backup}.cw`) + if (!fs.existsSync(file)) throw new Error(`Backup ${backup} not found`) + const restored = Level.fromCW(fs.readFileSync(file), level.name) + if (restored.width !== level.width || restored.height !== level.height || restored.length !== level.length) { + throw new Error('Backup has different dimensions') + } + level.blocks.set(restored.blocks) + level.upper = restored.upper + level.env = restored.env + level.blockDefs = restored.blockDefs + level.meta = restored.meta + level.dirty = true + for (const p of level.players) p.changeLevel(level, { silent: true, force: true }) + } + + // Load or create the main level, migrating the pre-2.0 levels/level.dat if present + initMain (name) { + if (this.exists(name)) { + this.main = this.load(name) + } else if (fs.existsSync(path.join(this.dir, 'level.dat'))) { + this.server.log.info('Migrating legacy levels/level.dat to the new .cw format...') + this.main = this.import('level.dat', name) + } else { + this.main = this.create(name, { width: 256, height: 64, length: 256, type: 'terrain' }) + } + return this.main + } +} + +module.exports = { LevelManager } diff --git a/lib/network/connection.js b/lib/network/connection.js new file mode 100644 index 0000000..95e00f4 --- /dev/null +++ b/lib/network/connection.js @@ -0,0 +1,121 @@ +'use strict' + +const { EventEmitter } = require('events') +const packets = require('../protocol/packets') +const { WebSocketTransport } = require('./websocket') + +const MAX_BUFFER = 64 * 1024 + +// Wraps a TCP socket (or a WebSocket running over it) and turns the byte stream +// into decoded Classic/CPE packets. +class Connection extends EventEmitter { + constructor (socket) { + super() + this.socket = socket + this.transport = null // null = raw TCP, otherwise WebSocketTransport + this.buffer = Buffer.alloc(0) + this.closed = false + this.fullCP437 = false + // negotiated per connection: they change the size of several packets + this.extBlocks = false + this.extPos = false + 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 + + socket.setNoDelay(true) + socket.on('data', data => this._onSocketData(data)) + socket.on('error', err => this.emit('socketError', err)) + socket.on('close', () => this._onClose()) + } + + _onSocketData (data) { + if (this.closed) return + if (this.transport === null && this.buffer.length === 0 && !this._sniffed) { + this._sniffed = true + // 'G' = HTTP GET => WebSocket upgrade (web client) + if (data[0] === 0x47) { + this.isWebSocket = true + this.transport = new WebSocketTransport(this.socket) + this.transport.on('data', d => this._onData(d)) + } + } + if (this.transport) { + if (!this.transport.open) { + if (this.transport.handshake(data)) this.forwardedFor = this.transport.remoteAddress + } else { + this.transport.receive(data) + } + return + } + this._onData(data) + } + + _onData (data) { + this.buffer = this.buffer.length ? Buffer.concat([this.buffer, data]) : data + if (this.buffer.length > MAX_BUFFER) return this.kick('Too much data') + + while (this.buffer.length > 0 && !this.closed) { + const id = this.buffer[0] + const def = packets.CLIENT[id] + if (!def) { + this.emit('invalidPacket', id) + return this.kick(`Unknown packet id ${id}`) + } + const size = packets.sizeOf(def, this) + if (this.buffer.length < size) return + const raw = this.buffer.subarray(0, size) + this.buffer = this.buffer.subarray(size) + let packet + try { + packet = packets.decode(id, raw, this) + } catch (err) { + return this.kick('Malformed packet') + } + // a bug in a packet handler must not take the whole server down + try { + this.emit('packet', packet) + this.emit(packet.name, packet) + } catch (err) { + this.emit('handlerError', err, packet) + return this.kick('Internal server error') + } + } + } + + writeRaw (buf) { + if (this.closed || this.socket.destroyed) return + if (this.transport) this.transport.write(buf) + else this.socket.write(buf) + } + + write (name, data) { + this.writeRaw(packets.encode(name, data, this)) + } + + kick (reason) { + if (this.closed) return + this.write('disconnect', { reason: String(reason).slice(0, 64) }) + this.close() + } + + close () { + if (this.closed) return + this.closed = true + const sock = this.socket + if (this.transport) this.transport.close() + else sock.end() + // make sure we don't keep half-open sockets around + setTimeout(() => sock.destroy(), 1000).unref() + this._onClose() + } + + _onClose () { + if (this._closeEmitted) return + this._closeEmitted = true + this.closed = true + this.emit('close') + } +} + +module.exports = { Connection } diff --git a/lib/network/websocket.js b/lib/network/websocket.js new file mode 100644 index 0000000..b0d64b5 --- /dev/null +++ b/lib/network/websocket.js @@ -0,0 +1,141 @@ +'use strict' + +// Minimal server-side WebSocket (RFC 6455) so the ClassiCube web client can connect +// on the same port as normal clients. Only binary frames are used by the client. + +const crypto = require('crypto') +const { EventEmitter } = require('events') + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' +// Client packets are tiny; anything bigger than this is not a real ClassiCube client. +const MAX_MESSAGE = 64 * 1024 + +class WebSocketTransport extends EventEmitter { + constructor (socket) { + super() + this.socket = socket + this.buffer = Buffer.alloc(0) + this.fragments = [] + this.open = false + this.closed = false + } + + // Parses the HTTP upgrade request. Returns true when handshake is complete. + handshake (data) { + this.buffer = Buffer.concat([this.buffer, data]) + const end = this.buffer.indexOf('\r\n\r\n') + if (end === -1) { + if (this.buffer.length > 8192) this.socket.destroy() + return false + } + const head = this.buffer.subarray(0, end).toString('latin1') + const rest = this.buffer.subarray(end + 4) + this.buffer = Buffer.alloc(0) + + const headers = {} + for (const line of head.split('\r\n').slice(1)) { + const i = line.indexOf(':') + if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim() + } + this.remoteAddress = headers['x-forwarded-for'] ? headers['x-forwarded-for'].split(',')[0].trim() : null + + 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') + return false + } + + const accept = crypto.createHash('sha1').update(key + GUID).digest('base64') + const lines = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}` + ] + if (headers['sec-websocket-protocol']) lines.push('Sec-WebSocket-Protocol: ClassiCube') + this.socket.write(lines.join('\r\n') + '\r\n\r\n') + this.open = true + if (rest.length) this.receive(rest) + return true + } + + receive (data) { + if (this.closed) return + this.buffer = this.buffer.length ? Buffer.concat([this.buffer, data]) : data + if (this.buffer.length > MAX_MESSAGE + 14) return this.close(1009) + while (this.buffer.length >= 2) { + const b0 = this.buffer[0] + const b1 = this.buffer[1] + const fin = (b0 & 0x80) !== 0 + const opcode = b0 & 0x0F + const masked = (b1 & 0x80) !== 0 + let len = b1 & 0x7F + let o = 2 + if (len === 126) { + if (this.buffer.length < 4) return + len = this.buffer.readUInt16BE(2); o = 4 + } else if (len === 127) { + if (this.buffer.length < 10) return + const big = this.buffer.readBigUInt64BE(2) + if (big > BigInt(MAX_MESSAGE)) return this.close(1009) + len = Number(big); o = 10 + } + if (!masked) return this.close(1002) // RFC 6455: client frames must be masked + if (len > MAX_MESSAGE) return this.close(1009) + const maskLen = 4 + if (this.buffer.length < o + maskLen + len) return + const mask = this.buffer.subarray(o, o + 4) + o += maskLen + const payload = Buffer.from(this.buffer.subarray(o, o + len)) + for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3] + this.buffer = this.buffer.subarray(o + len) + + switch (opcode) { + case 0x0: // continuation + case 0x1: // text + case 0x2: // binary + this.fragments.push(payload) + this.fragmentBytes = (this.fragmentBytes || 0) + payload.length + if (this.fragmentBytes > MAX_MESSAGE) return this.close(1009) + if (fin) { + this.fragmentBytes = 0 + const msg = Buffer.concat(this.fragments) + this.fragments = [] + this.emit('data', msg) + } + break + case 0x8: this.close(1000); return + case 0x9: this.sendFrame(0xA, payload); break + case 0xA: break + default: this.close(1002); return + } + } + } + + sendFrame (opcode, payload) { + if (this.socket.destroyed) return + const len = payload.length + let header + if (len < 126) { + header = Buffer.from([0x80 | opcode, len]) + } else if (len < 65536) { + header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2) + } else { + header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2) + } + this.socket.write(Buffer.concat([header, payload])) + } + + write (buf) { this.sendFrame(0x2, buf) } + + close (code = 1000) { + if (this.closed) return + this.closed = true + const payload = Buffer.alloc(2) + payload.writeUInt16BE(code) + this.sendFrame(0x8, payload) + this.socket.end() + } +} + +module.exports = { WebSocketTransport } diff --git a/lib/player.js b/lib/player.js new file mode 100644 index 0000000..fb70dcb --- /dev/null +++ b/lib/player.js @@ -0,0 +1,927 @@ +'use strict' + +const zlib = require('zlib') +const crypto = require('crypto') +const { EventEmitter } = require('events') +const { EXTENSIONS } = require('./protocol/packets') +const Blocks = require('./blocks') +const text = require('./util/text') +const { ENV_COLORS, ENV_PROPS } = require('./level/level') + +const MESSAGE_TYPES = { + chat: 0, + status1: 1, + status2: 2, + status3: 3, + bottom1: 11, + bottom2: 12, + bottom3: 13, + announce: 100, + bigAnnounce: 101, + smallAnnounce: 102 +} + +const NOTIFY_ACTIONS = ['blockListSelected', 'blockListToggled', 'levelSaved', 'respawned', 'spawnUpdated', 'texturePackChanged', 'texturePromptResponded', 'thirdPersonChanged'] + +const NAME_RE = /^[A-Za-z0-9_.]{1,16}$/ +// names that would clash with JavaScript object internals when used as keys +const RESERVED_NAMES = new Set(['__proto__', 'constructor', 'prototype', 'console']) +const SELF = -1 + +// Positions are kept in protocol units: 1 block = 32 units, and y is the eye position (feet + 51). +const EYE = 51 + +function toUnits (blocks) { return Math.round(blocks * 32) } + +class Player extends EventEmitter { + constructor (server, conn) { + super() + this.server = server + this.conn = conn + this.ip = server.config.trustProxy && conn.forwardedFor ? conn.forwardedFor : conn.socketIp + this.name = null + this.id = null // entity id seen by other players + this.level = null + this.pos = { x: 0, y: 0, z: 0 } + this.yaw = 0 + this.pitch = 0 + this.heldBlock = 1 + this.cpe = false + this.appName = 'Unknown' + this.extensions = new Map() + this.customBlocksLevel = 0 + this.spawned = false + this.loggedIn = false + this.loadingLevel = false + this.record = null + this.joinedAt = Date.now() + this.lastActivity = Date.now() + this.partialMessage = '' + this.selection = null + this.sentBlockDefs = new Map() + this.blockTable = new Uint16Array(Blocks.MAX_BLOCK + 1) + this.frozen = false + this.hidden = false + this.data = {} // scratch space for plugins (not saved) + this.pingMs = null + this._pingSent = new Map() + this.isConsole = false + + conn.on('identification', p => this._onIdentification(p).catch(err => { + this.server.log.error('Login error:', err) + this.kick('Login failed') + })) + conn.on('close', () => this.server._onPlayerDisconnect(this)) + this._loginTimeout = setTimeout(() => { if (!this.loggedIn) this.kick('Login timed out') }, 15000) + this._loginTimeout.unref() + } + + // identity & rank + + get rank () { + return (this.record && this.server.ranks.get(this.record.rank)) || this.server.ranks.default + } + + get permission () { return this.rank.permission } + + get color () { return (this.record && this.record.color) || this.rank.color } + + get displayName () { + const r = this.record || {} + const title = r.title ? `${r.titleColor || this.color}[${r.title}${r.titleColor || this.color}] ` : '' + return `${this.rank.prefix || ''}${title}${this.color}${r.nick || this.name}` + } + + get coloredName () { return `${this.color}${(this.record && this.record.nick) || this.name}` } + + // CPE helpers + + supports (ext, version = 1) { + return (this.extensions.get(ext) || 0) >= version + } + + // login + + async _onIdentification (packet) { + if (this.name) return this.kick('Already identified') + const server = this.server + const cfg = server.config + + this.ip = cfg.trustProxy && this.conn.forwardedFor ? this.conn.forwardedFor : this.conn.socketIp + if (this.conn.isWebSocket && !cfg.allowWebClient) return this.kick('The web client is not allowed on this server') + if (packet.protocolVersion !== 7) return this.kick('Unsupported protocol version, please update your client') + if (!NAME_RE.test(packet.username) || RESERVED_NAMES.has(packet.username.toLowerCase())) return this.kick('Invalid username') + this.name = packet.username + + if (cfg.verifyNames && !this._isLocalIp()) { + const expected = crypto.createHash('md5').update(server.salt + this.name).digest('hex') + const key = packet.key.toLowerCase().replace(/^0+/, '') + if (expected.replace(/^0+/, '') !== key) { + server.log.info(`${this.name} (${this.ip}) failed name verification`) + return this.kick('Login failed! Close the game and sign in again.') + } + } + + const connecting = server.events.fire('playerConnecting', { name: this.name, ip: this.ip, player: this }) + if (connecting.cancelled) return this.kick(connecting.cancelReason || 'You are not allowed to join') + + this.record = server.playerDB.getOrCreate(this.name) + if (cfg.owners.some(o => o.toLowerCase() === this.name.toLowerCase()) && this.rank !== server.ranks.highest) { + this.record.rank = server.ranks.highest.name + } + if (this.permission < 0) return this.kick('You are banned from this server') + + const existing = server.findPlayerExact(this.name) + if (existing) existing.kick('Someone else logged in as you') + + const online = server.players.filter(p => p.loggedIn && p !== existing).length + if (online >= cfg.maxPlayers && this.permission < server.ranks.permissionOf('Operator')) { + return this.kick('The server is full, try again later') + } + + if (packet.padding === 0x42) await this._negotiateCPE() + this.conn.fullCP437 = this.supports('FullCP437') + // from now on these change the size of several packets in both directions + this.conn.extBlocks = this.supports('ExtendedBlocks') + this.conn.extPos = this.supports('ExtEntityPositions') + + if (this.supports('CustomBlocks')) { + this.conn.write('customBlockSupportLevel', { level: 1 }) + await this._waitFor('customBlockSupportLevel', 5000).then(p => { this.customBlocksLevel = p ? p.level : 0 }) + } + + clearTimeout(this._loginTimeout) + this.loggedIn = true + this.record.logins++ + this.record.lastLogin = Date.now() + this.record.lastIp = this.ip + this.record.name = this.name + if (!this.record.ips.includes(this.ip)) this.record.ips.push(this.ip) + server.playerDB.save() + + this._bindPackets() + server._addPlayer(this) + if (this.id === null) return + server.log.info(`${this.name} [${this.ip}${this.conn.isWebSocket ? ', web' : ''}] connected using ${this.appName}`) + + this._sendIdentification(server.levels.main) + this._sendTextColors() + for (const entry of server.customModels.values()) this.sendCustomModel(entry) + + const ok = this.changeLevel(server.levels.main, { initial: true }) + if (!ok) return + this._startPing() + server._onPlayerJoin(this) + } + + // Connections from this machine (without a proxy in between) skip name verification + _isLocalIp () { + const ip = this.conn.socketIp + return (ip === '127.0.0.1' || ip === '::1') && !this.conn.forwardedFor + } + + _waitFor (packetName, timeout) { + return new Promise(resolve => { + const timer = setTimeout(() => { this.conn.off(packetName, handler); resolve(null) }, timeout) + const handler = (p) => { clearTimeout(timer); resolve(p) } + this.conn.once(packetName, handler) + }) + } + + _negotiateCPE () { + this.cpe = true + const exts = Object.entries(EXTENSIONS) + this.conn.write('extInfo', { appName: `MCScript ${this.server.version}`, count: exts.length }) + for (const [extName, version] of exts) this.conn.write('extEntry', { extName, version }) + + return new Promise(resolve => { + let expected = null + const timer = setTimeout(done, 10000) + const onInfo = (p) => { + this.appName = p.appName + expected = p.count + if (expected === 0) done() + } + const onEntry = (p) => { + if (EXTENSIONS[p.extName] !== undefined) this.extensions.set(p.extName, Math.min(p.version, EXTENSIONS[p.extName])) + if (expected !== null && --expected <= 0) done() + } + const conn = this.conn + conn.on('extInfo', onInfo) + conn.on('extEntry', onEntry) + function done () { + clearTimeout(timer) + conn.off('extInfo', onInfo) + conn.off('extEntry', onEntry) + resolve() + } + }) + } + + _sendIdentification (level) { + const cfg = this.server.config + const motd = level && level.motd && level.motd !== 'ignore' ? level.motd : cfg.motd + this.conn.write('serverIdentification', { + protocolVersion: 7, + serverName: cfg.name, + motd, + userType: this.server.blockPerms.canDelete(this, 7) ? 0x64 : 0x00 + }) + } + + _sendTextColors () { + if (!this.supports('TextColors')) return + for (const [code, hex] of Object.entries(this.server.config.customColors || {})) { + const n = parseInt(String(hex).replace('#', ''), 16) + this.conn.write('setTextColor', { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, a: 255, code: code.charCodeAt(0) }) + } + } + + _startPing () { + this._pingTimer = setInterval(() => { + if (this.conn.closed) return clearInterval(this._pingTimer) + if (this.supports('TwoWayPing')) { + const id = (Math.random() * 30000) | 0 + this._pingSent.set(id, Date.now()) + this.conn.write('twoWayPing', { direction: 1, data: id }) + } else { + this.conn.write('ping') + } + }, 5000) + this._pingTimer.unref() + } + + _bindPackets () { + const conn = this.conn + conn.on('position', p => this._onPosition(p)) + conn.on('setBlock', p => this._onSetBlock(p)) + conn.on('message', p => this._onMessage(p)) + conn.on('playerClicked', p => this._onClick(p)) + conn.on('pluginMessage', p => { + this.server.events.fire('pluginMessage', { player: this, channel: p.channel, data: p.data }) + }) + conn.on('notifyAction', p => { + this.server.events.fire('notifyAction', { player: this, action: NOTIFY_ACTIONS[p.action] || p.action, value: p.value }) + }) + conn.on('notifyPositionAction', p => { + const action = p.action === 3 ? 'respawned' : p.action === 4 ? 'spawnUpdated' : p.action + this.server.events.fire('notifyAction', { player: this, action, position: { x: p.x, y: p.y, z: p.z } }) + }) + conn.on('twoWayPing', p => { + if (p.direction === 0) { + conn.write('twoWayPing', { direction: 0, data: p.data }) + } else if (this._pingSent.has(p.data)) { + this.pingMs = Date.now() - this._pingSent.get(p.data) + this._pingSent.delete(p.data) + } + }) + } + + // messages + + message (msg, type = 'chat') { + if (!this.loggedIn || this.conn.closed) return + const typeId = MESSAGE_TYPES[type] ?? type + if (typeId !== 0) { + if (!this.supports('MessageTypes')) { + if (typeId >= 100) this.message(msg) + return + } + this.conn.write('message', { type: typeId, message: text.sanitize(String(msg)).slice(0, 64) }) + return + } + for (const line of String(msg).split('\n')) { + for (const part of text.wrap(line)) this.conn.write('message', { type: 0, message: part }) + } + } + + kick (reason = 'Kicked') { + this.kickReason = reason + this.conn.kick(reason) + } + + // levels & spawning + + // Moves the player to another level. Returns false if prevented. + changeLevel (level, opts = {}) { + const server = this.server + const from = this.level + if (!opts.initial && !opts.force) { + if (from === level) { this.message('&cYou are already in that level.'); return false } + if (this.permission < server.ranks.permissionOf(level.visitRank)) { + this.message(`&cYou are not allowed to visit ${level.name}.`) + return false + } + if (level.needsExtPositions && !this.conn.extPos) { + this.message(`&c${level.name} is too big for your client (update to the latest ClassiCube).`) + return false + } + const ev = server.events.fire('playerChangeLevel', { player: this, from, to: level }) + if (ev.cancelled) { if (ev.cancelReason) this.message(ev.cancelReason); return false } + } + + if (from) { + for (const other of server.players) { + if (other === this || other.level !== from || !other.spawned) continue + other.hideEntity(this.id) + this.hideEntity(other.id) + } + for (const e of server.entitiesIn(from)) this.hideEntity(e.id) + } + + if (from && !opts.keepPosition) this.rememberPosition() + const at = opts.keepPosition ? { ...this.feetPos, yaw: this.yaw, pitch: this.pitch } : level.spawn + this.spawned = false + this.level = level + if (this.selection && from !== level) this.cancelSelection() + if (this.supports('InstantMOTD') && from) this._sendIdentification(level) + this._sendLevel(level) + this.spawn(at) + + server.tabList.update(this) + server.events.fire('playerSpawn', { player: this, level, from }) + if (from && !opts.silent && from !== level) { + server.broadcast(`${this.coloredName} &ewent to &f${level.name}`, p => p.level === level || p.level === from) + } + return true + } + + // Re-sends the current level keeping the player's position + reloadLevel () { + return this.changeLevel(this.level, { force: true, silent: true, keepPosition: true }) + } + + // Recalculates which block ids this client understands + _updateBlockTable () { + const level = this.level + const cpeBlocks = this.customBlocksLevel >= 1 + const defs = this.supports('BlockDefinitions') + const maxId = this.conn.extBlocks ? Blocks.MAX_BLOCK : 255 + for (let id = 0; id <= Blocks.MAX_BLOCK; id++) { + let b = id + if (id > Blocks.MAX_CPE) { + const def = level && level.getBlockDef(id) + b = def ? (defs && id <= maxId ? id : def.fallback) : 1 + } else if (id > Blocks.MAX_CLASSIC && !cpeBlocks) { + b = Blocks.CPE_FALLBACK[id] + } + if (b > Blocks.MAX_CLASSIC && b <= Blocks.MAX_CPE && !cpeBlocks) b = Blocks.CPE_FALLBACK[b] + this.blockTable[id] = b + } + } + + // Highest block id this client understands + get maxBlockId () { return this.conn.extBlocks ? Blocks.MAX_BLOCK : 255 } + + convertBlock (id) { return this.blockTable[id] } + + // Sends (or removes) custom block definitions so the client matches the current level + sendBlockDefinitions () { + if (!this.supports('BlockDefinitions') || !this.level) return + const useExt = this.supports('BlockDefinitionsExt', 2) + for (let id = 1; id <= this.maxBlockId; id++) { + const def = this.level.getBlockDef(id) + const key = def ? JSON.stringify(def) : null + if (this.sentBlockDefs.get(id) === key) continue + if (def) { + const [name, data] = Blocks.definitionPacket(def, useExt) + this.conn.write(name, data) + this.sentBlockDefs.set(id, key) + } else if (this.sentBlockDefs.has(id)) { + this.conn.write('removeBlockDefinition', { block: id }) + this.sentBlockDefs.delete(id) + } + } + this._updateBlockTable() + } + + _sendLevel (level) { + this.loadingLevel = true + this._updateBlockTable() + this.sendBlockDefinitions() + + const conn = this.conn + const fast = this.supports('FastMap') + const { lower, upper } = level.serializeForClient(this.blockTable) + const compress = data => fast ? zlib.deflateRawSync(data.subarray(4), { level: 5 }) : zlib.gzipSync(data, { level: 5 }) + if (fast) conn.write('levelInitializeFast', { volume: level.volume }) + else conn.write('levelInitialize') + + // With ExtendedBlocks the "percent" byte tells the client which array a chunk belongs to + // (0 = lower 8 bits, anything else = upper bits), so it can't be used for progress. + const sendArray = (data, isUpper) => { + const compressed = compress(data) + const total = compressed.length + for (let i = 0; i < total; i += 1024) { + const chunk = compressed.subarray(i, Math.min(i + 1024, total)) + const percent = conn.extBlocks ? (isUpper ? 1 : 0) : Math.min(100, Math.floor((i + chunk.length) / total * 100)) + conn.write('levelDataChunk', { length: chunk.length, data: chunk, percent }) + } + } + sendArray(lower, false) + if (upper && conn.extBlocks) sendArray(upper, true) + conn.write('levelFinalize', { x: level.width, y: level.height, z: level.length }) + this.loadingLevel = false + + this.sendEnv() + this.sendBlockPermissions() + } + + // Environment: colors, texture pack, weather and map properties + sendEnv () { + const level = this.level + if (!level) return + const env = level.resolvedEnv() + if (this.supports('EnvColors')) { + ENV_COLORS.forEach((key, variable) => { + const hex = env[key] + if (hex) { + const n = parseInt(hex, 16) + this.conn.write('envSetColor', { variable, r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }) + } else { + this.conn.write('envSetColor', { variable, r: -1, g: -1, b: -1 }) + } + }) + } + if (this.supports('EnvMapAspect')) { + const url = env.texture || this.server.config.defaultTexture || '' + this.conn.write('setMapEnvUrl', { url }) + for (const [key, type] of Object.entries(ENV_PROPS)) { + let value = env[key] + if (key === 'cloudsSpeed' || key === 'weatherSpeed') value = Math.round(value * 256) + else if (key === 'weatherFade') value = Math.round(value * 128) + else if (key === 'skyboxHorSpeed' || key === 'skyboxVerSpeed') value = Math.round(value * 1024) + else if (key === 'expFog') value = value ? 1 : 0 + else if (key === 'sideBlock' || key === 'edgeBlock') value = this.convertBlock(value) + this.conn.write('setMapEnvProperty', { type, value }) + } + } + if (this.supports('EnvWeatherType')) this.conn.write('envSetWeatherType', { weather: env.weather }) + if (this.supports('LightingMode') && env.lighting !== null && env.lighting !== undefined) { + this.conn.write('lightingMode', { mode: env.lighting, locked: 0 }) + } + } + + sendBlockPermissions () { + if (!this.supports('BlockPermissions') || !this.level) return + const canBuild = this.level.canBuild(this) + for (let id = 1; id <= this.maxBlockId; id++) { + if (id > Blocks.MAX_CPE && !this.level.getBlockDef(id)) continue + if (id > Blocks.MAX_CLASSIC && id <= Blocks.MAX_CPE && this.customBlocksLevel < 1) continue + this.conn.write('setBlockPermission', { + block: id, + allowPlace: canBuild && this.server.blockPerms.canPlace(this, id) ? 1 : 0, + allowDelete: canBuild && this.server.blockPerms.canDelete(this, id) ? 1 : 0 + }) + } + } + + // Spawns the player at a position given in blocks (feet position) + spawn (at) { + const server = this.server + const level = this.level + this.pos = { x: toUnits(at.x), y: toUnits(at.y) + EYE, z: toUnits(at.z) } + this.yaw = at.yaw || 0 + this.pitch = at.pitch || 0 + + this.showEntity({ ...this.entityInfo(), id: SELF }) + this.spawned = true + + for (const other of server.players) { + if (other === this || other.level !== level || !other.spawned) continue + if (!this.hidden) other.showEntity(this.entityInfo()) + if (!other.hidden) this.showEntity(other.entityInfo()) + } + for (const e of server.entitiesIn(level)) this.showEntity(e) + } + + entityInfo () { + const r = this.record || {} + return { + id: this.id, + name: this.displayName, + listName: this.name, + skin: r.skin || this.name, + model: this.currentModel || r.model || 'humanoid', + scale: this.data.modelScale || null, + rotation: this.data.entityRotation || null, + pos: this.pos, + yaw: this.yaw, + pitch: this.pitch + } + } + + // Shows an entity (another player or a bot) to this player + showEntity (e) { + if (e.id === null || e.id === undefined) return + const name = e.name.slice(0, 64) + if (this.supports('ExtPlayerList', 2)) { + this.conn.write('extAddEntity2', { id: e.id, entityName: name, skin: e.skin || e.listName || name, x: e.pos.x, y: e.pos.y, z: e.pos.z, yaw: e.yaw, pitch: e.pitch }) + } else { + this.conn.write('spawnPlayer', { id: e.id, entityName: name, x: e.pos.x, y: e.pos.y, z: e.pos.z, yaw: e.yaw, pitch: e.pitch }) + } + if (e.model && e.model !== 'humanoid' && this.supports('ChangeModel')) { + this.conn.write('changeModel', { id: e.id, model: e.model }) + } + if (e.scale && this.supports('EntityProperty')) { + for (const type of [3, 4, 5]) this.conn.write('setEntityProperty', { id: e.id, type, value: Math.round(e.scale * 1000) }) + } + if (e.rotation && this.supports('EntityProperty')) { + e.rotation.forEach((deg, type) => { if (deg) this.conn.write('setEntityProperty', { id: e.id, type, value: Math.round(deg) }) }) + } + } + + hideEntity (id) { + if (id === null || id === undefined) return + this.conn.write('despawnPlayer', { id }) + } + + // Teleports the player to a position in blocks (feet position) + // Saved before teleports and level changes so /back can return here + rememberPosition () { + if (!this.level || !this.spawned) return + this.previousPosition = { level: this.level.name, ...this.feetPos, yaw: this.yaw, pitch: this.pitch } + } + + teleport (x, y, z, yaw = this.yaw, pitch = this.pitch) { + this.rememberPosition() + this.pos = { x: toUnits(x), y: toUnits(y) + EYE, z: toUnits(z) } + this.yaw = yaw & 255 + this.pitch = pitch & 255 + this.conn.write('teleport', { id: SELF, ...this.pos, yaw: this.yaw, pitch: this.pitch }) + this._broadcastMovement(null, true) + } + + teleportTo (other) { + if (other.level !== this.level) { + if (!this.changeLevel(other.level)) return false + } else { + this.rememberPosition() + } + this.pos = { ...other.pos } + this.yaw = other.yaw + this.pitch = other.pitch + this.conn.write('teleport', { id: SELF, ...this.pos, yaw: this.yaw, pitch: this.pitch }) + this._broadcastMovement(null, true) + return true + } + + // Current position in whole blocks (feet) + get blockPos () { + return { + x: Math.floor(this.pos.x / 32), + y: Math.floor((this.pos.y - EYE) / 32), + z: Math.floor(this.pos.z / 32) + } + } + + // Exact feet position in blocks + get feetPos () { + return { x: this.pos.x / 32, y: (this.pos.y - EYE) / 32, z: this.pos.z / 32 } + } + + // persist = false for temporary changes (e.g. zombies in a minigame) + setModel (model, persist = true) { + if (persist) { + this.record.model = model === 'humanoid' ? null : model + this.server.playerDB.save() + } + this.currentModel = model + for (const p of this.server.players) { + if (p.level !== this.level || !p.spawned || !p.supports('ChangeModel')) continue + p.conn.write('changeModel', { id: p === this ? SELF : this.id, model }) + } + } + + setSkin (skin) { + this.record.skin = skin || null + this.server.playerDB.save() + this.respawnForOthers() + } + + // Re-sends this player's entity to everyone else (after name/skin changes) + respawnForOthers () { + for (const p of this.server.players) { + if (p === this || p.level !== this.level || !p.spawned) continue + p.hideEntity(this.id) + if (!this.hidden) p.showEntity(this.entityInfo()) + } + this.server.tabList.update(this) + } + + // CPE features + + holdBlock (block, lock = false) { + if (this.supports('HeldBlock')) this.conn.write('holdThis', { block: this.convertBlock(block), preventChange: lock ? 1 : 0 }) + } + + setHotbar (block, index) { + if (this.supports('SetHotbar')) this.conn.write('setHotbar', { block: this.convertBlock(block), index }) + } + + setReach (blocks) { + if (this.supports('ClickDistance')) this.conn.write('setClickDistance', { distance: Math.round(blocks * 32) }) + this.data.reach = blocks + } + + setHacks ({ flying = true, noClip = true, speeding = true, spawnControl = true, thirdPerson = true, jumpHeight = -1 } = {}) { + if (!this.supports('HackControl')) return + const b = v => v ? 1 : 0 + this.conn.write('hackControl', { flying: b(flying), noClip: b(noClip), speeding: b(speeding), spawnControl: b(spawnControl), thirdPerson: b(thirdPerson), jumpHeight }) + } + + setVelocity (x, y, z, { addX = false, addY = false, addZ = false } = {}) { + if (!this.supports('VelocityControl')) return false + this.conn.write('velocityControl', { + x: Math.round(x * 10000), + y: Math.round(y * 10000), + z: Math.round(z * 10000), + modeX: addX ? 0 : 1, + modeY: addY ? 0 : 1, + modeZ: addZ ? 0 : 1 + }) + return true + } + + setSpawnpoint (x, y, z, yaw = 0, pitch = 0) { + if (this.supports('SetSpawnpoint')) this.conn.write('setSpawnpoint', { x: toUnits(x), y: toUnits(y) + EYE, z: toUnits(z), yaw, pitch }) + } + + // Draws a translucent box (SelectionCuboid). Coordinates are block corners (inclusive). + showSelection (id, label, p1, p2, color = [255, 255, 255, 100]) { + if (!this.supports('SelectionCuboid')) return false + this.conn.write('makeSelection', { + id, + label, + x1: Math.min(p1.x, p2.x), + y1: Math.min(p1.y, p2.y), + z1: Math.min(p1.z, p2.z), + x2: Math.max(p1.x, p2.x) + 1, + y2: Math.max(p1.y, p2.y) + 1, + z2: Math.max(p1.z, p2.z) + 1, + r: color[0], + g: color[1], + b: color[2], + a: color[3] ?? 100 + }) + return true + } + + hideSelection (id) { + if (this.supports('SelectionCuboid')) this.conn.write('removeSelection', { id }) + } + + sendCustomModel (entry) { + if (!this.supports('CustomModels', 2)) return + for (const [name, data] of this.server._modelPackets(entry)) this.conn.write(name, data) + } + + // Custom data for client plugins (PluginMessages): up to 64 bytes per message + sendPluginMessage (channel, data) { + if (!this.supports('PluginMessages')) return false + const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data)) + this.conn.write('pluginMessage', { channel, data: buf.subarray(0, 64) }) + return true + } + + // Cinematic bars and hiding parts of the HUD (CinematicGui). barSize from 0 to 1. + setCinematic ({ hideCrosshair = false, hideHand = false, hideHotbar = false, color = [0, 0, 0, 255], barSize = 0 } = {}) { + if (!this.supports('CinematicGui')) return false + this.conn.write('cinematicGui', { + hideCrosshair: hideCrosshair ? 1 : 0, + hideHand: hideHand ? 1 : 0, + hideHotbar: hideHotbar ? 1 : 0, + r: color[0], + g: color[1], + b: color[2], + a: color[3] ?? 255, + barSize: Math.round(Math.max(0, Math.min(1, barSize)) * 65535) + }) + return true + } + + // Opens or closes the block inventory (ToggleBlockList) + toggleBlockList (open = true) { + if (!this.supports('ToggleBlockList')) return false + this.conn.write('toggleBlockList', { close: open ? 0 : 1 }) + return true + } + + setTextHotKey (label, action, keyCode, keyMods = 0) { + if (this.supports('TextHotKey')) this.conn.write('setTextHotKey', { label, action, keyCode, keyMods }) + } + + // Ask the player to mark `count` blocks (placing or breaking). Resolves with [{x, y, z, block}] + selectBlocks (count, label = 'Selection') { + if (this.selection) this.selection.reject(new Error('Selection replaced')) + return new Promise((resolve, reject) => { + this.selection = { count, marks: [], label, resolve, reject } + this.message(`&ePlace or break &f${count}&e block${count > 1 ? 's' : ''} to mark ${count > 1 ? 'the corners' : 'the position'}. &7(/abort to cancel)`) + }) + } + + // Adds a position to the current selection (clicking a block, or /mark) + addMark (x, y, z, block = this.level.getBlock(x, y, z)) { + const sel = this.selection + if (!sel) return false + sel.marks.push({ x, y, z, block }) + if (sel.marks.length === 1 && sel.count > 1) { + this.showSelection(250, sel.label, sel.marks[0], sel.marks[0], [255, 255, 0, 120]) + this.message(`&eMarked &f(${x}, ${y}, ${z})&e. Mark the second position.`, 'status3') + } + if (sel.marks.length >= sel.count) { + this.selection = null + this.hideSelection(250) + this.message('', 'status3') + sel.resolve(sel.marks) + } + return true + } + + cancelSelection () { + if (!this.selection) return false + const sel = this.selection + this.selection = null + this.hideSelection(250) + sel.reject(Object.assign(new Error('Selection cancelled'), { cancelled: true })) + return true + } + + // incoming packets + + _onPosition (p) { + if (!this.spawned || this.loadingLevel) return + this.lastActivity = Date.now() + if (this.supports('HeldBlock')) this.heldBlock = p.heldBlock + + const from = this.pos + const to = { x: p.x, y: p.y, z: p.z } + const moved = from.x !== to.x || from.y !== to.y || from.z !== to.z + const rotated = this.yaw !== p.yaw || this.pitch !== p.pitch + if (!moved && !rotated) return + + if (this.frozen && moved) { + this.conn.write('teleport', { id: SELF, ...from, yaw: p.yaw, pitch: p.pitch }) + return + } + + if (moved) { + const level = this.level + const ev = this.server.events.fire('playerMove', { player: this, from, to, yaw: p.yaw, pitch: p.pitch }) + if (ev.cancelled) { + this.conn.write('teleport', { id: SELF, ...from, yaw: p.yaw, pitch: p.pitch }) + return + } + if (this.level !== level || this.pos !== from) return // a handler teleported the player + } + const old = this.pos + this.pos = to + this.yaw = p.yaw + this.pitch = p.pitch + this._broadcastMovement(old, false) + } + + _broadcastMovement (old, teleport) { + if (this.hidden || !this.spawned) return + const pos = this.pos + let packet + if (!teleport && old) { + const dx = pos.x - old.x; const dy = pos.y - old.y; const dz = pos.z - old.z + const fits = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)) <= 127 + if (fits) packet = ['posAndOrientUpdate', { id: this.id, dx, dy, dz, yaw: this.yaw, pitch: this.pitch }] + } + if (!packet) packet = ['teleport', { id: this.id, ...pos, yaw: this.yaw, pitch: this.pitch }] + for (const other of this.server.players) { + if (other === this || other.level !== this.level || !other.spawned) continue + other.conn.write(packet[0], packet[1]) + } + } + + _revertBlock (x, y, z) { + if (this.level && this.level.inBounds(x, y, z)) { + this.conn.write('setBlock', { x, y, z, block: this.convertBlock(this.level.getBlock(x, y, z)) }) + } + } + + _onSetBlock (p) { + if (!this.spawned || this.loadingLevel) return + this.lastActivity = Date.now() + const { x, y, z } = p + const level = this.level + const server = this.server + if (!level.inBounds(x, y, z)) return + + const placing = p.mode === 1 + const oldBlock = level.getBlock(x, y, z) + let block = placing ? p.block : 0 + + // selection mode (used by /cuboid, /zone, ...) + if (this.selection) { + this._revertBlock(x, y, z) + this.addMark(x, y, z, placing ? p.block : oldBlock) + return + } + + // distance check (reach + tolerance) to stop hacked clients from building far away + const eye = { x: this.pos.x / 32, y: (this.pos.y) / 32, z: this.pos.z / 32 } + const reach = (this.data.reach || server.config.maxClickDistance) + 4 + if (Math.hypot(eye.x - x - 0.5, eye.y - y - 0.5, eye.z - z - 0.5) > reach) { + return this._revertBlock(x, y, z) + } + + if (placing && !level.isValidBlock(block)) return this._revertBlock(x, y, z) + + // Plugins (e.g. minigames) may override the permission decision through 'blockPermission' + let denied = null + if (!level.canBuild(this)) denied = `&cYou are not allowed to build in ${level.name}.` + else if (placing ? !server.blockPerms.canPlace(this, block) : !server.blockPerms.canDelete(this, oldBlock)) { + denied = `&cYou can't ${placing ? 'place' : 'delete'} ${level.blockName(placing ? block : oldBlock)}.` + } + const perm = server.events.fire('blockPermission', { player: this, level, x, y, z, oldBlock, block, placing, allowed: !denied, message: denied }) + if (!perm.allowed || perm.cancelled) { + if (perm.message) this._throttledMessage('blockperm', perm.message) + return this._revertBlock(x, y, z) + } + + const ev = server.events.fire('blockChange', { player: this, level, x, y, z, oldBlock, block, placing }) + if (ev.cancelled) { + if (ev.cancelReason) this._throttledMessage('blockcancel', ev.cancelReason) + return this._revertBlock(x, y, z) + } + block = ev.block + + // stacking slabs creates a double slab, like classic servers + if (placing && (block === 44 || block === 50) && y > 0 && level.getBlock(x, y - 1, z) === block) { + this._revertBlock(x, y, z) + level.setBlock(x, y - 1, z, block === 44 ? 43 : 4) + } else if (!level.setBlock(x, y, z, block)) { + this._revertBlock(x, y, z) + } + + if (placing) this.record.blocksPlaced++ + else this.record.blocksDeleted++ + } + + _throttledMessage (key, msg) { + const now = Date.now() + this._throttle = this._throttle || {} + if ((this._throttle[key] || 0) > now) return + this._throttle[key] = now + 2000 + this.message(msg) + } + + _onMessage (p) { + this.lastActivity = Date.now() + let msg = p.message + if (p.partial === 1 && this.supports('LongerMessages')) { + if (this.partialMessage.length < 2048) this.partialMessage += msg + return + } + if (this.partialMessage) { msg = this.partialMessage + msg; this.partialMessage = '' } + msg = msg.trim() + if (!msg) return + + if (msg.startsWith('/') && !msg.startsWith('//')) { + this.server.commands.execute(this, msg) + return + } + if (msg.startsWith('//')) msg = msg.slice(1) + this.chat(msg) + } + + // Sends a chat message as this player (applies mute, colors and chat events) + chat (msg) { + const server = this.server + const r = this.record + if (r.muteUntil && r.muteUntil > Date.now()) { + return this._throttledMessage('mute', `&cYou are muted for another ${text.formatDuration(r.muteUntil - Date.now())}.`) + } + if (this.permission >= server.ranks.permissionOf(server.config.chatColorsRank)) msg = text.convertPercentCodes(msg) + msg = text.sanitize(msg) + + const ev = server.events.fire('playerChat', { player: this, message: msg, format: null }) + if (ev.cancelled) { + if (ev.cancelReason) this.message(ev.cancelReason) + return + } + r.messages++ + const line = ev.format || `${this.displayName}&f: ${ev.message}` + server.log.info(`<${this.name}> ${ev.message}`) + for (const p of server.players) if (p.loggedIn) p.message(line) + } + + _onClick (p) { + if (!this.spawned) return + this.server.events.fire('playerClick', { + player: this, + button: ['left', 'right', 'middle'][p.button] || p.button, + action: p.action === 0 ? 'press' : 'release', + yaw: p.yaw, + pitch: p.pitch, + targetEntity: p.targetEntity, + target: p.x === -1 && p.y === -1 && p.z === -1 ? null : { x: p.x, y: p.y, z: p.z, face: p.face } + }) + } +} + +module.exports = { Player, MESSAGE_TYPES, EYE, toUnits } diff --git a/lib/plugins/installer.js b/lib/plugins/installer.js new file mode 100644 index 0000000..3bfb59c --- /dev/null +++ b/lib/plugins/installer.js @@ -0,0 +1,124 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const { execFile } = require('child_process') + +const MAX_SIZE = 2 * 1024 * 1024 +const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/ +// "package", "@scope/package", optionally with "@version" or "@tag" +const NPM_SPEC_RE = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*(@[A-Za-z0-9._~^<>=*|+-]+)?$/ + +// GitHub page URLs -> raw file URLs +function rawUrl (url) { + const m = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/) + if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}` + const g = url.match(/^https:\/\/gist\.github\.com\/([^/]+)\/([0-9a-f]+)\/?$/) + if (g) return `https://gist.githubusercontent.com/${g[1]}/${g[2]}/raw` + return url +} + +// Reads a response body, giving up as soon as it goes over `max` bytes +async function readLimited (res, max) { + if (!res.body || !res.body.getReader) { + const text = await res.text() + if (Buffer.byteLength(text) > max) throw new Error('The file is too big (max 2 MB)') + return text + } + const reader = res.body.getReader() + const chunks = [] + let size = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + size += value.length + if (size > max) { + reader.cancel().catch(() => {}) + throw new Error('The file is too big (max 2 MB)') + } + chunks.push(Buffer.from(value)) + } + return Buffer.concat(chunks).toString('utf8') +} + +// Downloads a single-file plugin into plugins/.js. Returns the plugin name. +async function installFromUrl (pluginsDir, url, { fetchImpl = fetch } = {}) { + // plain http would let anyone on the network path swap the code (fine for this machine only) + if (!/^https:\/\//i.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?\//i.test(url)) { + throw new Error('The URL must start with https://') + } + const src = rawUrl(url) + const res = await fetchImpl(src, { signal: AbortSignal.timeout(20000) }) + if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`) + const len = Number(res.headers.get('content-length') || 0) + if (len > MAX_SIZE) throw new Error('The file is too big (max 2 MB)') + const code = await readLimited(res, MAX_SIZE) + if (!/module\.exports|exports\./.test(code)) throw new Error('That does not look like a MCScript plugin (no module.exports)') + + const declared = code.match(/name\s*:\s*['"]([A-Za-z0-9_.-]+)['"]/) + const fromUrl = path.basename(new URL(src).pathname).replace(/\.js$/i, '') + const name = declared ? declared[1] : fromUrl + if (!NAME_RE.test(name)) throw new Error(`Invalid plugin name "${name}"`) + const file = path.join(pluginsDir, `${name}.js`) + if (fs.existsSync(file) || fs.existsSync(path.join(pluginsDir, name))) throw new Error(`A plugin named ${name} is already installed (use /puninstall first)`) + fs.mkdirSync(pluginsDir, { recursive: true }) + fs.writeFileSync(file, code) + return name +} + +// Installs an npm package as plugins// (with its own node_modules). Lifecycle scripts are not run. +function installFromNpm (pluginsDir, spec, { npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' } = {}) { + return new Promise((resolve, reject) => { + // the spec ends up on the npm command line, so only accept plain package names (or file: paths) + if (spec.startsWith('file:') ? /[\s"'`$&|;<>^%]/.test(spec) : !NPM_SPEC_RE.test(spec)) { + return reject(new Error(`Invalid package "${spec}"`)) + } + const pkgName = spec.startsWith('file:') ? null : spec.replace(/^(@[^/]+\/[^@]+|[^@]+).*$/, '$1') + let name = pkgName ? pkgName.replace(/^@/, '').replace(/\//g, '-') : path.basename(spec.slice(5)) + name = name.replace(/^mcscript-plugin-/, '') + if (!NAME_RE.test(name)) return reject(new Error(`Invalid package name "${spec}"`)) + const dir = path.join(pluginsDir, name) + if (fs.existsSync(dir) || fs.existsSync(dir + '.js')) return reject(new Error(`A plugin named ${name} is already installed (use /puninstall first)`)) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: `mcscript-plugin-wrapper-${name}`, private: true }, null, 2)) + + const args = ['install', spec, '--ignore-scripts', '--no-audit', '--no-fund', '--omit=dev', '--prefix', dir] + execFile(npm, args, { timeout: 180000, shell: process.platform === 'win32' }, (err, stdout, stderr) => { + if (err) { + fs.rmSync(dir, { recursive: true, force: true }) + return reject(new Error(`npm install failed: ${(stderr || err.message).trim().split('\n').pop()}`)) + } + // find the installed package name (for file: specs we read it from package.json) + const deps = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).dependencies || {} + const installed = Object.keys(deps)[0] + fs.writeFileSync(path.join(dir, 'index.js'), `'use strict'\n\n// Installed with /pinstall npm:${spec}\nmodule.exports = require(${JSON.stringify(installed)})\n`) + // use the name the plugin declares for its folder, so /plugins and /punload agree + let declared = null + try { + const main = require.resolve(installed, { paths: [dir] }) + const m = fs.readFileSync(main, 'utf8').match(/name\s*:\s*['"]([A-Za-z0-9_.-]+)['"]/) + if (m) declared = m[1] + } catch (e) {} + if (declared && declared !== name && NAME_RE.test(declared) && !fs.existsSync(path.join(pluginsDir, declared)) && !fs.existsSync(path.join(pluginsDir, declared + '.js'))) { + fs.renameSync(dir, path.join(pluginsDir, declared)) + name = declared + } + resolve(name) + }) + }) +} + +// Moves a plugin out of the plugins folder (to plugins/.removed) instead of deleting it +function uninstall (pluginsDir, name) { + if (!NAME_RE.test(name)) throw new Error(`Invalid plugin name "${name}"`) + const candidates = [path.join(pluginsDir, name), path.join(pluginsDir, `${name}.js`)] + const target = candidates.find(p => fs.existsSync(p)) + if (!target) throw new Error(`Plugin ${name} is not installed`) + const removed = path.join(pluginsDir, '.removed') + fs.mkdirSync(removed, { recursive: true }) + const dest = path.join(removed, `${path.basename(target)}-${Date.now()}`) + fs.renameSync(target, dest) + return dest +} + +module.exports = { installFromUrl, installFromNpm, uninstall, rawUrl } diff --git a/lib/plugins/manager.js b/lib/plugins/manager.js new file mode 100644 index 0000000..74d5be5 --- /dev/null +++ b/lib/plugins/manager.js @@ -0,0 +1,275 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const { CommandError } = require('../commands/manager') +const text = require('../util/text') +const Blocks = require('../blocks') + +const API_VERSION = 1 + +// Loads plugins from the plugins/ folder. A plugin is either plugins/.js or plugins//index.js +// and exports: { name, version, description, author, depends, defaultConfig, load(ctx), unload(ctx), api } +class PluginManager { + constructor (server, opts) { + this.server = server + this.dir = opts.dir + this.configDir = opts.configDir + this.dataDir = opts.dataDir + this.disabled = new Set((opts.disabled || []).map(n => n.toLowerCase())) + this.plugins = new Map() // name(lower) -> { module, ctx, file } + } + + // name -> file path of every plugin found on disk + discover () { + const found = new Map() + if (!fs.existsSync(this.dir)) return found + for (const entry of fs.readdirSync(this.dir, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue + if (entry.isDirectory()) { + const index = path.join(this.dir, entry.name, 'index.js') + if (fs.existsSync(index)) found.set(entry.name.toLowerCase(), index) + } else if (entry.name.endsWith('.js')) { + found.set(entry.name.slice(0, -3).toLowerCase(), path.join(this.dir, entry.name)) + } + } + return found + } + + get (name) { + const p = this.plugins.get(String(name).toLowerCase()) + return p || null + } + + list () { return [...this.plugins.values()] } + + loadAll () { + const found = this.discover() + const modules = new Map() + for (const [name, file] of found) { + if (this.disabled.has(name)) continue + try { + const mod = this._require(file) + if (!this._isValid(mod)) { + this.server.log.warn(`Ignoring ${path.relative(process.cwd(), file)}: old MCScript 1.x plugin format (see docs/PLUGINS.md)`) + continue + } + modules.set((mod.name || name).toLowerCase(), { mod, file }) + } catch (err) { + this.server.log.error(`Failed to read plugin ${name}:`, err) + } + } + + // load respecting dependencies + const loading = new Set() + const visit = (name) => { + if (this.plugins.has(name) || loading.has(name)) return + const entry = modules.get(name) + if (!entry) return + loading.add(name) + for (const dep of entry.mod.depends || []) { + if (!modules.has(dep.toLowerCase()) && !this.plugins.has(dep.toLowerCase())) { + this.server.log.error(`Plugin ${entry.mod.name} needs "${dep}" which is not installed`) + return + } + visit(dep.toLowerCase()) + } + try { + this._activate(entry.mod, entry.file) + } catch (err) { + // already logged; a broken plugin must not stop the server from starting + } + } + for (const name of [...modules.keys()].sort()) visit(name) + } + + _require (file) { + // clear cached copies of the plugin's files so reloads pick up changes + const base = path.dirname(file) === path.resolve(this.dir) ? file : path.dirname(file) + for (const key of Object.keys(require.cache)) { + if (key === file || key.startsWith(base + path.sep)) delete require.cache[key] + } + return require(file) + } + + _isValid (mod) { + return mod && typeof mod === 'object' && typeof mod.load === 'function' + } + + load (name) { + const lower = String(name).toLowerCase() + if (this.plugins.has(lower)) throw new Error(`Plugin ${name} is already loaded`) + const file = this.discover().get(lower) + if (!file) throw new Error(`Plugin ${name} not found in ${this.dir}`) + const mod = this._require(file) + if (!this._isValid(mod)) throw new Error(`${name} is not a valid plugin (it must export a load(ctx) function)`) + for (const dep of mod.depends || []) { + if (!this.plugins.has(dep.toLowerCase())) this.load(dep) + } + return this._activate(mod, file) + } + + _activate (mod, file) { + const name = mod.name || path.basename(file, '.js') + if (mod.apiVersion && mod.apiVersion > API_VERSION) { + this.server.log.warn(`Plugin ${name} targets plugin API v${mod.apiVersion}, this server supports v${API_VERSION}`) + } + const ctx = this._createContext(name, mod, file) + const entry = { name, module: mod, ctx, file, loadedAt: Date.now() } + this.plugins.set(name.toLowerCase(), entry) + try { + mod.load(ctx) + this.server.log.info(`Plugin &a${name}&f ${mod.version ? 'v' + mod.version + ' ' : ''}loaded`) + this.server.events.fire('pluginLoad', { plugin: entry }) + return entry + } catch (err) { + this.server.log.error(`Plugin ${name} failed to load:`, err) + this._cleanup(entry) + this.plugins.delete(name.toLowerCase()) + throw err + } + } + + unload (name) { + const entry = this.get(name) + if (!entry) throw new Error(`Plugin ${name} is not loaded`) + const dependents = this.list().filter(p => (p.module.depends || []).some(d => d.toLowerCase() === entry.name.toLowerCase())) + for (const dep of dependents) this.unload(dep.name) + try { + if (typeof entry.module.unload === 'function') entry.module.unload(entry.ctx) + } catch (err) { + this.server.log.error(`Plugin ${entry.name} threw while unloading:`, err) + } + this._cleanup(entry) + this.plugins.delete(entry.name.toLowerCase()) + this.server.events.fire('pluginUnload', { plugin: entry }) + this.server.log.info(`Plugin ${entry.name} unloaded`) + } + + isDisabled (name) { return this.disabled.has(String(name).toLowerCase()) } + + // Turns a plugin off now and keeps it off after restarts (config.disabledPlugins) + disable (name) { + const lower = String(name).toLowerCase() + if (!this.discover().has(lower) && !this.plugins.has(lower)) throw new Error(`Plugin ${name} not found`) + if (this.plugins.has(lower)) this.unload(lower) + this.disabled.add(lower) + this._persistDisabled() + } + + enable (name) { + const lower = String(name).toLowerCase() + if (!this.discover().has(lower)) throw new Error(`Plugin ${name} not found in ${this.dir}`) + this.disabled.delete(lower) + this._persistDisabled() + return this.plugins.get(lower) || this.load(lower) + } + + _persistDisabled () { + const cfg = this.server.config + cfg.disabledPlugins = [...this.disabled].sort() + if (cfg.file) this.server.saveConfig() + } + + reload (name) { + const entry = this.get(name) + const realName = entry ? entry.name : name + if (entry) this.unload(realName) + return this.load(realName) + } + + unloadAll () { + for (const entry of this.list().reverse()) { + try { this.unload(entry.name) } catch (err) {} + } + } + + _cleanup (entry) { + const owner = entry.name + this.server.events.removeOwner(owner) + this.server.commands.removeOwner(owner) + for (const t of entry.ctx._timers) { clearTimeout(t); clearInterval(t) } + for (const fn of entry.ctx._disposers.reverse()) { + try { fn() } catch (err) { this.server.log.error(`Cleanup error in ${owner}:`, err) } + } + entry.ctx._timers.clear() + entry.ctx._disposers.length = 0 + } + + _createContext (name, mod, file) { + const server = this.server + const manager = this + const configFile = path.join(this.configDir, `${name}.json`) + const dataDir = path.join(this.dataDir, name) + + let config = JSON.parse(JSON.stringify(mod.defaultConfig || {})) + if (fs.existsSync(configFile)) { + try { + config = { ...config, ...JSON.parse(fs.readFileSync(configFile, 'utf8')) } + } catch (err) { + server.log.error(`Invalid config file ${configFile}:`, err.message) + } + } else if (mod.defaultConfig) { + fs.mkdirSync(this.configDir, { recursive: true }) + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)) + } + + const ctx = { + name, + server, + log: server.log.child(name), + config, + dir: path.dirname(file), + dataDir, + text, + Blocks, + CommandError, + _timers: new Set(), + _disposers: [], + + saveConfig () { + fs.mkdirSync(manager.configDir, { recursive: true }) + fs.writeFileSync(configFile, JSON.stringify(ctx.config, null, 2)) + }, + // Objects come back without a prototype, so user supplied keys ("constructor", "__proto__"...) are safe + loadData (fileName, defaults = {}) { + const f = path.join(dataDir, fileName) + const json = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : JSON.stringify(defaults) + return JSON.parse(json, (key, value) => value && typeof value === 'object' && !Array.isArray(value) + ? Object.assign(Object.create(null), value) + : value) + }, + saveData (fileName, data) { + fs.mkdirSync(dataDir, { recursive: true }) + const f = path.join(dataDir, fileName) + fs.writeFileSync(f + '.tmp', JSON.stringify(data, null, 2)) + fs.renameSync(f + '.tmp', f) + }, + on (event, handler, opts = {}) { + return server.events.on(event, handler, { ...opts, owner: name }) + }, + command (def) { + return server.commands.register({ ...def, owner: name }) + }, + setInterval (fn, ms) { + const t = setInterval(() => { try { fn() } catch (err) { ctx.log.error(err) } }, ms) + ctx._timers.add(t) + return t + }, + setTimeout (fn, ms) { + const t = setTimeout(() => { ctx._timers.delete(t); try { fn() } catch (err) { ctx.log.error(err) } }, ms) + ctx._timers.add(t) + return t + }, + clearTimer (t) { clearTimeout(t); clearInterval(t); ctx._timers.delete(t) }, + onUnload (fn) { ctx._disposers.push(fn) }, + getPlugin (other) { + const p = manager.get(other) + return p ? p.module.api || p.module : null + } + } + return ctx + } +} + +module.exports = { PluginManager, API_VERSION } diff --git a/lib/plugins/template.js b/lib/plugins/template.js new file mode 100644 index 0000000..4f0047e --- /dev/null +++ b/lib/plugins/template.js @@ -0,0 +1,50 @@ +'use strict' + +// Source for a new plugin, used by /pcreate and `npm run plugin:create ` +module.exports = function pluginTemplate (name, author = 'unknown') { + return `'use strict' + +// ${name} - created with MCScript's plugin generator. +// Documentation: docs/PLUGINS.md + +module.exports = { + name: '${name}', + version: '1.0.0', + description: 'Describe what ${name} does', + author: '${author}', + + // Saved to config/plugins/${name}.json the first time the plugin loads + defaultConfig: { + greeting: '&aHello from ${name}!' + }, + + load (ctx) { + const { server, config, log } = ctx + + // Commands are removed automatically when the plugin is unloaded + ctx.command({ + name: '${name.toLowerCase()}', + description: 'Example command of ${name}', + usage: '/${name.toLowerCase()} [player]', + rank: 'Guest', + run (player, args) { + const target = args[0] ? server.findPlayer(args[0], player) : player + if (!target) return + target.message(config.greeting) + } + }) + + // Events are also cleaned up automatically. Use ev.cancel() to prevent the action. + ctx.on('playerJoin', ({ player }) => { + log.info(\`\${player.name} joined\`) + }) + + log.info('Enabled!') + }, + + unload (ctx) { + ctx.log.info('Disabled!') + } +} +` +} diff --git a/lib/protocol/cp437.js b/lib/protocol/cp437.js new file mode 100644 index 0000000..3e00669 --- /dev/null +++ b/lib/protocol/cp437.js @@ -0,0 +1,48 @@ +'use strict' + +// Code page 437 <-> Unicode. Classic strings are 64 bytes of CP437 padded with spaces. +// Clients without the FullCP437 extension only understand printable ASCII (0x20-0x7E). + +const LOW = '\u0000☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼' +const HIGH = + 'ÇüéâäàåçêëèïîìÄÅ' + + 'ÉæÆôöòûùÿÖÜ¢£¥₧ƒ' + + 'áíóúñѪº¿⌐¬½¼¡«»' + + '░▒▓│┤╡╢╖╕╣║╗╝╜╛┐' + + '└┴┬├─┼╞╟╚╔╩╦╠═╬╧' + + '╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀' + + 'αßΓπΣσµτΦΘΩδ∞φε∩' + + '≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ' + +const byteToChar = new Array(256) +const charToByte = new Map() + +for (let i = 0; i < 256; i++) { + let ch + if (i < 0x20) ch = LOW[i] + else if (i < 0x7F) ch = String.fromCharCode(i) + else if (i === 0x7F) ch = '⌂' + else ch = HIGH[i - 0x80] + byteToChar[i] = ch + if (i !== 0) charToByte.set(ch, i) +} + +function encode (str, fullCP437 = true) { + const out = [] + for (const ch of String(str)) { + const code = ch.codePointAt(0) + if (code >= 0x20 && code < 0x7F) { out.push(code); continue } + const b = charToByte.get(ch) + if (b !== undefined && fullCP437) out.push(b) + else out.push(0x3F) // '?' + } + return out +} + +function decode (bytes) { + let s = '' + for (const b of bytes) s += byteToChar[b] + return s +} + +module.exports = { encode, decode, LOW, HIGH } diff --git a/lib/protocol/packets.js b/lib/protocol/packets.js new file mode 100644 index 0000000..6307042 --- /dev/null +++ b/lib/protocol/packets.js @@ -0,0 +1,240 @@ +'use strict' + +// Classic protocol 7 + CPE (Classic Protocol Extension) packet definitions. +// Sizes match the ClassiCube client (src/Protocol.c) for the extension versions we advertise. + +const cp437 = require('./cp437') + +const TYPE_SIZES = { u8: 1, i8: 1, u16: 2, i16: 2, i32: 4, f32: 4, str: 64 } + +// Some field types depend on the extensions negotiated with the client: +// blk - a block id: 1 byte, or 2 bytes with ExtendedBlocks +// pos - an entity coordinate: 2 bytes, or 4 bytes with ExtEntityPositions +// xbytes:N - N extra bytes that only exist with ExtendedBlocks +function typeSize (type, opts = NO_OPTS) { + if (type === 'blk') return opts.extBlocks ? 2 : 1 + if (type === 'pos') return opts.extPos ? 4 : 2 + if (type.startsWith('bytes:')) return Number(type.slice(6)) + if (type.startsWith('xbytes:')) return opts.extBlocks ? Number(type.slice(7)) : 0 + return TYPE_SIZES[type] +} + +const NO_OPTS = { extBlocks: false, extPos: false } +const VARIANTS = [ + { extBlocks: false, extPos: false }, + { extBlocks: true, extPos: false }, + { extBlocks: false, extPos: true }, + { extBlocks: true, extPos: true } +] +const variantIndex = opts => (opts.extBlocks ? 1 : 0) + (opts.extPos ? 2 : 0) + +function def (id, fields) { + const parsed = fields.map(f => { + const [name, type] = f.split(':') + return [name, type === 'bytes' || type === 'xbytes' ? f.slice(name.length + 1) : type] + }) + const sizes = VARIANTS.map(v => 1 + parsed.reduce((n, [, t]) => n + typeSize(t, v), 0)) + return { id, fields: parsed, size: sizes[0], sizes } +} + +// Size of a packet for a connection with the given extension flags +function sizeOf (packet, opts = NO_OPTS) { + return packet.sizes[variantIndex(opts)] +} + +// Packets sent by the server +const SERVER = { + serverIdentification: def(0x00, ['protocolVersion:u8', 'serverName:str', 'motd:str', 'userType:u8']), + ping: def(0x01, []), + levelInitialize: def(0x02, []), + levelInitializeFast: def(0x02, ['volume:i32']), + levelDataChunk: def(0x03, ['length:i16', 'data:bytes:1024', 'percent:u8']), + levelFinalize: def(0x04, ['x:i16', 'y:i16', 'z:i16']), + setBlock: def(0x06, ['x:i16', 'y:i16', 'z:i16', 'block:blk']), + spawnPlayer: def(0x07, ['id:i8', 'entityName:str', 'x:pos', 'y:pos', 'z:pos', 'yaw:u8', 'pitch:u8']), + teleport: def(0x08, ['id:i8', 'x:pos', 'y:pos', 'z:pos', 'yaw:u8', 'pitch:u8']), + posAndOrientUpdate: def(0x09, ['id:i8', 'dx:i8', 'dy:i8', 'dz:i8', 'yaw:u8', 'pitch:u8']), + posUpdate: def(0x0a, ['id:i8', 'dx:i8', 'dy:i8', 'dz:i8']), + orientUpdate: def(0x0b, ['id:i8', 'yaw:u8', 'pitch:u8']), + despawnPlayer: def(0x0c, ['id:i8']), + message: def(0x0d, ['type:i8', 'message:str']), + disconnect: def(0x0e, ['reason:str']), + updateUserType: def(0x0f, ['userType:u8']), + // CPE + extInfo: def(0x10, ['appName:str', 'count:i16']), + extEntry: def(0x11, ['extName:str', 'version:i32']), + setClickDistance: def(0x12, ['distance:i16']), + customBlockSupportLevel: def(0x13, ['level:u8']), + holdThis: def(0x14, ['block:blk', 'preventChange:u8']), + setTextHotKey: def(0x15, ['label:str', 'action:str', 'keyCode:i32', 'keyMods:u8']), + extAddPlayerName: def(0x16, ['nameId:i16', 'playerName:str', 'listName:str', 'groupName:str', 'groupRank:u8']), + extRemovePlayerName: def(0x18, ['nameId:i16']), + envSetColor: def(0x19, ['variable:u8', 'r:i16', 'g:i16', 'b:i16']), + makeSelection: def(0x1a, ['id:u8', 'label:str', 'x1:i16', 'y1:i16', 'z1:i16', 'x2:i16', 'y2:i16', 'z2:i16', 'r:i16', 'g:i16', 'b:i16', 'a:i16']), + removeSelection: def(0x1b, ['id:u8']), + setBlockPermission: def(0x1c, ['block:blk', 'allowPlace:u8', 'allowDelete:u8']), + changeModel: def(0x1d, ['id:i8', 'model:str']), + envSetWeatherType: def(0x1f, ['weather:u8']), + hackControl: def(0x20, ['flying:u8', 'noClip:u8', 'speeding:u8', 'spawnControl:u8', 'thirdPerson:u8', 'jumpHeight:i16']), + extAddEntity2: def(0x21, ['id:i8', 'entityName:str', 'skin:str', 'x:pos', 'y:pos', 'z:pos', 'yaw:u8', 'pitch:u8']), + defineBlock: def(0x23, ['block:blk', 'blockName:str', 'collide:u8', 'speed:u8', 'topTex:u8', 'sideTex:u8', 'bottomTex:u8', + 'transmitsLight:u8', 'walkSound:u8', 'fullBright:u8', 'shape:u8', 'draw:u8', 'fogDensity:u8', 'fogR:u8', 'fogG:u8', 'fogB:u8']), + removeBlockDefinition: def(0x24, ['block:blk']), + // BlockDefinitionsExt version 2 + defineBlockExt: def(0x25, ['block:blk', 'blockName:str', 'collide:u8', 'speed:u8', 'topTex:u8', 'leftTex:u8', 'rightTex:u8', + 'frontTex:u8', 'backTex:u8', 'bottomTex:u8', 'transmitsLight:u8', 'walkSound:u8', 'fullBright:u8', + 'minX:i8', 'minY:i8', 'minZ:i8', 'maxX:i8', 'maxY:i8', 'maxZ:i8', 'draw:u8', 'fogDensity:u8', 'fogR:u8', 'fogG:u8', 'fogB:u8']), + bulkBlockUpdate: def(0x26, ['count:u8', 'indices:bytes:1024', 'blocks:bytes:256', 'high:xbytes:64']), + setTextColor: def(0x27, ['r:u8', 'g:u8', 'b:u8', 'a:u8', 'code:u8']), + setMapEnvUrl: def(0x28, ['url:str']), + setMapEnvProperty: def(0x29, ['type:u8', 'value:i32']), + setEntityProperty: def(0x2a, ['id:i8', 'type:u8', 'value:i32']), + twoWayPing: def(0x2b, ['direction:u8', 'data:i16']), + setInventoryOrder: def(0x2c, ['block:blk', 'order:blk']), + setHotbar: def(0x2d, ['block:blk', 'index:u8']), + setSpawnpoint: def(0x2e, ['x:pos', 'y:pos', 'z:pos', 'yaw:u8', 'pitch:u8']), + velocityControl: def(0x2f, ['x:i32', 'y:i32', 'z:i32', 'modeX:u8', 'modeY:u8', 'modeZ:u8']), + lightingMode: def(0x37, ['mode:u8', 'locked:u8']), + // CustomParticles + defineEffect: def(0x30, ['effectId:u8', 'u1:u8', 'v1:u8', 'u2:u8', 'v2:u8', 'r:u8', 'g:u8', 'b:u8', 'frameCount:u8', + 'particleCount:u8', 'size:u8', 'sizeVariation:i32', 'spread:u16', 'speed:i32', 'gravity:i32', 'baseLifetime:i32', + 'lifetimeVariation:i32', 'collideFlags:u8', 'fullBright:u8']), + spawnEffect: def(0x31, ['effectId:u8', 'x:i32', 'y:i32', 'z:i32', 'originX:i32', 'originY:i32', 'originZ:i32']), + // CustomModels version 2 (coordinates in blocks, big endian floats) + defineModel: def(0x32, ['modelId:u8', 'modelName:str', 'flags:u8', 'nameY:f32', 'eyeY:f32', 'collisionX:f32', 'collisionY:f32', + 'collisionZ:f32', 'pickMinX:f32', 'pickMinY:f32', 'pickMinZ:f32', 'pickMaxX:f32', 'pickMaxY:f32', 'pickMaxZ:f32', + 'uScale:u16', 'vScale:u16', 'numParts:u8']), + defineModelPart: def(0x33, ['modelId:u8', 'minX:f32', 'minY:f32', 'minZ:f32', 'maxX:f32', 'maxY:f32', 'maxZ:f32', 'uv:bytes:48', + 'originX:f32', 'originY:f32', 'originZ:f32', 'rotX:f32', 'rotY:f32', 'rotZ:f32', 'anims:bytes:68', 'flags:u8']), + undefineModel: def(0x34, ['modelId:u8']), + pluginMessage: def(0x35, ['channel:u8', 'data:bytes:64']), + cinematicGui: def(0x38, ['hideCrosshair:u8', 'hideHand:u8', 'hideHotbar:u8', 'r:u8', 'g:u8', 'b:u8', 'a:u8', 'barSize:u16']), + toggleBlockList: def(0x3b, ['close:u8']) +} + +// Packets sent by the client, indexed by id +const CLIENT = { + 0x00: { name: 'identification', ...def(0x00, ['protocolVersion:u8', 'username:str', 'key:str', 'padding:u8']) }, + 0x05: { name: 'setBlock', ...def(0x05, ['x:i16', 'y:i16', 'z:i16', 'mode:u8', 'block:blk']) }, + 0x08: { name: 'position', ...def(0x08, ['heldBlock:blk', 'x:pos', 'y:pos', 'z:pos', 'yaw:u8', 'pitch:u8']) }, + 0x0d: { name: 'message', ...def(0x0d, ['partial:u8', 'message:str']) }, + 0x10: { name: 'extInfo', ...def(0x10, ['appName:str', 'count:i16']) }, + 0x11: { name: 'extEntry', ...def(0x11, ['extName:str', 'version:i32']) }, + 0x13: { name: 'customBlockSupportLevel', ...def(0x13, ['level:u8']) }, + 0x22: { name: 'playerClicked', ...def(0x22, ['button:u8', 'action:u8', 'yaw:i16', 'pitch:i16', 'targetEntity:i8', 'x:i16', 'y:i16', 'z:i16', 'face:u8']) }, + 0x2b: { name: 'twoWayPing', ...def(0x2b, ['direction:u8', 'data:i16']) }, + 0x35: { name: 'pluginMessage', ...def(0x35, ['channel:u8', 'data:bytes:64']) }, + 0x39: { name: 'notifyAction', ...def(0x39, ['action:u16', 'value:u16']) }, + 0x3a: { name: 'notifyPositionAction', ...def(0x3a, ['action:u16', 'x:i16', 'y:i16', 'z:i16']) } +} + +// Extensions this server implements, with the version we speak +const EXTENSIONS = { + ClickDistance: 1, + CustomBlocks: 1, + HeldBlock: 1, + EmoteFix: 1, + TextHotKey: 1, + ExtPlayerList: 2, + EnvColors: 1, + SelectionCuboid: 1, + BlockPermissions: 1, + ChangeModel: 1, + EnvWeatherType: 1, + HackControl: 1, + MessageTypes: 1, + PlayerClick: 1, + FullCP437: 1, + LongerMessages: 1, + BlockDefinitions: 1, + BlockDefinitionsExt: 2, + BulkBlockUpdate: 1, + TextColors: 1, + EnvMapAspect: 1, + EntityProperty: 1, + TwoWayPing: 1, + InventoryOrder: 1, + InstantMOTD: 1, + FastMap: 1, + SetHotbar: 1, + SetSpawnpoint: 1, + VelocityControl: 1, + LightingMode: 1, + ExtendedBlocks: 1, + ExtEntityPositions: 1, + CustomParticles: 1, + CustomModels: 2, + PluginMessages: 1, + CinematicGui: 1, + NotifyAction: 1, + ToggleBlockList: 1 +} + +function writeString (buf, offset, value, fullCP437) { + const bytes = cp437.encode(value == null ? '' : value, fullCP437) + for (let i = 0; i < 64; i++) buf[offset + i] = i < bytes.length ? bytes[i] : 0x20 +} + +function readString (buf, offset) { + let end = offset + 64 + while (end > offset && (buf[end - 1] === 0x20 || buf[end - 1] === 0x00)) end-- + return cp437.decode(buf.subarray(offset, end)) +} + +function encode (name, data = {}, opts = {}) { + const packet = SERVER[name] + if (!packet) throw new Error(`Unknown packet ${name}`) + const buf = Buffer.alloc(sizeOf(packet, opts)) + buf[0] = packet.id + let o = 1 + for (const [field, rawType] of packet.fields) { + const v = data[field] + let type = rawType + if (type === 'blk') type = opts.extBlocks ? 'u16' : 'u8' + else if (type === 'pos') type = opts.extPos ? 'i32' : 'i16' + switch (type) { + case 'u8': buf.writeUInt8((v | 0) & 0xFF, o); o += 1; break + case 'i8': buf.writeInt8(clamp(v | 0, -128, 127), o); o += 1; break + case 'u16': buf.writeUInt16BE((v | 0) & 0xFFFF, o); o += 2; break + case 'i16': buf.writeInt16BE(clamp(v | 0, -32768, 32767), o); o += 2; break + case 'i32': buf.writeInt32BE(v | 0, o); o += 4; break + case 'f32': buf.writeFloatBE(Number(v) || 0, o); o += 4; break + case 'str': writeString(buf, o, v, opts.fullCP437 !== false); o += 64; break + default: { + const n = typeSize(type, opts) + if (v && n) Buffer.from(v).copy(buf, o, 0, Math.min(n, v.length)) + o += n + } + } + } + return buf +} + +function decode (id, buf, opts = NO_OPTS) { + const packet = CLIENT[id] + const out = { name: packet.name } + let o = 1 + for (const [field, rawType] of packet.fields) { + let type = rawType + if (type === 'blk') type = opts.extBlocks ? 'u16' : 'u8' + else if (type === 'pos') type = opts.extPos ? 'i32' : 'i16' + switch (type) { + case 'u8': out[field] = buf.readUInt8(o); o += 1; break + case 'i8': out[field] = buf.readInt8(o); o += 1; break + case 'u16': out[field] = buf.readUInt16BE(o); o += 2; break + case 'i16': out[field] = buf.readInt16BE(o); o += 2; break + case 'i32': out[field] = buf.readInt32BE(o); o += 4; break + case 'f32': out[field] = buf.readFloatBE(o); o += 4; break + case 'str': out[field] = readString(buf, o); o += 64; break + default: { + const n = typeSize(type, opts) + out[field] = Buffer.from(buf.subarray(o, o + n)); o += n + } + } + } + return out +} + +function clamp (v, min, max) { return v < min ? min : v > max ? max : v } + +module.exports = { SERVER, CLIENT, EXTENSIONS, encode, decode, sizeOf, readString, writeString } diff --git a/lib/ranks.js b/lib/ranks.js new file mode 100644 index 0000000..d8a6aa4 --- /dev/null +++ b/lib/ranks.js @@ -0,0 +1,70 @@ +'use strict' + +const fs = require('fs') + +// Rank system inspired by MCGalaxy: every rank has a numeric permission level. +// Commands, blocks and levels require a minimum rank. + +const DEFAULT_RANKS = [ + { name: 'Banned', permission: -20, color: '&8', prefix: '', drawLimit: 0, maxUndo: 0 }, + { name: 'Guest', permission: 0, color: '&7', prefix: '', drawLimit: 0, maxUndo: 0 }, + { name: 'Builder', permission: 30, color: '&2', prefix: '', drawLimit: 50000, maxUndo: 20000 }, + { name: 'AdvBuilder', permission: 50, color: '&3', prefix: '', drawLimit: 250000, maxUndo: 100000 }, + { name: 'Operator', permission: 80, color: '&c', prefix: '', drawLimit: 2000000, maxUndo: 500000 }, + { name: 'Admin', permission: 100, color: '&e', prefix: '', drawLimit: 5000000, maxUndo: 1000000 }, + { name: 'Owner', permission: 120, color: '&4', prefix: '', drawLimit: 16777216, maxUndo: 5000000 } +] + +class Ranks { + constructor (file, defaultRank) { + this.file = file + if (file && fs.existsSync(file)) { + this.list = JSON.parse(fs.readFileSync(file, 'utf8')) + } else { + this.list = DEFAULT_RANKS.map(r => ({ ...r })) + if (file) fs.writeFileSync(file, JSON.stringify(this.list, null, 2)) + } + this.list.sort((a, b) => a.permission - b.permission) + this.defaultName = defaultRank || 'Guest' + } + + save () { + if (this.file) fs.writeFileSync(this.file, JSON.stringify(this.list, null, 2)) + } + + get all () { return this.list } + + get default () { return this.get(this.defaultName) || this.list.find(r => r.permission >= 0) || this.list[0] } + + get lowest () { return this.list[0] } + + get highest () { return this.list[this.list.length - 1] } + + // by name (case-insensitive) or permission number + get (nameOrPerm) { + if (nameOrPerm === null || nameOrPerm === undefined) return null + if (typeof nameOrPerm === 'object') return nameOrPerm + const s = String(nameOrPerm).toLowerCase() + if (/^-?\d+$/.test(s)) { + const n = Number(s) + // exact match, otherwise the highest rank below + return this.list.find(r => r.permission === n) || [...this.list].reverse().find(r => r.permission <= n) || this.lowest + } + return this.list.find(r => r.name.toLowerCase() === s) || null + } + + // Minimum permission number for a rank reference (name, number or rank object) + permissionOf (ref) { + if (ref === null || ref === undefined) return this.default.permission + if (typeof ref === 'number') return ref + const r = this.get(ref) + return r ? r.permission : this.default.permission + } + + next (rank) { + const i = this.list.indexOf(this.get(rank)) + return i >= 0 && i < this.list.length - 1 ? this.list[i + 1] : null + } +} + +module.exports = { Ranks, DEFAULT_RANKS } diff --git a/lib/server.js b/lib/server.js new file mode 100644 index 0000000..6e551c6 --- /dev/null +++ b/lib/server.js @@ -0,0 +1,560 @@ +'use strict' + +const net = require('net') +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const readline = require('readline') + +const { Connection } = require('./network/connection') +const { Player, toUnits, EYE } = require('./player') +const { EventBus } = require('./events') +const { Logger } = require('./util/logger') +const { Ranks } = require('./ranks') +const { createPlayerDB } = require('./storage/player-db') +const { JsonStore } = require('./storage/json-store') +const { CommandManager, CommandError } = require('./commands/manager') +const { PluginManager } = require('./plugins/manager') +const { LevelManager } = require('./level/manager') +const { BlockPermissions } = require('./block-permissions') +const { Heartbeat } = require('./heartbeat') +const { generators } = require('./level/generators') +const { saveConfig } = require('./config') +const Blocks = require('./blocks') +const text = require('./util/text') +const registerCoreCommands = require('./commands/core') +const { modelPackets, effectPacket } = require('./cpe-extras') + +const MAX_ENTITY_ID = 126 + +class MCScriptServer { + constructor (config) { + this.config = config + this.root = config.root || process.cwd() + this.version = require('../package.json').version + this.log = new Logger({ dir: config.logToFile ? path.join(this.root, 'logs') : null, debug: config.debug, silent: config.silent }) + this.events = new EventBus(this.log) + this.salt = randomSalt(16) + this.players = [] + this.entities = new Map() // non-player entities (bots/NPCs): id -> entity + this.customModels = new Map() // name -> { id, model } (CustomModels) + this.particleEffects = new Map() // name -> { id, effect } (CustomParticles) + this._usedIds = new Set() + this._connectionsPerIp = new Map() + this.startedAt = Date.now() + this.running = false + + for (const [code] of Object.entries(config.customColors || {})) text.extraColorCodes.add(code) + + const configDir = path.join(this.root, 'config') + const dataDir = path.join(this.root, 'data') + fs.mkdirSync(configDir, { recursive: true }) + fs.mkdirSync(dataDir, { recursive: true }) + + this.ranks = new Ranks(path.join(configDir, 'ranks.json'), config.defaultRank) + this.playerDB = createPlayerDB(dataDir, config.database, this.log) + this.blockPerms = new BlockPermissions(this, path.join(configDir, 'blockperms.json')) + this.globalBlocks = new JsonStore(path.join(dataDir, 'blockdefs.json'), {}) + this.commands = new CommandManager(this, path.join(configDir, 'commands.json')) + this.levels = new LevelManager(this, { dir: path.join(this.root, 'levels'), backupsToKeep: config.backupsToKeep }) + this.plugins = new PluginManager(this, { + dir: path.resolve(this.root, config.pluginsDir || 'plugins'), + configDir: path.join(configDir, 'plugins'), + dataDir: path.join(dataDir, 'plugins'), + disabled: config.disabledPlugins + }) + this.heartbeat = new Heartbeat(this) + this.tabList = new TabList(this) + this.console = new ConsolePlayer(this) + this.CommandError = CommandError + this.Blocks = Blocks + this.text = text + this.generators = generators + } + + // A command sender with console powers whose messages go to `onMessage` (web panel, relays...) + createConsoleActor (name, onMessage) { + const actor = new ConsolePlayer(this) + actor.name = name + actor.displayName = actor.coloredName = `&4${name}` + actor.message = (msg) => onMessage(msg) + return actor + } + + // Persists changes made to server.config (e.g. by commands) to config/server.json + saveConfig () { saveConfig(this.config) } + + // Global custom block definitions (id -> definition) + get blockDefs () { return this.globalBlocks.data } + + // lifecycle + + async start () { + const cfg = this.config + this.log.info(`Starting &bMCScript ${this.version}&f (Node ${process.version})`) + this.levels.initMain(cfg.mainLevel) + registerCoreCommands(this) + this.plugins.loadAll() + + this.tcp = net.createServer(socket => this._onConnection(socket)) + this.tcp.maxConnections = cfg.maxPlayers + 10 + await new Promise((resolve, reject) => { + this.tcp.once('error', reject) + this.tcp.listen(cfg.port, cfg.host, () => { this.tcp.off('error', reject); resolve() }) + }) + this.port = this.tcp.address().port + this.running = true + this.log.info(`Listening on &a${cfg.host}:${this.port}&f${cfg.allowWebClient ? ' (desktop + web client)' : ''}`) + + if (cfg.autosaveMinutes > 0) { + this._autosave = setInterval(() => { + const n = this.levels.saveAll(true) + if (n) this.log.info(`Autosaved ${n} level${n > 1 ? 's' : ''}`) + this.playerDB.flush() + }, cfg.autosaveMinutes * 60000) + } + if (cfg.backupMinutes > 0) { + this._backups = setInterval(() => { + for (const level of this.levels.loaded.values()) { + if (!level.changedSinceBackup) continue + try { this.levels.backup(level); level.changedSinceBackup = false } catch (err) { this.log.error('Backup failed:', err) } + } + }, cfg.backupMinutes * 60000) + this.events.on('levelSave', ({ level }) => { level.changedSinceBackup = true }) + } + // the heartbeat also registers the salt that name verification relies on + if (cfg.public || cfg.verifyNames) this.heartbeat.start() + if (cfg.checkForUpdates) require('./update-check').checkForUpdates(this) + this.events.fire('serverStart', { server: this }) + return this + } + + async stop (reason = 'Server is shutting down') { + if (!this.running) return + this.running = false + this.log.info('Stopping server...') + this.events.fire('serverStop', { server: this, reason }) + for (const p of [...this.players]) p.kick(reason) + clearInterval(this._autosave) + clearInterval(this._backups) + this.heartbeat.stop() + this.plugins.unloadAll() + this.levels.saveAll(true) + if (this.playerDB.close) this.playerDB.close() + else this.playerDB.flush() + this.globalBlocks.flush() + if (this._rl) this._rl.close() + await new Promise(resolve => this.tcp.close(() => resolve())) + this.log.info('Server stopped.') + this.log.close() + } + + startConsole (input = process.stdin) { + this._rl = readline.createInterface({ input, terminal: false }) + this._rl.on('line', line => { + line = line.trim() + if (!line) return + if (line.startsWith('/')) this.commands.execute(this.console, line) + else if (['stop', 'exit', 'quit'].includes(line)) this.commands.execute(this.console, '/stop') + else if (this.commands.find(line.split(' ')[0])) this.commands.execute(this.console, line) + else this.broadcast(`&d[Console]&f: ${text.sanitize(line)}`) + }) + } + + // connections + + _onConnection (socket) { + if (!this.running) return socket.destroy() + // stop a single address from filling every connection slot + const ip = (socket.remoteAddress || '').replace(/^::ffff:/, '') + const limit = this.config.maxConnectionsPerIp + const count = this._connectionsPerIp.get(ip) || 0 + if (limit > 0 && count >= limit) { + this.log.debug(`Refused connection from ${ip}: too many connections`) + return socket.destroy() + } + this._connectionsPerIp.set(ip, count + 1) + socket.once('close', () => { + const n = (this._connectionsPerIp.get(ip) || 1) - 1 + if (n > 0) this._connectionsPerIp.set(ip, n) + else this._connectionsPerIp.delete(ip) + }) + const conn = new Connection(socket) + 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) + } + + _allocateId () { + for (let id = 0; id <= MAX_ENTITY_ID; id++) { + if (!this._usedIds.has(id)) { this._usedIds.add(id); return id } + } + return null + } + + _freeId (id) { this._usedIds.delete(id) } + + _addPlayer (player) { + player.id = this._allocateId() + if (player.id === null) return player.kick('Too many entities on the server') + this.players.push(player) + } + + _onPlayerJoin (player) { + this.tabList.addAllFor(player) + const joinMsg = (player.record && player.record.loginMessage) || 'joined the game' + this.broadcast(`&a+ ${player.coloredName} &e${joinMsg}`, p => p !== player) + const welcome = String(this.config.welcomeMessage || '').replace(/\{player\}/g, player.name) + if (welcome) player.message(welcome) + this.events.fire('playerJoin', { player }) + } + + _onPlayerDisconnect (player) { + const i = this.players.indexOf(player) + if (i === -1) return + this.players.splice(i, 1) + clearInterval(player._pingTimer) + if (player.selection) player.cancelSelection() + for (const other of this.players) { + if (other.level === player.level && other.spawned) other.hideEntity(player.id) + } + this.tabList.remove(player) + this._freeId(player.id) + if (player.record) { + player.record.timeSpent += Date.now() - player.joinedAt + this.playerDB.save() + } + this.events.fire('playerLeave', { player, reason: player.kickReason || null }) + if (player.loggedIn) { + const why = player.kickReason && !/^(Someone else logged in|Server is shutting)/.test(player.kickReason) ? ` &7(${player.kickReason})` : '' + const leaveMsg = (player.record && player.record.logoutMessage) || 'left the game' + this.broadcast(`&c- ${player.coloredName} &e${leaveMsg}${why}`) + this.log.info(`${player.name} disconnected${player.kickReason ? ' (' + player.kickReason + ')' : ''}`) + } + } + + // players + + get online () { return this.players.filter(p => p.loggedIn) } + + findPlayerExact (name) { + const lower = String(name).toLowerCase() + return this.players.find(p => p.loggedIn && p.name.toLowerCase() === lower) || null + } + + // Exact name, or unique partial match. Returns { player, matches } + matchPlayers (partial) { + const exact = this.findPlayerExact(partial) + if (exact) return [exact] + const lower = String(partial).toLowerCase() + return this.online.filter(p => p.name.toLowerCase().includes(lower)) + } + + // Finds a player for a command, telling `requester` if none or several match + findPlayer (partial, requester) { + if (!partial) return null + const matches = this.matchPlayers(partial) + if (matches.length === 1) return matches[0] + if (requester) { + if (!matches.length) requester.message(`&cNo player online matches "${partial}".`) + else requester.message(`&cMultiple players match "${partial}": &f${matches.map(p => p.name).join(', ')}`) + } + return null + } + + broadcast (msg, filter, type = 'chat') { + for (const p of this.players) { + if (!p.loggedIn) continue + if (filter && !filter(p)) continue + p.message(msg, type) + } + if (!filter) this.log.info(msg) + } + + setRank (name, rank, by) { + const record = this.playerDB.getOrCreate(name) + const from = this.ranks.get(record.rank) || this.ranks.default + record.rank = rank.name + record.rankHistory = [...(record.rankHistory || []), { from: from.name, to: rank.name, by: by || null, at: Date.now() }].slice(-20) + this.playerDB.save() + this.events.fire('playerRankChange', { name: record.name, from, to: rank, by }) + const player = this.findPlayerExact(name) + if (player) { + if (player.supports('InstantMOTD')) player._sendIdentification(player.level) + else player.conn.write('updateUserType', { userType: this.blockPerms.canDelete(player, 7) ? 0x64 : 0 }) + player.sendBlockPermissions() + player.respawnForOthers() + } + } + + // blocks + + // changes: [[index, block], ...] already applied to level.blocks + broadcastBlockChanges (level, changes) { + for (const p of this.players) { + if (p.level !== level || !p.spawned) continue + if (changes.length === 1 || !p.supports('BulkBlockUpdate')) { + if (changes.length > 4096) { p.reloadLevel(); continue } + for (const [i, block] of changes) { + const { x, y, z } = level.unpack(i) + p.conn.write('setBlock', { x, y, z, block: p.convertBlock(block) }) + } + continue + } + for (let s = 0; s < changes.length; s += 256) { + const part = changes.slice(s, s + 256) + const indices = Buffer.alloc(1024) + const blocks = Buffer.alloc(256) + const high = Buffer.alloc(64) // ExtendedBlocks: 2 extra bits per block + part.forEach(([i, block], n) => { + const b = p.convertBlock(block) + indices.writeInt32BE(i, n * 4) + blocks[n] = b & 0xFF + high[n >> 2] |= ((b >> 8) & 3) << ((n & 3) * 2) + }) + p.conn.write('bulkBlockUpdate', { count: part.length - 1, indices, blocks, high }) + } + } + } + + // Global custom blocks (/gb). Level blocks live in level.blockDefs (/lb). + setGlobalBlock (def) { + def = Blocks.normalize(def) + this.globalBlocks.data[def.id] = def + this.globalBlocks.save() + this._refreshBlockDefs(null) + return def + } + + removeGlobalBlock (id) { + delete this.globalBlocks.data[id] + this.globalBlocks.save() + this._refreshBlockDefs(null) + } + + setLevelBlock (level, def) { + def = Blocks.normalize(def) + level.blockDefs[def.id] = def + level.dirty = true + this._refreshBlockDefs(level) + return def + } + + removeLevelBlock (level, id) { + delete level.blockDefs[id] + level.dirty = true + this._refreshBlockDefs(level) + } + + _refreshBlockDefs (level) { + for (const p of this.players) { + if (!p.spawned || (level && p.level !== level)) continue + p.sendBlockDefinitions() + p.sendBlockPermissions() + } + } + + // custom models & particles + + // Defines (or redefines) a custom model for every client that supports CustomModels v2. + // Players and bots can then use it with /model . + defineModel (model) { + const name = String(model.name || '').toLowerCase() + if (!/^[a-z0-9_.-]{1,64}$/.test(name)) throw new Error('Invalid model name') + let entry = this.customModels.get(name) + if (!entry) { + const used = new Set([...this.customModels.values()].map(e => e.id)) + let id = 0 + while (used.has(id)) id++ + if (id > 63) throw new Error('Too many custom models (max 64)') + entry = { id } + this.customModels.set(name, entry) + } + entry.model = { ...model, name } + for (const p of this.players) if (p.loggedIn) p.sendCustomModel(entry) + return entry + } + + removeModel (name) { + const entry = this.customModels.get(String(name).toLowerCase()) + if (!entry) return false + this.customModels.delete(String(name).toLowerCase()) + for (const p of this.players) if (p.loggedIn && p.supports('CustomModels', 2)) p.conn.write('undefineModel', { modelId: entry.id }) + return true + } + + _modelPackets (entry) { return modelPackets(entry.id, entry.model) } + + // Registers a named particle effect (see lib/cpe-extras.js for the fields) + defineParticle (name, effect) { + name = String(name).toLowerCase() + let entry = this.particleEffects.get(name) + if (!entry) { + if (this.particleEffects.size >= 255) throw new Error('Too many particle effects') + entry = { id: this.particleEffects.size } + this.particleEffects.set(name, entry) + } + entry.effect = effect + entry.version = (entry.version || 0) + 1 + return entry + } + + // Shows a particle effect at a position (blocks) to the players in a level + spawnParticles (level, name, x, y, z, origin = null) { + const entry = this.particleEffects.get(String(name).toLowerCase()) + if (!entry) throw new Error(`Unknown particle effect ${name}`) + for (const p of this.players) { + if (p.level !== level || !p.spawned || !p.supports('CustomParticles')) continue + const sent = p.data._effects || (p.data._effects = new Map()) + if (sent.get(entry.id) !== entry.version) { + p.conn.write('defineEffect', effectPacket(entry.id, entry.effect)) + sent.set(entry.id, entry.version) + } + const o = origin || { x, y: y - 1, z } + p.conn.write('spawnEffect', { + effectId: entry.id, + x: Math.round(x * 32), + y: Math.round(y * 32), + z: Math.round(z * 32), + originX: Math.round(o.x * 32), + originY: Math.round(o.y * 32), + originZ: Math.round(o.z * 32) + }) + } + } + + // entities (bots / NPCs) + + entitiesIn (level) { + return [...this.entities.values()].filter(e => e.level === level) + } + + // Creates a non-player entity visible to players in `level`. Position in blocks (feet). + createEntity ({ name, skin, model = 'humanoid', level, x, y, z, yaw = 0, pitch = 0, scale = null, data = {} }) { + const id = this._allocateId() + if (id === null) throw new Error('No free entity ids left') + const entity = { + id, + name, + skin: skin || text.stripColors(name), + model, + scale, + level, + pos: { x: toUnits(x), y: toUnits(y) + EYE, z: toUnits(z) }, + yaw, + pitch, + data + } + this.entities.set(id, entity) + for (const p of level.players) if (p.spawned) p.showEntity(entity) + return entity + } + + removeEntity (entity) { + if (!this.entities.has(entity.id)) return + this.entities.delete(entity.id) + for (const p of entity.level.players) if (p.spawned) p.hideEntity(entity.id) + this._freeId(entity.id) + } + + moveEntity (entity, x, y, z, yaw = entity.yaw, pitch = entity.pitch) { + entity.pos = { x: toUnits(x), y: toUnits(y) + EYE, z: toUnits(z) } + entity.yaw = yaw & 255 + entity.pitch = pitch & 255 + for (const p of entity.level.players) { + if (p.spawned) p.conn.write('teleport', { id: entity.id, ...entity.pos, yaw: entity.yaw, pitch: entity.pitch }) + } + } + + rotateEntity (entity, yaw, pitch = entity.pitch) { + entity.yaw = yaw & 255 + entity.pitch = pitch & 255 + for (const p of entity.level.players) { + if (p.spawned) p.conn.write('orientUpdate', { id: entity.id, yaw: entity.yaw, pitch: entity.pitch }) + } + } + + updateEntity (entity) { + for (const p of entity.level.players) { + if (!p.spawned) continue + p.hideEntity(entity.id) + p.showEntity(entity) + } + } +} + +// Players shown in the tab list (ExtPlayerList). Plugins can customize entries with the 'tabListEntry' event. +class TabList { + constructor (server) { this.server = server } + + entryFor (target) { + const ev = this.server.events.fire('tabListEntry', { + player: target, + listName: target.coloredName, + groupName: `&fIn ${target.level ? target.level.name : '?'}`, + groupRank: 0 + }) + return ev + } + + _send (viewer, target) { + if (!viewer.supports('ExtPlayerList') || !viewer.loggedIn) return + if (target.hidden && viewer !== target && viewer.permission < target.permission) return + const e = this.entryFor(target) + viewer.conn.write('extAddPlayerName', { + nameId: target === viewer ? -1 : target.id, + playerName: target.name, + listName: e.listName, + groupName: e.groupName, + groupRank: Math.max(0, Math.min(255, e.groupRank)) + }) + } + + addAllFor (viewer) { + for (const target of this.server.players) { + if (target !== viewer && target.loggedIn && target.level) this._send(viewer, target) + } + } + + update (target) { + for (const viewer of this.server.players) this._send(viewer, target) + } + + remove (target) { + for (const viewer of this.server.players) { + if (viewer.supports('ExtPlayerList') && viewer.loggedIn) viewer.conn.write('extRemovePlayerName', { nameId: target.id }) + } + } +} + +// The server console behaves like an all-powerful player for commands +class ConsolePlayer { + constructor (server) { + this.server = server + this.isConsole = true + this.name = '(console)' + this.displayName = '&4Console' + this.coloredName = '&4Console' + this.color = '&4' + this.permission = Infinity + this.record = null + this.data = {} + this.loggedIn = true + } + + get rank () { return this.server.ranks.highest } + get level () { return this.server.levels.main } + supports () { return false } + message (msg) { if (msg) this.server.log.info(msg) } +} + +function randomSalt (length) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + const bytes = crypto.randomBytes(length) + let s = '' + for (let i = 0; i < length; i++) s += chars[bytes[i] % chars.length] + return s +} + +module.exports = { MCScriptServer, ConsolePlayer, TabList } diff --git a/lib/storage/json-store.js b/lib/storage/json-store.js new file mode 100644 index 0000000..b6227ad --- /dev/null +++ b/lib/storage/json-store.js @@ -0,0 +1,38 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +// Small JSON file store with debounced, atomic writes. +class JsonStore { + constructor (file, defaults = {}) { + this.file = file + this.data = defaults + this.timer = null + if (fs.existsSync(file)) { + try { + this.data = JSON.parse(fs.readFileSync(file, 'utf8')) + } catch (err) { + // keep a copy of the broken file instead of losing it + fs.copyFileSync(file, `${file}.broken-${Date.now()}`) + } + } + } + + save () { + clearTimeout(this.timer) + this.timer = setTimeout(() => this.flush(), 1000) + if (this.timer.unref) this.timer.unref() + } + + flush () { + clearTimeout(this.timer) + this.timer = null + fs.mkdirSync(path.dirname(this.file), { recursive: true }) + const tmp = this.file + '.tmp' + fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2)) + fs.renameSync(tmp, this.file) + } +} + +module.exports = { JsonStore } diff --git a/lib/storage/player-db.js b/lib/storage/player-db.js new file mode 100644 index 0000000..539f757 --- /dev/null +++ b/lib/storage/player-db.js @@ -0,0 +1,197 @@ +'use strict' + +const fs = require('fs') +const { JsonStore } = require('./json-store') + +function newRecord (name) { + return { + name, + rank: null, + firstLogin: Date.now(), + lastLogin: Date.now(), + logins: 0, + timeSpent: 0, + blocksPlaced: 0, + blocksDeleted: 0, + messages: 0, + kicks: 0, + ips: [], + lastIp: null, + nick: null, + color: null, + title: null, + titleColor: null, + model: null, + skin: null, + muteUntil: null, + muteReason: null, + ban: null + } +} + +// Persistent information about every player that joined the server (data/players.json). +class PlayerDB { + constructor (file) { + this.store = new JsonStore(file, {}) + // keyed by player name, so no prototype: a player called "__proto__" or "constructor" is just a key + this.store.data = Object.assign(Object.create(null), this.store.data) + } + + key (name) { return String(name).toLowerCase() } + + get (name) { + return this.store.data[this.key(name)] || null + } + + getOrCreate (name) { + const k = this.key(name) + if (!this.store.data[k]) { + this.store.data[k] = newRecord(name) + this.save() + } + return this.store.data[k] + } + + update (name, fields) { + Object.assign(this.getOrCreate(name), fields) + this.save() + } + + all () { return Object.values(this.store.data) } + + // Find by exact name, or a unique prefix + find (partial) { + const exact = this.get(partial) + if (exact) return exact + const p = this.key(partial) + const matches = this.all().filter(r => r.name.toLowerCase().startsWith(p)) + return matches.length === 1 ? matches[0] : null + } + + save () { this.store.save() } + flush () { this.store.flush() } +} + +// Same interface, stored in an SQLite database (data/players.db) using Node's built-in node:sqlite. +// Better for servers with many thousands of players: records are loaded on demand and only +// the ones that changed are written back. +class SqlitePlayerDB { + constructor (file, { importFrom } = {}) { + const { DatabaseSync } = require('node:sqlite') + this.db = new DatabaseSync(file) + this.db.exec('PRAGMA journal_mode = WAL') + this.db.exec('CREATE TABLE IF NOT EXISTS players (key TEXT PRIMARY KEY, name TEXT NOT NULL, data TEXT NOT NULL)') + this.cache = new Map() // key -> { record, json } + this.timer = null + this.stmt = { + get: this.db.prepare('SELECT data FROM players WHERE key = ?'), + put: this.db.prepare('INSERT INTO players (key, name, data) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET name = excluded.name, data = excluded.data'), + prefix: this.db.prepare("SELECT key, data FROM players WHERE key LIKE ? ESCAPE '\\' LIMIT 3"), + all: this.db.prepare('SELECT key, data FROM players'), + count: this.db.prepare('SELECT COUNT(*) AS n FROM players') + } + if (importFrom && fs.existsSync(importFrom) && this.stmt.count.get().n === 0) this._import(importFrom) + } + + _import (file) { + const data = JSON.parse(fs.readFileSync(file, 'utf8')) + this.db.exec('BEGIN') + for (const [key, record] of Object.entries(data)) this.stmt.put.run(key, record.name, JSON.stringify(record)) + this.db.exec('COMMIT') + } + + key (name) { return String(name).toLowerCase() } + + _cached (key, json) { + let entry = this.cache.get(key) + if (!entry) { + entry = { record: JSON.parse(json), json } + this.cache.set(key, entry) + } + return entry.record + } + + get (name) { + const k = this.key(name) + if (this.cache.has(k)) return this.cache.get(k).record + const row = this.stmt.get.get(k) + return row ? this._cached(k, row.data) : null + } + + getOrCreate (name) { + const existing = this.get(name) + if (existing) return existing + const record = newRecord(name) + this.cache.set(this.key(name), { record, json: null }) + this.save() + return record + } + + update (name, fields) { + Object.assign(this.getOrCreate(name), fields) + this.save() + } + + // Read-only listing (records that are not cached are fresh copies) + all () { + return this.stmt.all.all().map(row => this.cache.has(row.key) ? this.cache.get(row.key).record : JSON.parse(row.data)) + } + + find (partial) { + const exact = this.get(partial) + if (exact) return exact + const p = this.key(partial) + const escaped = p.replace(/[\\%_]/g, '\\$&') + // records created since the last flush are only in the cache + const matches = new Map(this.stmt.prefix.all(escaped + '%').map(row => [row.key, row.data])) + for (const key of this.cache.keys()) if (key.startsWith(p)) matches.set(key, null) + if (matches.size !== 1) return null + const [key, data] = [...matches][0] + return data === null ? this.cache.get(key).record : this._cached(key, data) + } + + save () { + clearTimeout(this.timer) + this.timer = setTimeout(() => this.flush(), 1000) + if (this.timer.unref) this.timer.unref() + } + + // Writes every cached record that changed since it was last written + flush () { + clearTimeout(this.timer) + this.timer = null + const changed = [] + for (const [key, entry] of this.cache) { + const json = JSON.stringify(entry.record) + if (json !== entry.json) changed.push([key, entry, json]) + } + if (!changed.length) return + this.db.exec('BEGIN') + for (const [key, entry, json] of changed) { + this.stmt.put.run(key, entry.record.name, json) + entry.json = json + } + this.db.exec('COMMIT') + } + + close () { + this.flush() + this.db.close() + } +} + +// Picks the storage backend from config.database ('json' or 'sqlite') +function createPlayerDB (dataDir, type, log) { + const path = require('path') + const jsonFile = path.join(dataDir, 'players.json') + if (type === 'sqlite') { + try { + return new SqlitePlayerDB(path.join(dataDir, 'players.db'), { importFrom: jsonFile }) + } catch (err) { + if (log) log.warn(`SQLite is not available in this Node version (${err.message}); using players.json instead.`) + } + } + return new PlayerDB(jsonFile) +} + +module.exports = { PlayerDB, SqlitePlayerDB, createPlayerDB, newRecord } diff --git a/lib/update-check.js b/lib/update-check.js new file mode 100644 index 0000000..5787e17 --- /dev/null +++ b/lib/update-check.js @@ -0,0 +1,44 @@ +'use strict' + +// Asks GitHub for the latest MCScript release and logs a line when a newer one is out. +// Turn it off with "checkForUpdates": false in config/server.json. + +const RELEASES_URL = 'https://api.github.com/repos/perronosaurio/MCScript/releases/latest' + +// "2.1.0" > "2.0.3"; pre-releases ("2.1.0-beta.1") count as older than the final release +function isNewer (latest, current) { + const parse = v => { + const [main, pre] = String(v).replace(/^v/, '').split('-') + return { nums: main.split('.').map(n => parseInt(n, 10) || 0), pre: pre || null } + } + const a = parse(latest) + const b = parse(current) + for (let i = 0; i < 3; i++) { + if ((a.nums[i] || 0) !== (b.nums[i] || 0)) return (a.nums[i] || 0) > (b.nums[i] || 0) + } + if (a.pre === b.pre) return false + if (!a.pre) return true + if (!b.pre) return false + return a.pre > b.pre +} + +async function checkForUpdates (server, { url = RELEASES_URL, fetchImpl = fetch } = {}) { + try { + const res = await fetchImpl(url, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': `MCScript/${server.version}` }, + signal: AbortSignal.timeout(10000) + }) + if (!res.ok) return null + const release = await res.json() + const latest = String(release.tag_name || '').replace(/^v/, '') + if (latest && isNewer(latest, server.version)) { + server.log.info(`&eMCScript ${latest} is out (you have ${server.version}): &b${release.html_url}`) + return latest + } + } catch (err) { + server.log.debug(`Update check failed: ${err.message}`) + } + return null +} + +module.exports = { checkForUpdates, isNewer } diff --git a/lib/util/logger.js b/lib/util/logger.js new file mode 100644 index 0000000..d719f4e --- /dev/null +++ b/lib/util/logger.js @@ -0,0 +1,96 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const { stripColors } = require('./text') + +// &x color code -> ANSI escape for the console +const ANSI = { + 0: '\x1b[30m', + 1: '\x1b[34m', + 2: '\x1b[32m', + 3: '\x1b[36m', + 4: '\x1b[31m', + 5: '\x1b[35m', + 6: '\x1b[33m', + 7: '\x1b[37m', + 8: '\x1b[90m', + 9: '\x1b[94m', + a: '\x1b[92m', + b: '\x1b[96m', + c: '\x1b[91m', + d: '\x1b[95m', + e: '\x1b[93m', + f: '\x1b[97m' +} +const RESET = '\x1b[0m' +const LEVEL_COLORS = { debug: '\x1b[90m', info: '\x1b[36m', warn: '\x1b[33m', error: '\x1b[31m' } + +function toAnsi (msg) { + return String(msg).replace(/&([0-9a-f])/gi, (m, c) => ANSI[c.toLowerCase()] || '') + RESET +} + +class Logger { + constructor (opts = {}) { + this.dir = opts.dir || null + this.useColors = opts.colors !== undefined ? opts.colors : process.stdout.isTTY + this.silent = !!opts.silent + this.debugEnabled = !!opts.debug + this.prefix = opts.prefix || '' + this.shared = { stream: null, date: null, listeners: new Set() } + } + + child (prefix) { + const c = Object.create(this) + c.prefix = this.prefix + `[${prefix}] ` + return c + } + + _file () { + if (!this.dir) return null + const shared = this.shared + const date = new Date().toISOString().slice(0, 10) + if (shared.date !== date) { + if (shared.stream) shared.stream.end() + fs.mkdirSync(this.dir, { recursive: true }) + shared.stream = fs.createWriteStream(path.join(this.dir, `${date}.log`), { flags: 'a' }) + shared.stream.on('error', () => {}) + shared.date = date + } + return shared.stream + } + + log (level, ...args) { + if (level === 'debug' && !this.debugEnabled) return + const msg = args.map(a => a instanceof Error ? (a.stack || a.message) : typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + const time = new Date().toTimeString().slice(0, 8) + const plain = `${time} [${level.toUpperCase()}] ${this.prefix}${stripColors(msg)}` + const file = this._file() + if (file) file.write(plain + '\n') + for (const fn of this.shared.listeners) { + try { fn({ time: Date.now(), level, message: this.prefix + msg }) } catch (err) {} + } + if (this.silent) return + const out = level === 'error' || level === 'warn' ? process.stderr : process.stdout + if (this.useColors) { + out.write(`\x1b[90m${time}${RESET} ${LEVEL_COLORS[level]}${level.toUpperCase()}${RESET} ${this.prefix}${toAnsi('&f' + msg)}\n`) + } else { + out.write(plain + '\n') + } + } + + // Receives every log line ({ time, level, message }); returns a function to stop + subscribe (fn) { + this.shared.listeners.add(fn) + return () => this.shared.listeners.delete(fn) + } + + debug (...a) { this.log('debug', ...a) } + info (...a) { this.log('info', ...a) } + warn (...a) { this.log('warn', ...a) } + error (...a) { this.log('error', ...a) } + + close () { if (this.shared.stream) this.shared.stream.end() } +} + +module.exports = { Logger } diff --git a/lib/util/nbt.js b/lib/util/nbt.js new file mode 100644 index 0000000..783ca21 --- /dev/null +++ b/lib/util/nbt.js @@ -0,0 +1,162 @@ +'use strict' + +// Minimal big-endian NBT reader/writer (used by the ClassicWorld .cw format). +// Values are represented as { type, value } so they round-trip with the right tag types. + +const TAG = { + end: 0, + byte: 1, + short: 2, + int: 3, + long: 4, + float: 5, + double: 6, + byteArray: 7, + string: 8, + list: 9, + compound: 10, + intArray: 11, + longArray: 12 +} +const TAG_NAMES = Object.fromEntries(Object.entries(TAG).map(([k, v]) => [v, k])) + +class Reader { + constructor (buf) { this.buf = buf; this.o = 0 } + u8 () { return this.buf.readUInt8(this.o++) } + i8 () { return this.buf.readInt8(this.o++) } + i16 () { const v = this.buf.readInt16BE(this.o); this.o += 2; return v } + u16 () { const v = this.buf.readUInt16BE(this.o); this.o += 2; return v } + i32 () { const v = this.buf.readInt32BE(this.o); this.o += 4; return v } + i64 () { const v = this.buf.readBigInt64BE(this.o); this.o += 8; return v } + f32 () { const v = this.buf.readFloatBE(this.o); this.o += 4; return v } + f64 () { const v = this.buf.readDoubleBE(this.o); this.o += 8; return v } + str () { const n = this.u16(); const s = this.buf.toString('utf8', this.o, this.o + n); this.o += n; return s } + + payload (type) { + switch (type) { + case TAG.byte: return this.i8() + case TAG.short: return this.i16() + case TAG.int: return this.i32() + case TAG.long: return this.i64() + case TAG.float: return this.f32() + case TAG.double: return this.f64() + case TAG.byteArray: { + const n = this.i32() + const v = Buffer.from(this.buf.subarray(this.o, this.o + n)) + this.o += n + return v + } + case TAG.string: return this.str() + case TAG.list: { + const itemType = this.u8() + const n = this.i32() + const items = [] + for (let i = 0; i < n; i++) items.push(this.payload(itemType)) + return { itemType: TAG_NAMES[itemType], items } + } + case TAG.compound: { + const out = {} + for (;;) { + const t = this.u8() + if (t === TAG.end) break + const name = this.str() + out[name] = { type: TAG_NAMES[t], value: this.payload(t) } + } + return out + } + case TAG.intArray: { + const n = this.i32(); const a = [] + for (let i = 0; i < n; i++) a.push(this.i32()) + return a + } + case TAG.longArray: { + const n = this.i32(); const a = [] + for (let i = 0; i < n; i++) a.push(this.i64()) + return a + } + default: throw new Error(`Unknown NBT tag ${type}`) + } + } +} + +function read (buf) { + const r = new Reader(buf) + const type = r.u8() + if (type !== TAG.compound) throw new Error('NBT root must be a compound') + const name = r.str() + return { name, value: r.payload(type) } +} + +class Writer { + constructor () { this.parts = [] } + push (b) { this.parts.push(b) } + u8 (v) { const b = Buffer.alloc(1); b.writeUInt8(v & 0xFF); this.push(b) } + i8 (v) { const b = Buffer.alloc(1); b.writeInt8(v); this.push(b) } + i16 (v) { const b = Buffer.alloc(2); b.writeInt16BE(v); this.push(b) } + u16 (v) { const b = Buffer.alloc(2); b.writeUInt16BE(v); this.push(b) } + i32 (v) { const b = Buffer.alloc(4); b.writeInt32BE(v); this.push(b) } + i64 (v) { const b = Buffer.alloc(8); b.writeBigInt64BE(BigInt(v)); this.push(b) } + f32 (v) { const b = Buffer.alloc(4); b.writeFloatBE(v); this.push(b) } + f64 (v) { const b = Buffer.alloc(8); b.writeDoubleBE(v); this.push(b) } + str (s) { const b = Buffer.from(String(s), 'utf8'); this.u16(b.length); this.push(b) } + + payload (type, value) { + switch (TAG[type]) { + case TAG.byte: return this.i8(value) + case TAG.short: return this.i16(value) + case TAG.int: return this.i32(value) + case TAG.long: return this.i64(value) + case TAG.float: return this.f32(value) + case TAG.double: return this.f64(value) + case TAG.byteArray: this.i32(value.length); return this.push(Buffer.from(value)) + case TAG.string: return this.str(value) + case TAG.list: + this.u8(TAG[value.itemType]) + this.i32(value.items.length) + for (const item of value.items) this.payload(value.itemType, item) + return + case TAG.compound: + for (const [name, tag] of Object.entries(value)) { + if (!tag || tag.value === undefined || tag.value === null) continue + this.u8(TAG[tag.type]) + this.str(name) + this.payload(tag.type, tag.value) + } + return this.u8(TAG.end) + case TAG.intArray: this.i32(value.length); for (const v of value) this.i32(v); return + case TAG.longArray: this.i32(value.length); for (const v of value) this.i64(v); return + default: throw new Error(`Unknown NBT tag type ${type}`) + } + } +} + +function write (name, compound) { + const w = new Writer() + w.u8(TAG.compound) + w.str(name) + w.payload('compound', compound) + return Buffer.concat(w.parts) +} + +// helpers to build tags +const t = { + byte: v => ({ type: 'byte', value: v }), + short: v => ({ type: 'short', value: v }), + int: v => ({ type: 'int', value: v }), + long: v => ({ type: 'long', value: v }), + float: v => ({ type: 'float', value: v }), + string: v => ({ type: 'string', value: v }), + bytes: v => ({ type: 'byteArray', value: v }), + compound: v => ({ type: 'compound', value: v }) +} + +// Unwrap a compound into plain JS values (recursively) +function simplify (compound) { + const out = {} + for (const [k, tag] of Object.entries(compound)) { + out[k] = tag.type === 'compound' ? simplify(tag.value) : tag.value + } + return out +} + +module.exports = { read, write, t, simplify, TAG } diff --git a/lib/util/text.js b/lib/util/text.js new file mode 100644 index 0000000..f515d15 --- /dev/null +++ b/lib/util/text.js @@ -0,0 +1,160 @@ +'use strict' + +const COLORS = { + black: '&0', + navy: '&1', + green: '&2', + teal: '&3', + maroon: '&4', + purple: '&5', + gold: '&6', + silver: '&7', + gray: '&8', + blue: '&9', + lime: '&a', + aqua: '&b', + red: '&c', + pink: '&d', + yellow: '&e', + white: '&f' +} + +// aliases used by older code / MCGalaxy +const ALIASES = { + dark_blue: 'navy', + dark_green: 'green', + dark_red: 'maroon', + dark_yellow: 'gold', + grey: 'silver', + dark_gray: 'gray', + dark_grey: 'gray', + indigo: 'blue', + bright_green: 'lime', + cyan: 'aqua' +} + +const extraColorCodes = new Set() + +function isColorCode (ch) { + if (!ch) return false + return /[0-9a-fA-F]/.test(ch) || extraColorCodes.has(ch) +} + +function parseColor (name) { + if (!name) return null + name = String(name).toLowerCase() + if (/^[&%][0-9a-f]$/.test(name)) return '&' + name[1] + if (/^[0-9a-f]$/.test(name)) return '&' + name + if (COLORS[name]) return COLORS[name] + if (ALIASES[name]) return COLORS[ALIASES[name]] + for (const code of extraColorCodes) if (name === code) return '&' + code + return null +} + +// Convert player typed %c codes into &c codes +function convertPercentCodes (msg) { + return msg.replace(/%(.)/g, (m, c) => isColorCode(c) ? '&' + c.toLowerCase() : m) +} + +// Remove color codes that the client can't render (a trailing & crashes old clients) +function sanitize (msg) { + let out = '' + for (let i = 0; i < msg.length; i++) { + if (msg[i] === '&') { + if (isColorCode(msg[i + 1])) { out += '&' + msg[i + 1].toLowerCase(); i++ } + continue + } + out += msg[i] + } + return out +} + +function stripColors (msg) { + return String(msg).replace(/[&%]./g, '') +} + +function lastColor (line, fallback) { + let color = fallback + for (let i = 0; i < line.length - 1; i++) { + if (line[i] === '&' && isColorCode(line[i + 1])) color = '&' + line[i + 1] + } + return color +} + +// Split a message into lines of at most `max` characters, keeping colors across lines. +function wrap (message, max = 64, prefix = '> ') { + message = sanitize(String(message)) + const lines = [] + let color = '' + let line = '' + const push = (l) => { + // never end a line with a color code + l = l.replace(/(&.)+$/, '').replace(/\s+$/, '') + if (l.length) lines.push(l) + } + + for (const word of message.split(' ')) { + const candidate = line.length ? line + ' ' + word : word + if (candidate.length <= max) { line = candidate; continue } + + if (line.length) { + color = lastColor(line, color) + push(line) + // no need to repeat the color if the word sets its own + const ownColor = word[0] === '&' && isColorCode(word[1]) + line = prefix + (color === '&f' || ownColor ? '' : color) + word + } else { + line = word + } + while (line.length > max) { + let cut = max + if (line[cut - 1] === '&') cut-- + const part = line.slice(0, cut) + color = lastColor(part, color) + push(part) + line = prefix + (color === '&f' ? '' : color) + line.slice(cut) + } + } + push(line) + return lines +} + +function formatDuration (ms) { + const s = Math.floor(ms / 1000) + const parts = [] + const d = Math.floor(s / 86400); if (d) parts.push(d + 'd') + const h = Math.floor((s % 86400) / 3600); if (h) parts.push(h + 'h') + const m = Math.floor((s % 3600) / 60); if (m) parts.push(m + 'm') + const sec = s % 60; if (sec || !parts.length) parts.push(sec + 's') + return parts.join(' ') +} + +// "10m", "2h", "1d12h", "30" (seconds) -> milliseconds, or null +function parseDuration (str) { + if (!str) return null + const re = /(\d+)\s*([smhdw]?)/gi + let total = 0 + let matched = '' + let m + while ((m = re.exec(str)) !== null) { + matched += m[0] + const n = Number(m[1]) + const unit = (m[2] || 's').toLowerCase() + total += n * { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[unit] * 1000 + } + if (!matched || matched.replace(/\s/g, '') !== str.replace(/\s/g, '')) return null + return total +} + +module.exports = { + COLORS, + extraColorCodes, + isColorCode, + parseColor, + convertPercentCodes, + sanitize, + stripColors, + wrap, + formatDuration, + parseDuration +} diff --git a/package-lock.json b/package-lock.json index 0a707a9..35b849c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6601 +1,4205 @@ { "name": "mcscript", - "version": "1.0.1", - "lockfileVersion": 1, + "version": "2.0.0", + "lockfileVersion": 3, "requires": true, - "dependencies": { - "@babel/code-frame": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", - "integrity": "sha512-IGhtTmpjGbYzcEDOw7DcQtbQSXcG9ftmAXtWTu9V936vDye4xjjekktFAtgZsWpzTj/X01jocB46mTywm/4SZw==", - "dev": true, - "requires": { - "@babel/highlight": "^7.10.1" - } - }, - "@babel/core": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.10.1.tgz", - "integrity": "sha512-u8XiZ6sMXW/gPmoP5ijonSUln4unazG291X0XAQ5h0s8qnAFr6BRRZGUEK+jtRWdmB0NTJQt7Uga25q8GetIIg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.10.1", - "@babel/generator": "^7.10.1", - "@babel/helper-module-transforms": "^7.10.1", - "@babel/helpers": "^7.10.1", - "@babel/parser": "^7.10.1", - "@babel/template": "^7.10.1", - "@babel/traverse": "^7.10.1", - "@babel/types": "^7.10.1", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.1", - "json5": "^2.1.2", - "lodash": "^4.17.13", - "resolve": "^1.3.2", - "semver": "^5.4.1", - "source-map": "^0.5.0" - }, - "dependencies": { - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - } + "packages": { + "": { + "name": "mcscript", + "version": "2.0.0", + "license": "MIT", + "devDependencies": { + "standard": "^17.1.2" + }, + "engines": { + "node": ">=22.13" } }, - "@babel/generator": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.1.tgz", - "integrity": "sha512-AT0YPLQw9DI21tliuJIdplVfLHya6mcGa8ctkv7n4Qv+hYacJrKmNWIteAK1P9iyLikFIAkwqJ7HAOqIDLFfgA==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, - "requires": { - "@babel/types": "^7.10.1", - "jsesc": "^2.5.1", - "lodash": "^4.17.13", - "source-map": "^0.5.0" - }, + "license": "MIT", "dependencies": { - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - } + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "@babel/helper-function-name": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.1.tgz", - "integrity": "sha512-fcpumwhs3YyZ/ttd5Rz0xn0TpIwVkN7X0V38B9TWNfVF42KEkhkAAuPCQ3oXmtTRtiPJrmZ0TrfS0GKF0eMaRQ==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, - "requires": { - "@babel/helper-get-function-arity": "^7.10.1", - "@babel/template": "^7.10.1", - "@babel/types": "^7.10.1" + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "@babel/helper-get-function-arity": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.1.tgz", - "integrity": "sha512-F5qdXkYGOQUb0hpRaPoetF9AnsXknKjWMZ+wmsIRsp5ge5sFh4c3h1eH2pRTTuy9KKAA2+TTYomGXAtEL2fQEw==", + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "requires": { - "@babel/types": "^7.10.1" + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "@babel/helper-member-expression-to-functions": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.1.tgz", - "integrity": "sha512-u7XLXeM2n50gb6PWJ9hoO5oO7JFPaZtrh35t8RqKLT1jFKj9IWeD1zrcrYp1q1qiZTdEarfDWfTIP8nGsu0h5g==", + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "requires": { - "@babel/types": "^7.10.1" - } + "license": "Python-2.0" }, - "@babel/helper-module-imports": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.1.tgz", - "integrity": "sha512-SFxgwYmZ3HZPyZwJRiVNLRHWuW2OgE5k2nrVs6D9Iv4PPnXVffuEHy83Sfx/l4SqF+5kyJXjAyUmrG7tNm+qVg==", + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, - "requires": { - "@babel/types": "^7.10.1" + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "@babel/helper-module-transforms": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.10.1.tgz", - "integrity": "sha512-RLHRCAzyJe7Q7sF4oy2cB+kRnU4wDZY/H2xJFGof+M+SJEGhZsb+GFj5j1AD8NiSaVBJ+Pf0/WObiXu/zxWpFg==", + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.10.1", - "@babel/helper-replace-supers": "^7.10.1", - "@babel/helper-simple-access": "^7.10.1", - "@babel/helper-split-export-declaration": "^7.10.1", - "@babel/template": "^7.10.1", - "@babel/types": "^7.10.1", - "lodash": "^4.17.13" + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "@babel/helper-optimise-call-expression": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.1.tgz", - "integrity": "sha512-a0DjNS1prnBsoKx83dP2falChcs7p3i8VMzdrSbfLhuQra/2ENC4sbri34dz/rWmDADsmF1q5GbfaXydh0Jbjg==", + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, - "requires": { - "@babel/types": "^7.10.1" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "@babel/helper-plugin-utils": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", - "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", - "dev": true - }, - "@babel/helper-replace-supers": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz", - "integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==", + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "requires": { - "@babel/helper-member-expression-to-functions": "^7.10.1", - "@babel/helper-optimise-call-expression": "^7.10.1", - "@babel/traverse": "^7.10.1", - "@babel/types": "^7.10.1" - } + "license": "MIT" }, - "@babel/helper-simple-access": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.1.tgz", - "integrity": "sha512-VSWpWzRzn9VtgMJBIWTZ+GP107kZdQ4YplJlCmIrjoLVSi/0upixezHCDG8kpPVTBJpKfxTH01wDhh+jS2zKbw==", + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "requires": { - "@babel/template": "^7.10.1", - "@babel/types": "^7.10.1" + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "@babel/helper-split-export-declaration": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz", - "integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==", + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "requires": { - "@babel/types": "^7.10.1" + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "@babel/helper-validator-identifier": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.1.tgz", - "integrity": "sha512-5vW/JXLALhczRCWP0PnFDMCJAchlBvM7f4uk/jXritBnIa6E1KmqmtrS3yn1LAnxFBypQ3eneLuXjsnfQsgILw==", - "dev": true - }, - "@babel/helpers": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.1.tgz", - "integrity": "sha512-muQNHF+IdU6wGgkaJyhhEmI54MOZBKsFfsXFhboz1ybwJ1Kl7IHlbm2a++4jwrmY5UYsgitt5lfqo1wMFcHmyw==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, - "requires": { - "@babel/template": "^7.10.1", - "@babel/traverse": "^7.10.1", - "@babel/types": "^7.10.1" + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" } }, - "@babel/highlight": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.1.tgz", - "integrity": "sha512-8rMof+gVP8mxYZApLF/JgNDAkdKa+aJt3ZYxF8z6+j/hpeXL7iMsKCPHa2jNMHu/qqBwzQF4OHNoYi8dMA/rYg==", + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.10.1", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, + "license": "MIT", "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } + "optional": true } } }, - "@babel/parser": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.1.tgz", - "integrity": "sha512-AUTksaz3FqugBkbTZ1i+lDLG5qy8hIzCaAxEtttU6C0BtZZU9pkNZtWSVAht4EW9kl46YBiyTGMp9xTTGqViNg==", - "dev": true - }, - "@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } + "license": "MIT" }, - "@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "@babel/plugin-syntax-class-properties": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.1.tgz", - "integrity": "sha512-Gf2Yx/iRs1JREDtVZ56OrjjgFHCaldpTnuy9BHla10qyVT3YkIIGEtoDWhyop0ksu1GvNjHIoYRBqm3zoR1jyQ==", + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.1" - } + "license": "BSD-3-Clause" }, - "@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" } }, - "@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.1.tgz", - "integrity": "sha512-XyHIFa9kdrgJS91CUH+ccPVTnJShr8nLGc5bG2IhGXv5p1Rd+8BleGE5yzIg2Nc1QZAdHDa0Qp4m6066OL96Iw==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.1" + "license": "MIT", + "engines": { + "node": ">= 8" } }, - "@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" } }, - "@babel/plugin-syntax-numeric-separator": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.1.tgz", - "integrity": "sha512-uTd0OsHrpe3tH5gRPTxG8Voh99/WCU78vIm5NMRYPAqC8lR4vajt6KkCAknCHrx24vkPdd/05yfdGSB4EIY2mg==", + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.1" - } + "license": "MIT" + }, + "node_modules/@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true }, - "@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } + "license": "MIT" }, - "@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } + "license": "ISC" }, - "@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/acorn": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.2.0.tgz", + "integrity": "sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" } }, - "@babel/template": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.1.tgz", - "integrity": "sha512-OQDg6SqvFSsc9A0ej6SKINWrpJiNonRIniYondK2ViKhB06i3c0s+76XUft71iqBEe9S1OKsHwPAjfHnuvnCig==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "requires": { - "@babel/code-frame": "^7.10.1", - "@babel/parser": "^7.10.1", - "@babel/types": "^7.10.1" + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "@babel/traverse": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.1.tgz", - "integrity": "sha512-C/cTuXeKt85K+p08jN6vMDz8vSV0vZcI0wmQ36o6mjbuo++kPMdpOYw23W2XH04dbRt9/nMEfA4W3eR21CD+TQ==", + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "requires": { - "@babel/code-frame": "^7.10.1", - "@babel/generator": "^7.10.1", - "@babel/helper-function-name": "^7.10.1", - "@babel/helper-split-export-declaration": "^7.10.1", - "@babel/parser": "^7.10.1", - "@babel/types": "^7.10.1", - "debug": "^4.1.0", - "globals": "^11.1.0", - "lodash": "^4.17.13" - }, + "license": "MIT", "dependencies": { - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "@babel/types": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.1.tgz", - "integrity": "sha512-L2yqUOpf3tzlW9GVuipgLEcZxnO+96SzR6fjXMuxxNkIgFJ5+07mHCZ+HkHqaeZu8+3LKnNJJ1bKbjBETQAsrA==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.10.1", - "lodash": "^4.17.13", - "to-fast-properties": "^2.0.0" + "license": "MIT", + "engines": { + "node": ">=8" } }, - "@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "@cnakazawa/watch": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", - "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==", + "node_modules/ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", "dev": true, - "requires": { - "exec-sh": "^0.3.2", - "minimist": "^1.2.0" - }, "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - } + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "requires": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - } + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "@istanbuljs/schema": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", - "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "@jest/console": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-26.0.1.tgz", - "integrity": "sha512-9t1KUe/93coV1rBSxMmBAOIK3/HVpwxArCA1CxskKyRiv6o8J70V8C/V3OJminVCTa2M0hQI9AWRd5wxu2dAHw==", + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "jest-message-util": "^26.0.1", - "jest-util": "^26.0.1", - "slash": "^3.0.0" - } - }, - "@jest/core": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-26.0.1.tgz", - "integrity": "sha512-Xq3eqYnxsG9SjDC+WLeIgf7/8KU6rddBxH+SCt18gEpOhAGYC/Mq+YbtlNcIdwjnnT+wDseXSbU0e5X84Y4jTQ==", - "dev": true, - "requires": { - "@jest/console": "^26.0.1", - "@jest/reporters": "^26.0.1", - "@jest/test-result": "^26.0.1", - "@jest/transform": "^26.0.1", - "@jest/types": "^26.0.1", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-changed-files": "^26.0.1", - "jest-config": "^26.0.1", - "jest-haste-map": "^26.0.1", - "jest-message-util": "^26.0.1", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.0.1", - "jest-resolve-dependencies": "^26.0.1", - "jest-runner": "^26.0.1", - "jest-runtime": "^26.0.1", - "jest-snapshot": "^26.0.1", - "jest-util": "^26.0.1", - "jest-validate": "^26.0.1", - "jest-watcher": "^26.0.1", - "micromatch": "^4.0.2", - "p-each-series": "^2.1.0", - "rimraf": "^3.0.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@jest/environment": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-26.0.1.tgz", - "integrity": "sha512-xBDxPe8/nx251u0VJ2dFAFz2H23Y98qdIaNwnMK6dFQr05jc+Ne/2np73lOAx+5mSBO/yuQldRrQOf6hP1h92g==", + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", "dev": true, - "requires": { - "@jest/fake-timers": "^26.0.1", - "@jest/types": "^26.0.1", - "jest-mock": "^26.0.1" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@jest/fake-timers": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.0.1.tgz", - "integrity": "sha512-Oj/kCBnTKhm7CR+OJSjZty6N1bRDr9pgiYQr4wY221azLz5PHi08x/U+9+QpceAYOWheauLP8MhtSVFrqXQfhg==", + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "@sinonjs/fake-timers": "^6.0.1", - "jest-message-util": "^26.0.1", - "jest-mock": "^26.0.1", - "jest-util": "^26.0.1" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@jest/globals": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-26.0.1.tgz", - "integrity": "sha512-iuucxOYB7BRCvT+TYBzUqUNuxFX1hqaR6G6IcGgEqkJ5x4htNKo1r7jk1ji9Zj8ZMiMw0oB5NaA7k5Tx6MVssA==", + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, - "requires": { - "@jest/environment": "^26.0.1", - "@jest/types": "^26.0.1", - "expect": "^26.0.1" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@jest/reporters": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-26.0.1.tgz", - "integrity": "sha512-NWWy9KwRtE1iyG/m7huiFVF9YsYv/e+mbflKRV84WDoJfBqUrNRyDbL/vFxQcYLl8IRqI4P3MgPn386x76Gf2g==", + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, - "requires": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^26.0.1", - "@jest/test-result": "^26.0.1", - "@jest/transform": "^26.0.1", - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.2", - "graceful-fs": "^4.2.4", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^4.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", - "jest-haste-map": "^26.0.1", - "jest-resolve": "^26.0.1", - "jest-util": "^26.0.1", - "jest-worker": "^26.0.0", - "node-notifier": "^7.0.0", - "slash": "^3.0.0", - "source-map": "^0.6.0", - "string-length": "^4.0.1", - "terminal-link": "^2.0.0", - "v8-to-istanbul": "^4.1.3" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@jest/source-map": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-26.0.0.tgz", - "integrity": "sha512-S2Z+Aj/7KOSU2TfW0dyzBze7xr95bkm5YXNUqqCek+HE0VbNNSNzrRwfIi5lf7wvzDTSS0/ib8XQ1krFNyYgbQ==", + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, - "requires": { - "callsites": "^3.0.0", - "graceful-fs": "^4.2.4", - "source-map": "^0.6.0" - }, + "license": "MIT", "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@jest/test-result": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-26.0.1.tgz", - "integrity": "sha512-oKwHvOI73ICSYRPe8WwyYPTtiuOAkLSbY8/MfWF3qDEd/sa8EDyZzin3BaXTqufir/O/Gzea4E8Zl14XU4Mlyg==", + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, - "requires": { - "@jest/console": "^26.0.1", - "@jest/types": "^26.0.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "@jest/test-sequencer": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.0.1.tgz", - "integrity": "sha512-ssga8XlwfP8YjbDcmVhwNlrmblddMfgUeAkWIXts1V22equp2GMIHxm7cyeD5Q/B0ZgKPK/tngt45sH99yLLGg==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, - "requires": { - "@jest/test-result": "^26.0.1", - "graceful-fs": "^4.2.4", - "jest-haste-map": "^26.0.1", - "jest-runner": "^26.0.1", - "jest-runtime": "^26.0.1" - }, + "license": "MIT", "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@jest/transform": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.0.1.tgz", - "integrity": "sha512-pPRkVkAQ91drKGbzCfDOoHN838+FSbYaEAvBXvKuWeeRRUD8FjwXkqfUNUZL6Ke48aA/1cqq/Ni7kVMCoqagWA==", + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, - "requires": { - "@babel/core": "^7.1.0", - "@jest/types": "^26.0.1", - "babel-plugin-istanbul": "^6.0.0", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.2.4", - "jest-haste-map": "^26.0.1", - "jest-regex-util": "^26.0.0", - "jest-util": "^26.0.1", - "micromatch": "^4.0.2", - "pirates": "^4.0.1", - "slash": "^3.0.0", - "source-map": "^0.6.1", - "write-file-atomic": "^3.0.0" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, - "@jest/types": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.0.1.tgz", - "integrity": "sha512-IbtjvqI9+eS1qFnOIEL7ggWmT+iK/U+Vde9cGWtYb/b6XgKb3X44ZAe/z9YZzoAAZ/E92m0DqrilF934IGNnQA==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^1.1.1", - "@types/yargs": "^15.0.0", - "chalk": "^4.0.0" + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@sinonjs/commons": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.0.tgz", - "integrity": "sha512-wEj54PfsZ5jGSwMX68G8ZXFawcSglQSXqCftWX3ec8MDUzQdHgcKvw97awHbY0efQEL5iKUOAmmVtoYgmrSG4Q==", + "node_modules/balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, - "requires": { - "type-detect": "4.0.8" + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "@sinonjs/fake-timers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", - "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "node_modules/builtins": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.1.0.tgz", + "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", "dev": true, - "requires": { - "@sinonjs/commons": "^1.7.0" + "license": "MIT", + "dependencies": { + "semver": "^7.0.0" } }, - "@types/babel__core": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.7.tgz", - "integrity": "sha512-RL62NqSFPCDK2FM1pSDH0scHpJvsXtZNiYlMB73DgPBaG1E38ZYVL+ei5EkWRbr+KC4YNiAUNBnRj+bgwpgjMw==", + "node_modules/builtins/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "@types/babel__generator": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.1.tgz", - "integrity": "sha512-bBKm+2VPJcMRVwNhxKu8W+5/zT7pwNEqeokFOmbvVSqGzFneNxYcEBro9Ac7/N9tlsaPYnZLK8J1LWKkMsLAew==", + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, - "requires": { - "@babel/types": "^7.0.0" + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@types/babel__template": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.0.2.tgz", - "integrity": "sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "@types/babel__traverse": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.11.tgz", - "integrity": "sha512-ddHK5icION5U6q11+tV2f9Mo6CZVuT8GJKld2q9LqHSZbvLbH34Kcu2yFGckZut453+eQU6btIA3RihmnRgI+Q==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, - "requires": { - "@babel/types": "^7.3.0" + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@types/color-name": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", - "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", - "dev": true + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } }, - "@types/graceful-fs": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.3.tgz", - "integrity": "sha512-AiHRaEB50LQg0pZmm659vNBb9f4SJ0qrAnteuzhSeAUcJKxoYgEnprg/83kppCnc2zvtCKbdZry1a5pVY3lOTQ==", + "node_modules/chalk": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.0.0.tgz", + "integrity": "sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==", "dev": true, - "requires": { - "@types/node": "*" + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "@types/istanbul-lib-coverage": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.2.tgz", - "integrity": "sha512-rsZg7eL+Xcxsxk2XlBt9KcG8nOp9iYdKCOikY9x2RFJCyOdNj4MKPQty0e8oZr29vVAzKXr1BmR+kZauti3o1w==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, - "@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "*" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@types/istanbul-reports": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz", - "integrity": "sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==", + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "*", - "@types/istanbul-lib-report": "*" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" } }, - "@types/node": { - "version": "14.0.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.5.tgz", - "integrity": "sha512-90hiq6/VqtQgX8Sp0EzeIsv3r+ellbGj4URKj5j30tLlZvRUpnAe9YbYnjl3pJM93GyXU0tghHhvXHq+5rnCKA==", - "dev": true - }, - "@types/normalize-package-data": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", - "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", - "dev": true - }, - "@types/prettier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.1.tgz", - "integrity": "sha512-boy4xPNEtiw6N3abRhBi/e7hNvy3Tt8E9ZRAQrwAGzoCGZS/1wjo9KY7JHhnfnEsG5wSjDbymCozUM9a3ea7OQ==", - "dev": true - }, - "@types/stack-utils": { + "node_modules/data-view-byte-offset": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", - "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", - "dev": true - }, - "@types/yargs": { - "version": "15.0.5", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.5.tgz", - "integrity": "sha512-Dk/IDOPtOgubt/IaevIUbTgV7doaKkoorvOyYM2CMwuDyP89bekI7H4xLIwunNYiK9jhCkmc6pUrJk3cj2AB9w==", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, - "requires": { - "@types/yargs-parser": "*" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "@types/yargs-parser": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", - "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==", - "dev": true - }, - "abab": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", - "integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==", - "dev": true - }, - "acorn": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.2.0.tgz", - "integrity": "sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ==", + "node_modules/deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, - "acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, - "requires": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "acorn-jsx": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.2.0.tgz", - "integrity": "sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ==", - "dev": true - }, - "acorn-walk": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.1.1.tgz", - "integrity": "sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==", - "dev": true - }, - "ajv": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", - "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", - "requires": { - "fast-deep-equal": "^2.0.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "ansi-escapes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", - "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "requires": { - "type-fest": "^0.11.0" - }, + "license": "Apache-2.0", "dependencies": { - "type-fest": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", - "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", - "dev": true - } + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" } }, - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - }, + "license": "MIT", "dependencies": { - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - } + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" } }, - "anymatch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", - "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "dependencies": { + "is-arrayish": "^0.2.1" } }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, - "requires": { - "sprintf-js": "~1.0.2" + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", - "dev": true - }, - "arr-flatten": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", - "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", - "dev": true - }, - "arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", - "dev": true - }, - "array-includes": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz", - "integrity": "sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==", + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.0", - "is-string": "^1.0.5" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", - "dev": true - }, - "asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "requires": { - "safer-buffer": "~2.1.0" + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - }, - "assign-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", - "dev": true - }, - "astral-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", - "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", - "dev": true - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true - }, - "atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "dev": true - }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true - }, - "aws4": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz", - "integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==", - "dev": true + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, - "babel-jest": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.0.1.tgz", - "integrity": "sha512-Z4GGmSNQ8pX3WS1O+6v3fo41YItJJZsVxG5gIQ+HuB/iuAQBJxMTHTwz292vuYws1LnHfwSRgoqI+nxdy/pcvw==", - "dev": true, - "requires": { - "@jest/transform": "^26.0.1", - "@jest/types": "^26.0.1", - "@types/babel__core": "^7.1.7", - "babel-plugin-istanbul": "^6.0.0", - "babel-preset-jest": "^26.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "slash": "^3.0.0" - }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" } }, - "babel-plugin-istanbul": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz", - "integrity": "sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ==", + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^4.0.0", - "test-exclude": "^6.0.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" } }, - "babel-plugin-jest-hoist": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.0.0.tgz", - "integrity": "sha512-+AuoehOrjt9irZL7DOt2+4ZaTM6dlu1s5TTS46JBa0/qem4dy7VNW3tMb96qeEqcIh20LD73TVNtmVEeymTG7w==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, - "requires": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__traverse": "^7.0.6" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "babel-preset-current-node-syntax": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-0.1.2.tgz", - "integrity": "sha512-u/8cS+dEiK1SFILbOC8/rUI3ml9lboKuuMvZ/4aQnQmhecQAgPw5ew066C1ObnEAUmlx7dv/s2z52psWEtLNiw==", + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, - "requires": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "babel-preset-jest": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.0.0.tgz", - "integrity": "sha512-9ce+DatAa31DpR4Uir8g4Ahxs5K4W4L8refzt+qHWQANb6LhGcAEfIFgLUwk67oya2cCUd6t4eUMtO/z64ocNw==", + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, - "requires": { - "babel-plugin-jest-hoist": "^26.0.0", - "babel-preset-current-node-syntax": "^0.1.2" + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "babel-runtime": { - "version": "5.8.38", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz", - "integrity": "sha1-HAsC62MxL18If/IEUIJ7QlydTBk=", - "requires": { - "core-js": "^1.0.0" + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true - }, - "base": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", - "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", - "dev": true, - "requires": { - "cache-base": "^1.0.1", - "class-utils": "^0.3.5", - "component-emitter": "^1.2.1", - "define-property": "^1.0.0", - "isobject": "^3.0.1", - "mixin-deep": "^1.2.0", - "pascalcase": "^0.1.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } + "node_modules/eslint-config-standard": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-promise": "^6.0.0" + } + }, + "node_modules/eslint-config-standard-jsx": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard-jsx/-/eslint-config-standard-jsx-11.0.0.tgz", + "integrity": "sha512-+1EV/R0JxEK1L0NGolAr8Iktm3Rgotx3BKwgaX+eAuSX8D952LULKtjgZD3F+e6SvibONnhLwoTi9DPxN5LvvQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } + { + "type": "patreon", + "url": "https://www.patreon.com/feross" }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } + { + "type": "consulting", + "url": "https://feross.org/support" } + ], + "license": "MIT", + "peerDependencies": { + "eslint": "^8.8.0", + "eslint-plugin-react": "^7.28.0" } }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, - "requires": { - "tweetnacl": "^0.14.3" + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" } }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "node_modules/eslint-import-resolver-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "requires": { - "fill-range": "^7.0.1" - } + "license": "MIT" }, - "browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", - "dev": true - }, - "bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, - "requires": { - "node-int64": "^0.4.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true - }, - "cache-base": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", - "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", - "dev": true, - "requires": { - "collection-visit": "^1.0.0", - "component-emitter": "^1.2.1", - "get-value": "^2.0.6", - "has-value": "^1.0.0", - "isobject": "^3.0.1", - "set-value": "^2.0.0", - "to-object-path": "^0.3.0", - "union-value": "^1.0.0", - "unset-value": "^1.0.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - }, - "capture-exit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", - "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==", + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, - "requires": { - "rsvp": "^4.8.4" + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true - }, - "chalk": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.0.0.tgz", - "integrity": "sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==", + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" } }, - "char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true - }, - "chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true + "node_modules/eslint-module-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, - "ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true + "node_modules/eslint-plugin-es": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz", + "integrity": "sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } }, - "class-utils": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", - "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", - "dev": true, - "requires": { - "arr-union": "^3.1.0", - "define-property": "^0.2.5", - "isobject": "^3.0.0", - "static-extend": "^0.1.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - } + "node_modules/eslint-plugin-es/node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" } }, - "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/eslint-plugin-es/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true, - "requires": { - "restore-cursor": "^3.1.0" + "license": "Apache-2.0", + "engines": { + "node": ">=4" } }, - "cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==", - "dev": true + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } }, - "cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" } }, - "co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", - "dev": true + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } }, - "collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", - "dev": true + "node_modules/eslint-plugin-import/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, - "collection-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", - "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "requires": { - "map-visit": "^1.0.0", - "object-visit": "^1.0.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/eslint-plugin-n": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.7.0.tgz", + "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, - "requires": { - "color-name": "1.1.3" + "license": "MIT", + "dependencies": { + "builtins": "^5.0.1", + "eslint-plugin-es": "^4.1.0", + "eslint-utils": "^3.0.0", + "ignore": "^5.1.1", + "is-core-module": "^2.11.0", + "minimatch": "^3.1.2", + "resolve": "^1.22.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/eslint-plugin-n/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, - "requires": { - "delayed-stream": "~1.0.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "node_modules/eslint-plugin-n/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, - "contains-path": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", - "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", - "dev": true + "node_modules/eslint-plugin-promise": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.6.0.tgz", + "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } }, - "convert-source-map": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", - "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, - "requires": { - "safe-buffer": "~5.1.1" + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - } + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" } }, - "copy-descriptor": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", - "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", - "dev": true + "node_modules/eslint-plugin-react/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, - "core-js": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", - "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } }, - "cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "requires": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "cssom": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", - "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", - "dev": true + "node_modules/eslint-scope/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, - "cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", "dev": true, - "requires": { - "cssom": "~0.3.6" - }, + "license": "MIT", "dependencies": { - "cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true - } + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" } }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true, - "requires": { - "assert-plus": "^1.0.0" + "license": "Apache-2.0", + "engines": { + "node": ">=10" } }, - "data-urls": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", - "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "requires": { - "abab": "^2.0.3", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0" + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/eslint/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "requires": { - "ms": "2.0.0" + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" } }, - "debug-log": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/debug-log/-/debug-log-1.0.1.tgz", - "integrity": "sha1-IwdjLUwEOCuN+KMvcLiVBG1SdF8=", - "dev": true - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", - "dev": true - }, - "decimal.js": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.0.tgz", - "integrity": "sha512-vDPw+rDgn3bZe1+F/pyEwb1oMG2XTlRVgAa6B4KccTEpYgF8w6eQllVbQcfIJnZyvzFtFpxnpGtx8dd7DJp/Rw==", - "dev": true - }, - "decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true - }, - "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", - "dev": true - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true - }, - "define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, - "requires": { - "object-keys": "^1.0.12" - } - }, - "define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "requires": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "dependencies": { - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - } - } - }, - "deglob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/deglob/-/deglob-4.0.1.tgz", - "integrity": "sha512-/g+RDZ7yf2HvoW+E5Cy+K94YhgcFgr6C8LuHZD1O5HoNPkf3KY6RfXJ0DBGlB/NkLi5gml+G9zqRzk9S0mHZCg==", - "dev": true, - "requires": { - "find-root": "^1.0.0", - "glob": "^7.0.5", - "ignore": "^5.0.0", - "pkg-config": "^1.1.0", - "run-parallel": "^1.1.2", - "uniq": "^1.0.1" - }, - "dependencies": { - "ignore": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.6.tgz", - "integrity": "sha512-cgXgkypZBcCnOgSihyeqbo6gjIaIyDqPQB7Ra4vhE9m6kigdGoQDMHjviFhRZo3IMlRy6yElosoviMs5YxZXUA==", - "dev": true + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true } } }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true - }, - "detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true - }, - "diff-sequences": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.0.0.tgz", - "integrity": "sha512-JC/eHYEC3aSS0vZGjuoc4vHA0yAQTzhQQldXMeMF+JlxLGJlCO38Gma82NV9gk1jGFz8mDzUMeaKXvjRRdJ2dg==", - "dev": true - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "domexception": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", - "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "requires": { - "webidl-conversions": "^5.0.0" + "license": "MIT", + "engines": { + "node": ">=10" }, - "dependencies": { - "webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "dev": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "dotenv": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", - "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==", - "dev": true - }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, - "requires": { - "once": "^1.4.0" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "node_modules/eslint/node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "requires": { - "is-arrayish": "^0.2.1" + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" } }, - "es-abstract": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", - "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "requires": { - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1", - "is-callable": "^1.1.5", - "is-regex": "^1.0.5", - "object-inspect": "^1.7.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.0", - "string.prototype.trimleft": "^2.1.1", - "string.prototype.trimright": "^2.1.1" - } + "license": "MIT" }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "node_modules/eslint/node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" } }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "escodegen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.1.tgz", - "integrity": "sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ==", + "node_modules/eslint/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "requires": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" + "license": "MIT", + "engines": { + "node": ">=8" } }, - "eslint": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", - "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", + "node_modules/eslint/node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "ajv": "^6.10.0", - "chalk": "^2.1.0", - "cross-spawn": "^6.0.5", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^1.4.3", - "eslint-visitor-keys": "^1.1.0", - "espree": "^6.1.2", - "esquery": "^1.0.1", - "esutils": "^2.0.2", - "file-entry-cache": "^5.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", - "globals": "^12.1.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "inquirer": "^7.0.0", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.3.0", - "lodash": "^4.17.14", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.1", - "natural-compare": "^1.4.0", - "optionator": "^0.8.3", - "progress": "^2.0.0", - "regexpp": "^2.0.1", - "semver": "^6.1.2", - "strip-ansi": "^5.2.0", - "strip-json-comments": "^3.0.1", - "table": "^5.2.3", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", - "dev": true - }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "globals": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", - "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", - "dev": true, - "requires": { - "type-fest": "^0.8.1" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } + "license": "MIT", + "engines": { + "node": ">= 0.8.0" } }, - "eslint-config-standard": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-14.1.1.tgz", - "integrity": "sha512-Z9B+VR+JIXRxz21udPTL9HpFMyoMUEeX1G251EQ6e05WD9aPVtVBn09XUmZ259wCMlCDmYDSZG62Hhm+ZTJcUg==", - "dev": true - }, - "eslint-config-standard-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-standard-jsx/-/eslint-config-standard-jsx-8.1.0.tgz", - "integrity": "sha512-ULVC8qH8qCqbU792ZOO6DaiaZyHNS/5CZt3hKqHkEhVlhPEPN3nfBqqxJCyp59XrjIBZPu1chMYe9T2DXZ7TMw==", - "dev": true - }, - "eslint-import-resolver-node": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz", - "integrity": "sha512-b8crLDo0M5RSe5YG8Pu2DYBj71tSB6OvXkfzwbJU2w7y8P4/yo0MyF8jU26IEuEuHF2K5/gcAJE3LhQGqBBbVg==", + "node_modules/eslint/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "requires": { - "debug": "^2.6.9", - "resolve": "^1.13.1" - }, + "license": "MIT", "dependencies": { - "resolve": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", - "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", - "dev": true, - "requires": { - "path-parse": "^1.0.6" - } - } + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "eslint-module-utils": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz", - "integrity": "sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==", + "node_modules/eslint/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "requires": { - "debug": "^2.6.9", - "pkg-dir": "^2.0.0" + "license": "MIT", + "engines": { + "node": ">=8" } }, - "eslint-plugin-es": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-2.0.0.tgz", - "integrity": "sha512-f6fceVtg27BR02EYnBhgWLFQfK6bN4Ll0nQFrBHOlCsAyxeZkn0NHns5O0YZOPrV1B3ramd6cgFwaoFLcSkwEQ==", + "node_modules/eslint/node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "requires": { - "eslint-utils": "^1.4.2", - "regexpp": "^3.0.0" - }, + "license": "MIT", "dependencies": { - "regexpp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", - "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", - "dev": true - } + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" } }, - "eslint-plugin-import": { - "version": "2.18.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.18.2.tgz", - "integrity": "sha512-5ohpsHAiUBRNaBWAF08izwUGlbrJoJJ+W9/TBwsGoR1MnlgfwMIKrFeSjWbt6moabiXW9xNvtFz+97KHRfI4HQ==", + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "requires": { - "array-includes": "^3.0.3", - "contains-path": "^0.1.0", - "debug": "^2.6.9", - "doctrine": "1.5.0", - "eslint-import-resolver-node": "^0.3.2", - "eslint-module-utils": "^2.4.0", - "has": "^1.0.3", - "minimatch": "^3.0.4", - "object.values": "^1.1.0", - "read-pkg-up": "^2.0.0", - "resolve": "^1.11.0" - }, - "dependencies": { - "doctrine": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", - "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", - "dev": true, - "requires": { - "esutils": "^2.0.2", - "isarray": "^1.0.0" - } - }, - "load-json-file": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", - "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "parse-json": "^2.2.0", - "pify": "^2.0.0", - "strip-bom": "^3.0.0" - } - }, - "path-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", - "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", - "dev": true, - "requires": { - "pify": "^2.0.0" - } - }, - "read-pkg": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", - "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", - "dev": true, - "requires": { - "load-json-file": "^2.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^2.0.0" - } - }, - "read-pkg-up": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", - "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", - "dev": true, - "requires": { - "find-up": "^2.0.0", - "read-pkg": "^2.0.0" - } - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true - } + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "eslint-plugin-node": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-10.0.0.tgz", - "integrity": "sha512-1CSyM/QCjs6PXaT18+zuAXsjXGIGo5Rw630rSKwokSs2jrYURQc4R5JZpoanNCqwNmepg+0eZ9L7YiRUJb8jiQ==", + "node_modules/eslint/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "requires": { - "eslint-plugin-es": "^2.0.0", - "eslint-utils": "^1.4.2", - "ignore": "^5.1.1", - "minimatch": "^3.0.4", - "resolve": "^1.10.1", - "semver": "^6.1.0" - }, + "license": "ISC", "dependencies": { - "ignore": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.6.tgz", - "integrity": "sha512-cgXgkypZBcCnOgSihyeqbo6gjIaIyDqPQB7Ra4vhE9m6kigdGoQDMHjviFhRZo3IMlRy6yElosoviMs5YxZXUA==", - "dev": true - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" } }, - "eslint-plugin-promise": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz", - "integrity": "sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==", - "dev": true - }, - "eslint-plugin-react": { - "version": "7.14.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.14.3.tgz", - "integrity": "sha512-EzdyyBWC4Uz2hPYBiEJrKCUi2Fn+BJ9B/pJQcjw5X+x/H2Nm59S4MJIvL4O5NEE0+WbnQwEBxWY03oUk+Bc3FA==", + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "requires": { - "array-includes": "^3.0.3", - "doctrine": "^2.1.0", - "has": "^1.0.3", - "jsx-ast-utils": "^2.1.0", - "object.entries": "^1.1.0", - "object.fromentries": "^2.0.0", - "object.values": "^1.1.0", - "prop-types": "^15.7.2", - "resolve": "^1.10.1" - }, - "dependencies": { - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - } + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "eslint-plugin-standard": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz", - "integrity": "sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==", - "dev": true - }, - "eslint-scope": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", - "integrity": "sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==", + "node_modules/espree/node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, - "requires": { - "esrecurse": "^4.1.0", - "estraverse": "^4.1.1" + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" } }, - "eslint-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", - "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" } }, - "eslint-visitor-keys": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz", - "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==", - "dev": true - }, - "espree": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "requires": { - "acorn": "^7.1.1", - "acorn-jsx": "^5.2.0", - "eslint-visitor-keys": "^1.1.0" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true - }, - "esquery": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", - "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "requires": { - "estraverse": "^5.1.0" - }, + "license": "BSD-2-Clause", "dependencies": { - "estraverse": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz", - "integrity": "sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==", - "dev": true - } + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" } }, - "esrecurse": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", - "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "requires": { - "estraverse": "^4.1.0" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, - "esutils": { + "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "exec-sh": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", - "integrity": "sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A==", - "dev": true - }, - "execa": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", - "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", - "dev": true, - "requires": { - "cross-spawn": "^6.0.0", - "get-stream": "^4.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", - "dev": true - }, - "expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", - "dev": true, - "requires": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "expect": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-26.0.1.tgz", - "integrity": "sha512-QcCy4nygHeqmbw564YxNbHTJlXh47dVID2BUP52cZFpLU9zHViMFK6h07cC1wf7GYCTIigTdAXhVua8Yl1FkKg==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "ansi-styles": "^4.0.0", - "jest-get-type": "^26.0.0", - "jest-matcher-utils": "^26.0.1", - "jest-message-util": "^26.0.1", - "jest-regex-util": "^26.0.0" + "engines": { + "node": ">=0.10.0" } }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", - "dev": true, - "requires": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "dependencies": { - "is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4" - } - } - } - }, - "external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "requires": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - } - }, - "extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "requires": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - } - } - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "license": "MIT" }, - "fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" - }, - "fast-json-stable-stringify": { + "node_modules/fast-json-stable-stringify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", - "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true }, - "fast-levenshtein": { + "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, - "fb-watchman": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", - "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, - "requires": { - "bser": "2.1.1" + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" } }, - "figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "requires": { - "escape-string-regexp": "^1.0.5" + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" } }, - "file-entry-cache": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", - "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "requires": { - "flat-cache": "^2.0.1" + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "requires": { - "to-regex-range": "^5.0.1" + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" } }, - "find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true - }, - "find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, - "requires": { - "locate-path": "^2.0.0" - } + "license": "ISC" }, - "flat-cache": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", - "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", - "dev": true, - "requires": { - "flatted": "^2.0.0", - "rimraf": "2.6.3", - "write": "1.0.3" - }, - "dependencies": { - "rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - } + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "flatted": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", - "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", - "dev": true - }, - "for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", - "dev": true - }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "fragment-cache": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", - "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, - "requires": { - "map-cache": "^0.2.2" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "fsevents": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", - "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, - "optional": true + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "gensync": { - "version": "1.0.0-beta.1", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", - "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, - "get-stdin": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", - "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", - "dev": true + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, - "requires": { - "pump": "^3.0.0" + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" } }, - "get-value": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", - "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", - "dev": true + "node_modules/get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, - "requires": { - "assert-plus": "^1.0.0" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "glob": { + "node_modules/glob": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "requires": { + "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" } }, - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } }, - "graceful-fs": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", - "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==", - "dev": true + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "growly": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", - "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "optional": true + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" }, - "har-validator": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", - "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, - "requires": { - "ajv": "^6.5.5", - "har-schema": "^2.0.0" - } + "license": "MIT" }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, - "requires": { - "function-bind": "^1.1.1" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "has-flag": { + "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "has-symbols": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", - "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", - "dev": true - }, - "has-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", - "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", "dev": true, - "requires": { - "get-value": "^2.0.6", - "has-values": "^1.0.0", - "isobject": "^3.0.0" + "engines": { + "node": ">=8" } }, - "has-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", - "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", - "dev": true, - "requires": { - "is-number": "^3.0.0", - "kind-of": "^4.0.0" - }, - "dependencies": { - "is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "kind-of": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", - "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "hash-base": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", - "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "hosted-git-info": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.4.tgz", - "integrity": "sha512-pzXIvANXEFrc5oFFXRMkbLPQ2rXRoDERwDLyrcUxGhaZhgP54BBSl9Oheh7Vv0T090cszWBxPjkQQ5Sq1PbBRQ==", - "dev": true - }, - "html-encoding-sniffer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", - "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, - "requires": { - "whatwg-encoding": "^1.0.5" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true - }, - "iconv": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/iconv/-/iconv-2.2.3.tgz", - "integrity": "sha1-4ITWDut9c9p/CpwJbkyKvgkL+u0=", - "optional": true, - "requires": { - "nan": "^2.3.5" + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" + "license": "MIT", + "engines": { + "node": ">= 4" } }, - "ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "import-fresh": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", - "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "import-local": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz", - "integrity": "sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==", - "dev": true, - "requires": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "requires": { - "find-up": "^4.0.0" - } - } - } - }, - "imurmurhash": { + "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true + "dev": true, + "engines": { + "node": ">=0.8.19" + } }, - "inflight": { + "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "requires": { + "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, - "inherits": { + "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, - "inquirer": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz", - "integrity": "sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^3.0.0", - "cli-cursor": "^3.1.0", - "cli-width": "^2.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.15", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.5.3", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" - }, - "dependencies": { - "ansi-escapes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", - "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", - "dev": true, - "requires": { - "type-fest": "^0.11.0" - } - }, - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "type-fest": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", - "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", - "dev": true - } + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" } }, - "ip-regex": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", - "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", - "dev": true - }, - "irc": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/irc/-/irc-0.5.2.tgz", - "integrity": "sha1-NxT0doNlqW0LL3dryRFmvrJGS7w=", - "requires": { - "iconv": "~2.2.1", - "irc-colors": "^1.1.0", - "node-icu-charset-detector": "~0.2.0" - } - }, - "irc-colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/irc-colors/-/irc-colors-1.5.0.tgz", - "integrity": "sha512-HtszKchBQTcqw1DC09uD7i7vvMayHGM1OCo6AHt5pkgZEyo99ClhHTMJdf+Ezc9ovuNNxcH89QfyclGthjZJOw==" - }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-arrayish": { + "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, - "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true - }, - "is-callable": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", - "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", - "dev": true - }, - "is-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", - "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, - "requires": { - "ci-info": "^2.0.0" + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, + "license": "MIT", "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-date-object": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", - "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", - "dev": true - }, - "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, - "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" - }, + "license": "MIT", "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-docker": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz", - "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, - "optional": true - }, - "is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, - "requires": { - "isobject": "^3.0.1" + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-potential-custom-element-name": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz", - "integrity": "sha1-DFLlS8yjkbssSUsh6GJtczbG45c=", - "dev": true - }, - "is-regex": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", - "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, - "requires": { - "has": "^1.0.3" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-stream": { + "node_modules/is-date-object": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true - }, - "is-string": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", - "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", - "dev": true - }, - "is-symbol": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", - "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, - "requires": { - "has-symbols": "^1.0.1" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-typedarray": { + "node_modules/is-document.all": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true - }, - "is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", "dev": true, - "optional": true, - "requires": { - "is-docker": "^2.0.0" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", - "dev": true - }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true - }, - "istanbul-lib-coverage": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", - "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", - "dev": true + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "istanbul-lib-instrument": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", - "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, - "requires": { - "@babel/core": "^7.7.5", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.0.0", - "semver": "^6.3.0" - }, + "license": "MIT", "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.0" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "istanbul-lib-source-maps": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", - "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "dependencies": { - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" } }, - "istanbul-reports": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", - "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-26.0.1.tgz", - "integrity": "sha512-29Q54kn5Bm7ZGKIuH2JRmnKl85YRigp0o0asTc6Sb6l2ch1DCXIeZTLLFy9ultJvhkTqbswF5DEx4+RlkmCxWg==", + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, - "requires": { - "@jest/core": "^26.0.1", - "import-local": "^3.0.2", - "jest-cli": "^26.0.1" + "license": "MIT", + "engines": { + "node": ">= 0.4" }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - }, - "jest-cli": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-26.0.1.tgz", - "integrity": "sha512-pFLfSOBcbG9iOZWaMK4Een+tTxi/Wcm34geqZEqrst9cZDkTQ1LZ2CnBrTlHWuYAiTMFr0EQeK52ScyFU8wK+w==", - "dev": true, - "requires": { - "@jest/core": "^26.0.1", - "@jest/test-result": "^26.0.1", - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "import-local": "^3.0.2", - "is-ci": "^2.0.0", - "jest-config": "^26.0.1", - "jest-util": "^26.0.1", - "jest-validate": "^26.0.1", - "prompts": "^2.0.1", - "yargs": "^15.3.1" - } - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-changed-files": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.0.1.tgz", - "integrity": "sha512-q8LP9Sint17HaE2LjxQXL+oYWW/WeeXMPE2+Op9X3mY8IEGFVc14xRxFjUuXUbcPAlDLhtWdIEt59GdQbn76Hw==", + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "execa": "^4.0.0", - "throat": "^5.0.0" - }, + "license": "MIT", "dependencies": { - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "execa": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.0.2.tgz", - "integrity": "sha512-QI2zLa6CjGWdiQsmSkZoGtDx2N+cQIGb3yNolGTdjSQzydzLgYYf8LRuagp7S7fPimjcrzUDSUFd/MgzELMi4Q==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, - "get-stream": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", - "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, - "is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "dev": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "requires": { - "path-key": "^3.0.0" - } - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "jest-config": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.0.1.tgz", - "integrity": "sha512-9mWKx2L1LFgOXlDsC4YSeavnblN6A4CPfXFiobq+YYLaBMymA/SczN7xYTSmLaEYHZOcB98UdoN4m5uNt6tztg==", - "dev": true, - "requires": { - "@babel/core": "^7.1.0", - "@jest/test-sequencer": "^26.0.1", - "@jest/types": "^26.0.1", - "babel-jest": "^26.0.1", - "chalk": "^4.0.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.1", - "graceful-fs": "^4.2.4", - "jest-environment-jsdom": "^26.0.1", - "jest-environment-node": "^26.0.1", - "jest-get-type": "^26.0.0", - "jest-jasmine2": "^26.0.1", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.0.1", - "jest-util": "^26.0.1", - "jest-validate": "^26.0.1", - "micromatch": "^4.0.2", - "pretty-format": "^26.0.1" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } - } - }, - "jest-diff": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.0.1.tgz", - "integrity": "sha512-odTcHyl5X+U+QsczJmOjWw5tPvww+y9Yim5xzqxVl/R1j4z71+fHW4g8qu1ugMmKdFdxw+AtQgs5mupPnzcIBQ==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^26.0.0", - "jest-get-type": "^26.0.0", - "pretty-format": "^26.0.1" - } - }, - "jest-docblock": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz", - "integrity": "sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==", - "dev": true, - "requires": { - "detect-newline": "^3.0.0" - } - }, - "jest-each": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-26.0.1.tgz", - "integrity": "sha512-OTgJlwXCAR8NIWaXFL5DBbeS4QIYPuNASkzSwMCJO+ywo9BEa6TqkaSWsfR7VdbMLdgYJqSfQcIyjJCNwl5n4Q==", - "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "jest-get-type": "^26.0.0", - "jest-util": "^26.0.1", - "pretty-format": "^26.0.1" - } - }, - "jest-environment-jsdom": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.0.1.tgz", - "integrity": "sha512-u88NJa3aptz2Xix2pFhihRBAatwZHWwSiRLBDBQE1cdJvDjPvv7ZGA0NQBxWwDDn7D0g1uHqxM8aGgfA9Bx49g==", - "dev": true, - "requires": { - "@jest/environment": "^26.0.1", - "@jest/fake-timers": "^26.0.1", - "@jest/types": "^26.0.1", - "jest-mock": "^26.0.1", - "jest-util": "^26.0.1", - "jsdom": "^16.2.2" - } - }, - "jest-environment-node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.0.1.tgz", - "integrity": "sha512-4FRBWcSn5yVo0KtNav7+5NH5Z/tEgDLp7VRQVS5tCouWORxj+nI+1tOLutM07Zb2Qi7ja+HEDoOUkjBSWZg/IQ==", - "dev": true, - "requires": { - "@jest/environment": "^26.0.1", - "@jest/fake-timers": "^26.0.1", - "@jest/types": "^26.0.1", - "jest-mock": "^26.0.1", - "jest-util": "^26.0.1" - } - }, - "jest-get-type": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.0.0.tgz", - "integrity": "sha512-zRc1OAPnnws1EVfykXOj19zo2EMw5Hi6HLbFCSjpuJiXtOWAYIjNsHVSbpQ8bDX7L5BGYGI8m+HmKdjHYFF0kg==", - "dev": true - }, - "jest-haste-map": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.0.1.tgz", - "integrity": "sha512-J9kBl/EdjmDsvyv7CiyKY5+DsTvVOScenprz/fGqfLg/pm1gdjbwwQ98nW0t+OIt+f+5nAVaElvn/6wP5KO7KA==", - "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "@types/graceful-fs": "^4.1.2", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.1.2", - "graceful-fs": "^4.2.4", - "jest-serializer": "^26.0.0", - "jest-util": "^26.0.1", - "jest-worker": "^26.0.0", - "micromatch": "^4.0.2", - "sane": "^4.0.3", - "walker": "^1.0.7", - "which": "^2.0.2" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "jest-jasmine2": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.0.1.tgz", - "integrity": "sha512-ILaRyiWxiXOJ+RWTKupzQWwnPaeXPIoLS5uW41h18varJzd9/7I0QJGqg69fhTT1ev9JpSSo9QtalriUN0oqOg==", - "dev": true, - "requires": { - "@babel/traverse": "^7.1.0", - "@jest/environment": "^26.0.1", - "@jest/source-map": "^26.0.0", - "@jest/test-result": "^26.0.1", - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "co": "^4.6.0", - "expect": "^26.0.1", - "is-generator-fn": "^2.0.0", - "jest-each": "^26.0.1", - "jest-matcher-utils": "^26.0.1", - "jest-message-util": "^26.0.1", - "jest-runtime": "^26.0.1", - "jest-snapshot": "^26.0.1", - "jest-util": "^26.0.1", - "pretty-format": "^26.0.1", - "throat": "^5.0.0" - } - }, - "jest-leak-detector": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.0.1.tgz", - "integrity": "sha512-93FR8tJhaYIWrWsbmVN1pQ9ZNlbgRpfvrnw5LmgLRX0ckOJ8ut/I35CL7awi2ecq6Ca4lL59bEK9hr7nqoHWPA==", - "dev": true, - "requires": { - "jest-get-type": "^26.0.0", - "pretty-format": "^26.0.1" - } - }, - "jest-matcher-utils": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.0.1.tgz", - "integrity": "sha512-PUMlsLth0Azen8Q2WFTwnSkGh2JZ8FYuwijC8NR47vXKpsrKmA1wWvgcj1CquuVfcYiDEdj985u5Wmg7COEARw==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "jest-diff": "^26.0.1", - "jest-get-type": "^26.0.0", - "pretty-format": "^26.0.1" - } - }, - "jest-message-util": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.0.1.tgz", - "integrity": "sha512-CbK8uQREZ8umUfo8+zgIfEt+W7HAHjQCoRaNs4WxKGhAYBGwEyvxuK81FXa7VeB9pwDEXeeKOB2qcsNVCAvB7Q==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "@jest/types": "^26.0.1", - "@types/stack-utils": "^1.0.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.2", - "slash": "^3.0.0", - "stack-utils": "^2.0.2" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-mock": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-26.0.1.tgz", - "integrity": "sha512-MpYTBqycuPYSY6xKJognV7Ja46/TeRbAZept987Zp+tuJvMN0YBWyyhG9mXyYQaU3SBI0TUlSaO5L3p49agw7Q==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, - "requires": { - "@jest/types": "^26.0.1" + "license": "MIT", + "engines": { + "node": ">=8" } }, - "jest-pnp-resolver": { + "node_modules/is-regex": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz", - "integrity": "sha512-pgFw2tm54fzgYvc/OHrnysABEObZCUNFnhjoRjaVOCN8NYc032/gVjPaHD4Aq6ApkSieWtfKAFQtmDKAmhupnQ==", - "dev": true - }, - "jest-regex-util": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz", - "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==", - "dev": true - }, - "jest-resolve": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.0.1.tgz", - "integrity": "sha512-6jWxk0IKZkPIVTvq6s72RH735P8f9eCJW3IM5CX/SJFeKq1p2cZx0U49wf/SdMlhaB/anann5J2nCJj6HrbezQ==", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "jest-pnp-resolver": "^1.2.1", - "jest-util": "^26.0.1", - "read-pkg-up": "^7.0.1", - "resolve": "^1.17.0", - "slash": "^3.0.0" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - }, - "resolve": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", - "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", - "dev": true, - "requires": { - "path-parse": "^1.0.6" - } - } + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-resolve-dependencies": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.0.1.tgz", - "integrity": "sha512-9d5/RS/ft0vB/qy7jct/qAhzJsr6fRQJyGAFigK3XD4hf9kIbEH5gks4t4Z7kyMRhowU6HWm/o8ILqhaHdSqLw==", + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "jest-regex-util": "^26.0.0", - "jest-snapshot": "^26.0.1" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-runner": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-26.0.1.tgz", - "integrity": "sha512-CApm0g81b49Znm4cZekYQK67zY7kkB4umOlI2Dx5CwKAzdgw75EN+ozBHRvxBzwo1ZLYZ07TFxkaPm+1t4d8jA==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, - "requires": { - "@jest/console": "^26.0.1", - "@jest/environment": "^26.0.1", - "@jest/test-result": "^26.0.1", - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-config": "^26.0.1", - "jest-docblock": "^26.0.0", - "jest-haste-map": "^26.0.1", - "jest-jasmine2": "^26.0.1", - "jest-leak-detector": "^26.0.1", - "jest-message-util": "^26.0.1", - "jest-resolve": "^26.0.1", - "jest-runtime": "^26.0.1", - "jest-util": "^26.0.1", - "jest-worker": "^26.0.0", - "source-map-support": "^0.5.6", - "throat": "^5.0.0" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } - } - }, - "jest-runtime": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.0.1.tgz", - "integrity": "sha512-Ci2QhYFmANg5qaXWf78T2Pfo6GtmIBn2rRaLnklRyEucmPccmCKvS9JPljcmtVamsdMmkyNkVFb9pBTD6si9Lw==", - "dev": true, - "requires": { - "@jest/console": "^26.0.1", - "@jest/environment": "^26.0.1", - "@jest/fake-timers": "^26.0.1", - "@jest/globals": "^26.0.1", - "@jest/source-map": "^26.0.0", - "@jest/test-result": "^26.0.1", - "@jest/transform": "^26.0.1", - "@jest/types": "^26.0.1", - "@types/yargs": "^15.0.0", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.4", - "jest-config": "^26.0.1", - "jest-haste-map": "^26.0.1", - "jest-message-util": "^26.0.1", - "jest-mock": "^26.0.1", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.0.1", - "jest-snapshot": "^26.0.1", - "jest-util": "^26.0.1", - "jest-validate": "^26.0.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0", - "yargs": "^15.3.1" - }, - "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-serializer": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.0.0.tgz", - "integrity": "sha512-sQGXLdEGWFAE4wIJ2ZaIDb+ikETlUirEOBsLXdoBbeLhTHkZUJwgk3+M8eyFizhM6le43PDCCKPA1hzkSDo4cQ==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, - "requires": { - "graceful-fs": "^4.2.4" - }, + "license": "MIT", "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-snapshot": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.0.1.tgz", - "integrity": "sha512-jxd+cF7+LL+a80qh6TAnTLUZHyQoWwEHSUFJjkw35u3Gx+BZUNuXhYvDqHXr62UQPnWo2P6fvQlLjsU93UKyxA==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, - "requires": { - "@babel/types": "^7.0.0", - "@jest/types": "^26.0.1", - "@types/prettier": "^2.0.0", - "chalk": "^4.0.0", - "expect": "^26.0.1", - "graceful-fs": "^4.2.4", - "jest-diff": "^26.0.1", - "jest-get-type": "^26.0.0", - "jest-matcher-utils": "^26.0.1", - "jest-message-util": "^26.0.1", - "jest-resolve": "^26.0.1", - "make-dir": "^3.0.0", - "natural-compare": "^1.4.0", - "pretty-format": "^26.0.1", - "semver": "^7.3.2" - }, + "license": "MIT", "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - }, - "semver": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", - "dev": true - } + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-util": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.0.1.tgz", - "integrity": "sha512-byQ3n7ad1BO/WyFkYvlWQHTsomB6GIewBh8tlGtusiylAlaxQ1UpS0XYH0ngOyhZuHVLN79Qvl6/pMiDMSSG1g==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "is-ci": "^2.0.0", - "make-dir": "^3.0.0" - }, + "license": "MIT", "dependencies": { - "graceful-fs": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true - } + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-validate": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-26.0.1.tgz", - "integrity": "sha512-u0xRc+rbmov/VqXnX3DlkxD74rHI/CfS5xaV2VpeaVySjbb1JioNVOyly5b56q2l9ZKe7bVG5qWmjfctkQb0bA==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "camelcase": "^6.0.0", - "chalk": "^4.0.0", - "jest-get-type": "^26.0.0", - "leven": "^3.1.0", - "pretty-format": "^26.0.1" + "license": "MIT", + "engines": { + "node": ">= 0.4" }, - "dependencies": { - "camelcase": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz", - "integrity": "sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==", - "dev": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-watcher": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.0.1.tgz", - "integrity": "sha512-pdZPydsS8475f89kGswaNsN3rhP6lnC3/QDCppP7bg1L9JQz7oU9Mb/5xPETk1RHDCWeqmVC47M4K5RR7ejxFw==", + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, - "requires": { - "@jest/test-result": "^26.0.1", - "@jest/types": "^26.0.1", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "jest-util": "^26.0.1", - "string-length": "^4.0.1" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "jest-worker": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.0.0.tgz", - "integrity": "sha512-pPaYa2+JnwmiZjK9x7p9BoZht+47ecFCDFA/CJxspHzeDvQcfVBLWzCiWyo+EGrSiQMWZtCFo9iSvMZnAAo8vw==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, - "requires": { - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "js-logger": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/js-logger/-/js-logger-1.6.0.tgz", - "integrity": "sha512-K4kt2AdD0jUYINbe00BPPpsL65u/rdYOgfaBBVWm/mid+ANk7qxDnoXgKI5ilm49Sjmach2Dzlc+5VxKdRA3tw==" - }, - "js-tokens": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, - "js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" }, - "jsdom": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.2.2.tgz", - "integrity": "sha512-pDFQbcYtKBHxRaP55zGXCJWgFHkDAYbKcsXEK/3Icu9nKYZkutUXfLBwbD+09XDutkYSHcgfQLZ0qvpAAm9mvg==", - "dev": true, - "requires": { - "abab": "^2.0.3", - "acorn": "^7.1.1", - "acorn-globals": "^6.0.0", - "cssom": "^0.4.4", - "cssstyle": "^2.2.0", - "data-urls": "^2.0.0", - "decimal.js": "^10.2.0", - "domexception": "^2.0.1", - "escodegen": "^1.14.1", - "html-encoding-sniffer": "^2.0.1", - "is-potential-custom-element-name": "^1.0.0", - "nwsapi": "^2.2.0", - "parse5": "5.1.1", - "request": "^2.88.2", - "request-promise-native": "^1.0.8", - "saxes": "^5.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^3.0.1", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^2.0.0", - "webidl-conversions": "^6.0.0", - "whatwg-encoding": "^1.0.5", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0", - "ws": "^7.2.3", - "xml-name-validator": "^3.0.0" - } - }, - "jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" }, - "json-parse-better-errors": { + "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, - "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true - }, - "json-schema-traverse": { + "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, - "json-stable-stringify-without-jsonify": { + "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" }, - "json5": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", - "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, - "requires": { - "minimist": "^1.2.5" - }, + "license": "MIT", "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - } + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" } }, - "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.2.3", - "verror": "1.10.0" + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" } }, - "jsx-ast-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.3.0.tgz", - "integrity": "sha512-3HNoc7nZ1hpZIKB3hJ7BlFRkzCx2BynRtfSwbkqZdpRdvAPsGMnzclPwrvDBS7/lalHTj21NwIeaEpysHBOudg==", + "node_modules/load-json-file": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz", + "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==", "dev": true, - "requires": { - "array-includes": "^3.1.1", - "object.assign": "^4.1.0" + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.15", + "parse-json": "^4.0.0", + "pify": "^4.0.1", + "strip-bom": "^3.0.0", + "type-fest": "^0.3.0" + }, + "engines": { + "node": ">=6" } }, - "kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true - }, - "kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true - }, - "leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true - }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "node_modules/load-json-file/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" + "license": "MIT", + "engines": { + "node": ">=4" } }, - "lines-and-columns": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", - "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", - "dev": true - }, - "locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "node_modules/load-json-file/node_modules/type-fest": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz", + "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==", "dev": true, - "requires": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=6" } }, - "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true - }, - "lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "lodash.merge": { + "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "lodash.reduce": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", - "integrity": "sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs=" - }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "loose-envify": { + "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" } }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, - "requires": { - "semver": "^6.0.0" - }, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "makeerror": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", - "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, - "requires": { - "tmpl": "1.0.x" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "map-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", - "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", "dev": true, - "requires": { - "object-visit": "^1.0.0" + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "micromatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", - "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, - "requires": { - "braces": "^3.0.1", - "picomatch": "^2.0.5" + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "mime-db": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", - "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==", - "dev": true - }, - "mime-types": { - "version": "2.1.27", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", - "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, - "requires": { - "mime-db": "1.44.0" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, - "minecraft-classic-protocol": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/minecraft-classic-protocol/-/minecraft-classic-protocol-1.3.1.tgz", - "integrity": "sha1-Qkavun/7B15dOBWikD5p4R1jE40=", - "requires": { - "babel-runtime": "^5.4.4", - "lodash.get": "^4.3.0", - "lodash.merge": "^4.4.0", - "minecraft-data": "^1.1.0", - "protodef": "^1.2.2" + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, - "minecraft-classic-protocol-extension": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minecraft-classic-protocol-extension/-/minecraft-classic-protocol-extension-1.0.3.tgz", - "integrity": "sha512-QsFHD2WtqZD6FMQTD+NtCmNlvmVKRF2CCFUTJAFSYUpPXsvkO8u5tVfZ5JK5ENttveStVvBZT1JQj1OvkeHWWw==", - "requires": { - "minecraft-classic-protocol": "^1.3.0" + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "minecraft-data": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-1.6.0.tgz", - "integrity": "sha1-0u5PeXy8u/W7CoF05OvvAxDKouA=" - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, - "requires": { - "brace-expansion": "^1.1.7" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" } }, - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - }, - "mixin-deep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", - "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", - "dev": true, - "requires": { - "for-in": "^1.0.2", - "is-extendable": "^1.0.1" - }, - "dependencies": { - "is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4" - } - } + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, - "requires": { - "minimist": "0.0.8" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" } }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "nan": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", - "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==", - "optional": true - }, - "nanomatch": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", - "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", - "dev": true, - "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "fragment-cache": "^0.2.1", - "is-windows": "^1.0.2", - "kind-of": "^6.0.2", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - } - }, - "natural-compare": { + "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true - }, - "node-fetch": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" - }, - "node-icu-charset-detector": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/node-icu-charset-detector/-/node-icu-charset-detector-0.2.0.tgz", - "integrity": "sha1-wjINo3Tdy2cfxUy0oOBB4Vb/1jk=", - "optional": true, - "requires": { - "nan": "^2.3.3" + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "dependencies": { + "wrappy": "1" } }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", - "dev": true - }, - "node-modules-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", - "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", - "dev": true - }, - "node-notifier": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-7.0.1.tgz", - "integrity": "sha512-VkzhierE7DBmQEElhTGJIoiZa1oqRijOtgOlsXg32KrJRXsPy0NXFBqWGW/wTswnJlDCs5viRYaqWguqzsKcmg==", - "dev": true, - "optional": true, - "requires": { - "growly": "^1.3.0", - "is-wsl": "^2.1.1", - "semver": "^7.2.1", - "shellwords": "^0.1.1", - "uuid": "^7.0.3", - "which": "^2.0.2" - }, - "dependencies": { - "semver": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", - "dev": true, - "optional": true - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "optional": true, - "requires": { - "isexe": "^2.0.0" - } - } + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "requires": { - "path-key": "^2.0.0" + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "nwsapi": { + "node_modules/p-try": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", - "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", - "dev": true - }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true - }, - "object-copy": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", - "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", - "dev": true, - "requires": { - "copy-descriptor": "^0.1.0", - "define-property": "^0.2.5", - "kind-of": "^3.0.3" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "object-inspect": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", - "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object-visit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", - "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", - "dev": true, - "requires": { - "isobject": "^3.0.0" - } - }, - "object.assign": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", - "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", - "dev": true, - "requires": { - "define-properties": "^1.1.2", - "function-bind": "^1.1.1", - "has-symbols": "^1.0.0", - "object-keys": "^1.0.11" - } - }, - "object.entries": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.2.tgz", - "integrity": "sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA==", - "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5", - "has": "^1.0.3" - } - }, - "object.fromentries": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.2.tgz", - "integrity": "sha512-r3ZiBH7MQppDJVLx6fhD618GKNG40CZYH9wgwdhKxBDDbQgjeWGGd4AtkZad84d291YxvWe7bJGuE65Anh0dxQ==", - "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.0-next.1", - "function-bind": "^1.1.1", - "has": "^1.0.3" - } - }, - "object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - }, - "object.values": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz", - "integrity": "sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==", - "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.0-next.1", - "function-bind": "^1.1.1", - "has": "^1.0.3" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "onetime": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", - "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" + "license": "MIT", + "engines": { + "node": ">=6" } }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true - }, - "p-each-series": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.1.0.tgz", - "integrity": "sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ==", - "dev": true - }, - "p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true - }, - "p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "requires": { - "p-try": "^1.0.0" - } - }, - "p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "dev": true, - "requires": { - "p-limit": "^1.1.0" - } - }, - "p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", - "dev": true - }, - "parent-module": { + "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "callsites": "^3.0.0" }, - "dependencies": { - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - } + "engines": { + "node": ">=6" } }, - "parse-json": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", - "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", "dev": true, - "requires": { - "error-ex": "^1.2.0" + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" } }, - "parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "dev": true - }, - "pascalcase": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", - "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "path-is-absolute": { + "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", - "dev": true - }, - "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", - "dev": true - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true - }, - "picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" }, - "pirates": { + "node_modules/pify": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz", - "integrity": "sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", "dev": true, - "requires": { - "node-modules-regexp": "^1.0.0" + "license": "MIT", + "engines": { + "node": ">=6" } }, - "pkg-conf": { + "node_modules/pkg-conf": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz", "integrity": "sha512-m0OTbR/5VPNPqO1ph6Fqbj7Hv6QU7gR/tQW40ZqrL1rjgCU85W6C1bJn0BItuJqnR98PWzw7Z8hHeChD1WrgdQ==", "dev": true, - "requires": { + "license": "MIT", + "dependencies": { "find-up": "^3.0.0", "load-json-file": "^5.2.0" }, - "dependencies": { - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "load-json-file": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz", - "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.15", - "parse-json": "^4.0.0", - "pify": "^4.0.1", - "strip-bom": "^3.0.0", - "type-fest": "^0.3.0" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", - "dev": true, - "requires": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - } - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true - }, - "type-fest": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz", - "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==", - "dev": true - } + "engines": { + "node": ">=6" } }, - "pkg-config": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pkg-config/-/pkg-config-1.1.1.tgz", - "integrity": "sha1-VX7yLXPaPIg3EHdmxS6tq94pj+Q=", + "node_modules/pkg-conf/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", "dev": true, - "requires": { - "debug-log": "^1.0.0", - "find-root": "^1.0.0", - "xtend": "^4.0.1" + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" } }, - "pkg-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", - "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", "dev": true, - "requires": { - "find-up": "^2.1.0" + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" } }, - "posix-character-classes": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", - "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", - "dev": true - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", - "dev": true - }, - "pretty-format": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.0.1.tgz", - "integrity": "sha512-SWxz6MbupT3ZSlL0Po4WF/KujhQaVehijR2blyRDCzk9e45EaYMVhMBn49fnRuHxtkSpXTes1GxNpVmH86Bxfw==", + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, - "requires": { - "@jest/types": "^26.0.1", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", - "react-is": "^16.12.0" + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true - }, - "prompts": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.2.tgz", - "integrity": "sha512-Q06uKs2CkNYVID0VqwfAl9mipo99zkBv/n2JtWY89Yxa3ZabWSrs0e2KTudKVa3peLUvYXMefDqIleLPVUBZMA==", + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", "dev": true, - "requires": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.4" + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" } }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" + "license": "MIT", + "engines": { + "node": ">=4" } }, - "protodef": { - "version": "1.6.10", - "resolved": "https://registry.npmjs.org/protodef/-/protodef-1.6.10.tgz", - "integrity": "sha512-tsTML1o/J568/FuX5oevS5pcbYbjTmgjm8GeJUYjw37y+5Rt0JhpL5KHQFub274z6xaUKqB3x8Gvfz3RjibfIA==", - "requires": { - "lodash.get": "^4.4.2", - "lodash.reduce": "^4.6.0", - "protodef-validator": "^1.2.2", - "readable-stream": "^3.0.3" - } - }, - "protodef-validator": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.2.3.tgz", - "integrity": "sha512-dMcSMYRh8s0z0iQN0PLVlXwJOgN8cgBuM1uWzhMjkLdpKCOASwp+h7wHnTigBTRVhGLywykcb3EKiGSsXX4vvA==", - "requires": { - "ajv": "^6.5.4" + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, - "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", - "dev": true - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, - "punycode": { + "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - }, - "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "dev": true - }, - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - }, - "read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "dev": true, - "requires": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "dependencies": { - "parse-json": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", - "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1", - "lines-and-columns": "^1.1.6" - } - }, - "type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true - } + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true, + "engines": { + "node": ">=6" } }, - "read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, - "requires": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } + { + "type": "patreon", + "url": "https://www.patreon.com/feross" }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true + { + "type": "consulting", + "url": "https://feross.org/support" } - } + ], + "license": "MIT" }, - "readable-stream": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", - "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "regex-not": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", - "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", - "dev": true, - "requires": { - "extend-shallow": "^3.0.2", - "safe-regex": "^1.1.0" - } - }, - "regexpp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", - "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", - "dev": true - }, - "remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", - "dev": true - }, - "repeat-element": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", - "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", - "dev": true - }, - "repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "dev": true, - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "dependencies": { - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true - } - } - }, - "request-promise-core": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", - "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, - "requires": { - "lodash": "^4.17.15" - } - }, - "request-promise-native": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.8.tgz", - "integrity": "sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==", - "dev": true, - "requires": { - "request-promise-core": "1.1.3", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - }, - "dependencies": { - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - } + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true - }, - "require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, - "requireindex": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", - "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==" - }, - "resolve": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", - "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, - "requires": { - "path-parse": "^1.0.6" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true, - "requires": { - "resolve-from": "^5.0.0" + "license": "MIT", + "engines": { + "node": ">=8" }, - "dependencies": { - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - } + "funding": { + "url": "https://github.com/sponsors/mysticatea" } }, - "resolve-from": { + "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "resolve-url": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", - "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", - "dev": true - }, - "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, - "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "license": "MIT", + "engines": { + "node": ">=4" } }, - "ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } }, - "rimraf": { + "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "requires": { + "dependencies": { "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "rsvp": { - "version": "4.8.5", - "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", - "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", - "dev": true - }, - "run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true - }, - "run-parallel": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", - "integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==", - "dev": true - }, - "rxjs": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", - "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, - "safe-buffer": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", - "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" - }, - "safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", - "dev": true, - "requires": { - "ret": "~0.1.10" - } - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "sane": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz", - "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==", - "dev": true, - "requires": { - "@cnakazawa/watch": "^1.0.3", - "anymatch": "^2.0.0", - "capture-exit": "^2.0.0", - "exec-sh": "^0.3.2", - "execa": "^1.0.0", - "fb-watchman": "^2.0.0", - "micromatch": "^3.1.4", - "minimist": "^1.1.1", - "walker": "~1.0.5" - }, - "dependencies": { - "anymatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", - "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", - "dev": true, - "requires": { - "micromatch": "^3.1.4", - "normalize-path": "^2.1.1" - } - }, - "braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "requires": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - } - }, - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - }, - "normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", - "dev": true, - "requires": { - "remove-trailing-separator": "^1.0.1" - } - }, - "to-regex-range": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", - "dev": true, - "requires": { - "is-number": "^3.0.0", - "repeat-string": "^1.6.1" - } - } - } - }, - "saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", - "dev": true, - "requires": { - "xmlchars": "^2.2.0" - } - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true - }, - "set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "shebang-command": { + "node_modules/run-parallel": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", - "dev": true - }, - "shellwords": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", - "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, - "optional": true - }, - "signal-exit": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", - "dev": true - }, - "sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "slice-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", - "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "astral-regex": "^1.0.0", - "is-fullwidth-code-point": "^2.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - } - } - }, - "snapdragon": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", - "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", - "dev": true, - "requires": { - "base": "^0.11.1", - "debug": "^2.2.0", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "map-cache": "^0.2.2", - "source-map": "^0.5.6", - "source-map-resolve": "^0.5.0", - "use": "^3.1.0" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true - } - } - }, - "snapdragon-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", - "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", - "dev": true, - "requires": { - "define-property": "^1.0.0", - "isobject": "^3.0.0", - "snapdragon-util": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "is-accessor-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } + { + "type": "patreon", + "url": "https://www.patreon.com/feross" }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } + { + "type": "consulting", + "url": "https://feross.org/support" } - } - }, - "snapdragon-util": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", - "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", - "dev": true, - "requires": { - "kind-of": "^3.2.0" - }, + ], + "license": "MIT", "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "queue-microtask": "^1.2.2" } }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "source-map-resolve": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", - "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, - "requires": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0", - "resolve-url": "^0.2.1", - "source-map-url": "^0.4.0", - "urix": "^0.1.0" - } - }, - "source-map-support": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", - "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "source-map-url": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", - "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", - "dev": true - }, - "spdx-correct": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", - "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "spdx-exceptions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", - "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==", - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", - "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", - "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", - "dev": true + "license": "MIT" }, - "split-string": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", - "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, - "requires": { - "extend-shallow": "^3.0.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true - }, - "sshpk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", - "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } + "license": "MIT" }, - "stack-utils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.2.tgz", - "integrity": "sha512-0H7QK2ECz3fyZMzQ8rH0j2ykpfbnd20BFtfg/SqVC2+sCTtcw0aDTGB7dk+de4U4uUeuz6nOtJcrkFFLG1B0Rg==", + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, - "requires": { - "escape-string-regexp": "^2.0.0" - }, + "license": "MIT", "dependencies": { - "escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true - } - } - }, - "standard": { - "version": "14.3.4", - "resolved": "https://registry.npmjs.org/standard/-/standard-14.3.4.tgz", - "integrity": "sha512-+lpOkFssMkljJ6eaILmqxHQ2n4csuEABmcubLTb9almFi1ElDzXb1819fjf/5ygSyePCq4kU2wMdb2fBfb9P9Q==", - "dev": true, - "requires": { - "eslint": "~6.8.0", - "eslint-config-standard": "14.1.1", - "eslint-config-standard-jsx": "8.1.0", - "eslint-plugin-import": "~2.18.0", - "eslint-plugin-node": "~10.0.0", - "eslint-plugin-promise": "~4.2.1", - "eslint-plugin-react": "~7.14.2", - "eslint-plugin-standard": "~4.0.0", - "standard-engine": "^12.0.0" - } - }, - "standard-engine": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/standard-engine/-/standard-engine-12.1.0.tgz", - "integrity": "sha512-DVJnWM1CGkag4ucFLGdiYWa5/kJURPONmMmk17p8FT5NE4UnPZB1vxWnXnRo2sPSL78pWJG8xEM+1Tu19z0deg==", - "dev": true, - "requires": { - "deglob": "^4.0.1", - "get-stdin": "^7.0.0", - "minimist": "^1.2.5", - "pkg-conf": "^3.1.0" - }, - "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - } - } - }, - "static-extend": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", - "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", - "dev": true, - "requires": { - "define-property": "^0.2.5", - "object-copy": "^0.1.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - } - } - }, - "stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", - "dev": true - }, - "string-length": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.1.tgz", - "integrity": "sha512-PKyXUd0LK0ePjSOnWn34V2uD6acUWev9uy0Ft05k0E8xRW+SKcA0F7eMr7h5xlzfn+4O3N+55rduYyet3Jk+jw==", - "dev": true, - "requires": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "string.prototype.trimend": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", - "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "string.prototype.trimleft": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", - "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5", - "string.prototype.trimstart": "^1.0.0" + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" } }, - "string.prototype.trimright": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", - "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5", - "string.prototype.trimend": "^1.0.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "string.prototype.trimstart": { + "node_modules/side-channel-list": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", - "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, - "requires": { - "ansi-regex": "^5.0.0" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true - }, - "strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", - "dev": true - }, - "strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true - }, - "strip-json-comments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz", - "integrity": "sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w==", - "dev": true - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, - "requires": { - "has-flag": "^4.0.0" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "supports-hyperlinks": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz", - "integrity": "sha512-zoE5/e+dnEijk6ASB6/qrK+oYdm2do1hjoLWrqUC/8WEIW1gbxFcKuBof7sW8ArN6e+AYvsE8HBGiVRWL/F5CA==", + "node_modules/standard": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.2.tgz", + "integrity": "sha512-WLm12WoXveKkvnPnPnaFUUHuOB2cUdAsJ4AiGHL2G0UNMrcRAWY2WriQaV8IQ3oRmYr0AWUbLNr94ekYFAHOrA==", "dev": true, - "requires": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "eslint": "^8.41.0", + "eslint-config-standard": "17.1.0", + "eslint-config-standard-jsx": "^11.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-n": "^15.7.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.36.1", + "standard-engine": "^15.1.0", + "version-guard": "^1.1.1" + }, + "bin": { + "standard": "bin/cmd.cjs" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true - }, - "table": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", - "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", + "node_modules/standard-engine": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/standard-engine/-/standard-engine-15.1.0.tgz", + "integrity": "sha512-VHysfoyxFu/ukT+9v49d4BRXIokFRZuH3z1VRxzFArZdjSCFpro6rEIU3ji7e4AoAtuSfKBkiOmsrDqKW5ZSRw==", "dev": true, - "requires": { - "ajv": "^6.10.2", - "lodash": "^4.17.14", - "slice-ansi": "^2.1.0", - "string-width": "^3.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", - "dev": true - }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } + { + "type": "patreon", + "url": "https://www.patreon.com/feross" }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } + { + "type": "consulting", + "url": "https://feross.org/support" } + ], + "license": "MIT", + "dependencies": { + "get-stdin": "^8.0.0", + "minimist": "^1.2.6", + "pkg-conf": "^3.1.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" } }, - "test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", "dev": true, - "requires": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "dev": true - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, - "requires": { - "os-tmpdir": "~1.0.2" + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" } }, - "tmpl": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", - "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", - "dev": true - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", - "dev": true - }, - "to-object-path": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", - "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, + "license": "MIT", "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "to-regex": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", - "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, - "requires": { - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "regex-not": "^1.0.2", - "safe-regex": "^1.1.0" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, - "requires": { - "is-number": "^7.0.0" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "tough-cookie": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", - "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "requires": { - "ip-regex": "^2.1.0", - "psl": "^1.1.28", - "punycode": "^2.1.1" + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "tr46": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz", - "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "requires": { - "punycode": "^2.1.1" + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "tslib": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", - "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==", - "dev": true - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "node_modules/supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", "dev": true, - "requires": { - "safe-buffer": "^5.0.1" + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, - "requires": { - "prelude-ls": "~1.1.2" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, - "requires": { - "is-typedarray": "^1.0.0" + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" } }, - "union-value": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", - "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, - "requires": { - "arr-union": "^3.1.0", - "get-value": "^2.0.6", - "is-extendable": "^0.1.1", - "set-value": "^2.0.1" + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" } }, - "uniq": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", - "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", - "dev": true - }, - "unset-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", - "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", - "dev": true, - "requires": { - "has-value": "^0.3.1", - "isobject": "^3.0.0" - }, - "dependencies": { - "has-value": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", - "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", - "dev": true, - "requires": { - "get-value": "^2.0.3", - "has-values": "^0.1.4", - "isobject": "^2.0.0" - }, - "dependencies": { - "isobject": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", - "dev": true, - "requires": { - "isarray": "1.0.0" - } - } - } - }, - "has-values": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", - "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", - "dev": true - } + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, - "uri-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", - "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", - "requires": { - "punycode": "^2.1.0" + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" } }, - "urix": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", - "dev": true - }, - "use": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", - "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", - "dev": true - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "uuid": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", - "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, - "optional": true - }, - "v8-compile-cache": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz", - "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==", - "dev": true + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "v8-to-istanbul": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-4.1.4.tgz", - "integrity": "sha512-Rw6vJHj1mbdK8edjR7+zuJrpDtKIgNdAvTSAcpYfgMIw+u2dPDntD3dgN4XQFLU2/fvFQdzj+EeSGfd/jnY5fQ==", + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0", - "source-map": "^0.7.3" - }, + "license": "MIT", "dependencies": { - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true - } + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "vec3": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.4.tgz", - "integrity": "sha512-4+xyZSb8ZYa8QkPeYWg+2N34ZSI2ckFPjMAXT876c78hMkvYOh8wLjEE3DLSVlUrQef2jbvnQZdYULV4iacDrQ==" - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "node_modules/uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", "dev": true, - "requires": { - "browser-process-hrtime": "^1.0.0" + "dependencies": { + "punycode": "^2.1.0" } }, - "w3c-xmlserializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", - "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "node_modules/version-guard": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/version-guard/-/version-guard-1.1.3.tgz", + "integrity": "sha512-JwPr6erhX53EWH/HCSzfy1tTFrtPXUe927wdM1jqBBeYp1OM+qPHjWbsvv6pIBduqdgxxS+ScfG7S28pzyr2DQ==", "dev": true, - "requires": { - "xml-name-validator": "^3.0.0" + "license": "0BSD", + "engines": { + "node": ">=0.10.48" } }, - "walker": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", - "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=", + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, - "requires": { - "makeerror": "1.0.x" + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", - "dev": true - }, - "whatwg-encoding": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", - "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, - "requires": { - "iconv-lite": "0.4.24" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "whatwg-mimetype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", - "dev": true + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" }, - "whatwg-url": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.1.0.tgz", - "integrity": "sha512-vEIkwNi9Hqt4TV9RdnaBPNt+E2Sgmo3gePebCRgZ1R7g6d23+53zCTnuB0amKI4AXq6VM8jj2DUAa0S1vjJxkw==", + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, - "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^2.0.2", - "webidl-conversions": "^5.0.0" - }, + "license": "MIT", "dependencies": { - "webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "dev": true - } + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", "dev": true, - "requires": { - "isexe": "^2.0.0" + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "which-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", - "dev": true - }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, - "wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "wrappy": { + "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, - "write": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", - "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", - "dev": true, - "requires": { - "mkdirp": "^0.5.1" - } - }, - "write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "ws": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz", - "integrity": "sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==", - "dev": true - }, - "xml-name-validator": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", - "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", - "dev": true - }, - "xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true - }, - "y18n": { + "node_modules/xdg-basedir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", - "dev": true - }, - "yargs": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz", - "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==", - "dev": true, - "requires": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.1" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - } + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, - "yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } } } diff --git a/package.json b/package.json index dc5e96c..865c726 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,36 @@ { "name": "mcscript", - "version": "1.0.1", - "description": "A Minecraft Classic server in JavaScript", - "main": "index.js", + "version": "2.0.0", + "description": "A ClassiCube (Minecraft Classic + CPE) server written in JavaScript, with plugins", + "main": "lib/index.js", "author": "perronosaurio", - "scripts": { - "start": "node -r dotenv/config index.js" + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/perronosaurio/MCScript.git" + }, + "bugs": { + "url": "https://github.com/perronosaurio/MCScript/issues" }, - "dependencies": { - "irc": "^0.5.2", - "js-logger": "^1.4.1", - "md5.js": "^1.3.4", - "minecraft-classic-protocol": "^1.3.1", - "minecraft-classic-protocol-extension": "^1.0.3", - "node-fetch": "^2.6.1", - "requireindex": "^1.2.0", - "vec3": "^0.1.4" + "homepage": "https://github.com/perronosaurio/MCScript#readme", + "engines": { + "node": ">=22.13" + }, + "scripts": { + "start": "node index.js", + "test": "node --test --test-timeout=180000 test/*.test.js", + "lint": "standard lib plugins test scripts index.js", + "plugin:create": "node scripts/create-plugin.js" }, + "keywords": [ + "classicube", + "minecraft-classic", + "cpe", + "server", + "mcgalaxy" + ], + "dependencies": {}, "devDependencies": { - "jest": "^26.0.1", - "dotenv": "^8.2.0", - "standard": "^14.3.4" + "standard": "^17.1.2" } } diff --git a/plugins/announcer/index.js b/plugins/announcer/index.js new file mode 100644 index 0000000..aadd25c --- /dev/null +++ b/plugins/announcer/index.js @@ -0,0 +1,66 @@ +'use strict' + +// Broadcasts tips/announcements every few minutes. Edit config/plugins/announcer.json + +module.exports = { + name: 'announcer', + version: '1.0.0', + description: 'Periodic server announcements', + author: 'MCScript', + + defaultConfig: { + intervalMinutes: 5, + prefix: '&6[Tip] &e', + random: false, + messages: [ + 'Type &f/help&e to see all the commands you can use.', + 'Use &f/goto &e to visit other worlds. &f/levels&e lists them.', + 'Protect your builds! Ask an operator to create a &f/zone&e for you.', + 'Made a mistake? &f/undo&e reverts your last changes.' + ] + }, + + load (ctx) { + const { server, config } = ctx + let next = 0 + + const announce = () => { + if (!config.messages.length || !server.online.length) return + const i = config.random ? Math.floor(Math.random() * config.messages.length) : next++ % config.messages.length + server.broadcast(config.prefix + config.messages[i]) + } + + if (config.intervalMinutes > 0) ctx.setInterval(announce, config.intervalMinutes * 60000) + + ctx.command({ + name: 'announcer', + category: 'server', + rank: 'Admin', + usage: '/announcer |remove |list|now>', + description: 'Manages automatic announcements', + run (player, args, { usage, raw }) { + const sub = (args[0] || '').toLowerCase() + if (sub === 'list') { + config.messages.forEach((m, i) => player.message(`&f${i + 1}. &e${m}`)) + if (!config.messages.length) player.message('&eNo announcements.') + } else if (sub === 'add') { + const msg = raw.slice(3).trim() + if (!msg) return usage() + config.messages.push(ctx.text.convertPercentCodes(msg)) + ctx.saveConfig() + player.message('&aAnnouncement added.') + } else if (sub === 'remove') { + const i = Number(args[1]) - 1 + if (!(i >= 0 && i < config.messages.length)) return usage() + config.messages.splice(i, 1) + ctx.saveConfig() + player.message('&aAnnouncement removed.') + } else if (sub === 'now') { + announce() + } else { + return usage() + } + } + }) + } +} diff --git a/plugins/bots/index.js b/plugins/bots/index.js new file mode 100644 index 0000000..d19552a --- /dev/null +++ b/plugins/bots/index.js @@ -0,0 +1,203 @@ +'use strict' + +// NPC bots: named entities with a skin and model that stand in a level. +// AI "look" makes the bot face the nearest player; "wander" walks around slowly. +// Bots are stored in the level (level.meta.bots) and respawn when the level loads. + +module.exports = { + name: 'bots', + version: '1.0.0', + description: 'NPC bots with skins, models and simple AI', + author: 'MCScript', + + load (ctx) { + const { server, CommandError, text } = ctx + const live = new Map() // level name -> Map(bot name -> entity) + + const savedOf = level => (level.meta.bots = level.meta.bots || {}) + const liveOf = level => { + let m = live.get(level.name) + if (!m) live.set(level.name, (m = new Map())) + return m + } + + const spawnBot = (level, key, bot) => { + const entity = server.createEntity({ + name: bot.display || bot.name, + skin: bot.skin, + model: bot.model, + scale: bot.scale || null, + level, + x: bot.x, + y: bot.y, + z: bot.z, + yaw: bot.yaw, + pitch: bot.pitch, + data: { bot: key } + }) + liveOf(level).set(key, entity) + return entity + } + + const spawnAll = (level) => { + for (const [key, bot] of Object.entries(savedOf(level))) { + if (!liveOf(level).has(key)) spawnBot(level, key, bot) + } + } + + const despawnAll = (level) => { + for (const entity of liveOf(level).values()) server.removeEntity(entity) + live.delete(level.name) + } + + for (const level of server.levels.loaded.values()) spawnAll(level) + ctx.on('levelLoad', ({ level }) => spawnAll(level)) + ctx.on('levelUnload', ({ level }) => despawnAll(level), { priority: 'monitor' }) + ctx.onUnload(() => { for (const level of server.levels.loaded.values()) despawnAll(level) }) + + // AI tick + ctx.setInterval(() => { + for (const [levelName, bots] of live) { + const level = server.levels.get(levelName) + if (!level) continue + const players = level.players.filter(p => p.spawned && !p.hidden) + for (const [key, entity] of bots) { + const bot = savedOf(level)[key] + if (!bot || !bot.ai || bot.ai === 'none') continue + if (bot.ai === 'look' && players.length) { + let nearest = null; let best = Infinity + for (const p of players) { + const d = Math.hypot(p.pos.x - entity.pos.x, p.pos.y - entity.pos.y, p.pos.z - entity.pos.z) + if (d < best) { best = d; nearest = p } + } + if (nearest && best < 32 * 16) { + const dx = nearest.pos.x - entity.pos.x; const dz = nearest.pos.z - entity.pos.z + const dy = nearest.pos.y - entity.pos.y + const yaw = Math.round(((Math.atan2(dx, -dz) * 180 / Math.PI) + 360) % 360 * 256 / 360) + const pitch = Math.round(((-Math.atan2(dy, Math.hypot(dx, dz)) * 180 / Math.PI) + 360) % 360 * 256 / 360) + if (yaw !== entity.yaw || pitch !== entity.pitch) server.rotateEntity(entity, yaw, pitch) + } + } else if (bot.ai === 'wander') { + entity.data.angle = (entity.data.angle ?? Math.random() * Math.PI * 2) + (Math.random() - 0.5) * 0.6 + const x = entity.pos.x / 32 + Math.sin(entity.data.angle) * 0.25 + const z = entity.pos.z / 32 - Math.cos(entity.data.angle) * 0.25 + // stay within 6 blocks of home, on the ground + if (Math.hypot(x - bot.x, z - bot.z) > 6 || !level.inBounds(Math.floor(x), 0, Math.floor(z))) { + entity.data.angle += Math.PI + continue + } + const y = level.highestFreeY(Math.floor(x), Math.floor(z)) + if (Math.abs(y - (entity.pos.y - 51) / 32) > 1.1) { entity.data.angle += Math.PI / 2; continue } + const yaw = Math.round(((entity.data.angle * 180 / Math.PI) % 360 + 360) % 360 * 256 / 360) + server.moveEntity(entity, x, y, z, yaw, 0) + } + } + } + }, 200) + + const findBot = (level, name) => { + const key = String(name || '').toLowerCase() + const bot = savedOf(level)[key] + if (!bot) throw new CommandError(`No bot named "${name}" in this level.`) + return [key, bot] + } + + const respawn = (level, key) => { + const entity = liveOf(level).get(key) + if (entity) { server.removeEntity(entity); liveOf(level).delete(key) } + spawnBot(level, key, savedOf(level)[key]) + } + + ctx.command({ + name: 'bot', + aliases: ['bots', 'npc'], + category: 'world', + rank: 'Operator', + usage: '/bot [value]', + description: 'Creates and controls NPC bots', + help: [ + '/bot add [skin] - creates a bot at your position', + '/bot model , /bot skin , /bot name ', + '/bot ai , /bot scale <0.25-3>, /bot summon , /bot remove ' + ], + inGame: true, + run (player, args, { usage }) { + const level = player.level + const sub = (args[0] || '').toLowerCase() + const name = args[1] + const value = args.slice(2).join(' ') + + if (sub === 'list') { + const names = Object.values(savedOf(level)).map(b => `${b.name}&7(${b.ai || 'none'})&f`) + return player.message(names.length ? `&eBots here: &f${names.join(', ')}` : '&eNo bots in this level.') + } + if (!name) return usage() + + switch (sub) { + case 'add': + case 'create': { + if (!/^[A-Za-z0-9_]{1,16}$/.test(name)) throw new CommandError('Bot names can use letters, numbers and _ (max 16).') + const key = name.toLowerCase() + if (savedOf(level)[key]) throw new CommandError('A bot with that name already exists here.') + const p = player.feetPos + savedOf(level)[key] = { name, display: '&e' + name, skin: args[2] || name, model: 'humanoid', ai: 'look', x: p.x, y: p.y, z: p.z, yaw: player.yaw, pitch: 0 } + level.dirty = true + spawnBot(level, key, savedOf(level)[key]) + return player.message(`&aBot ${name} created. It looks at players nearby (/bot ai ${name} none to stop).`) + } + case 'remove': + case 'delete': { + const [key] = findBot(level, name) + const entity = liveOf(level).get(key) + if (entity) server.removeEntity(entity) + liveOf(level).delete(key) + delete savedOf(level)[key] + level.dirty = true + return player.message(`&aBot ${name} removed.`) + } + case 'summon': + case 'tp': { + const [key, bot] = findBot(level, name) + Object.assign(bot, player.feetPos, { yaw: player.yaw }) + level.dirty = true + const entity = liveOf(level).get(key) + server.moveEntity(entity, bot.x, bot.y, bot.z, bot.yaw, 0) + return player.message(`&aBot ${name} moved to you.`) + } + case 'model': + case 'skin': + case 'name': + case 'display': { + const [key, bot] = findBot(level, name) + if (!value) return usage() + if (sub === 'model') bot.model = value.toLowerCase() + else if (sub === 'skin') bot.skin = value + else bot.display = text.convertPercentCodes(value) + level.dirty = true + respawn(level, key) + return player.message(`&aBot ${name} updated.`) + } + case 'scale': { + const [key, bot] = findBot(level, name) + const scale = Number(value) + if (!(scale >= 0.25 && scale <= 3)) throw new CommandError('Scale must be between 0.25 and 3.') + bot.scale = scale + level.dirty = true + respawn(level, key) + return player.message(`&aBot ${name} scaled to ${scale}.`) + } + case 'ai': { + const [, bot] = findBot(level, name) + const ai = (value || '').toLowerCase() + if (!['none', 'look', 'wander'].includes(ai)) throw new CommandError('AI: none, look or wander.') + bot.ai = ai + level.dirty = true + return player.message(`&aBot ${name} AI set to ${ai}.`) + } + default: + return usage() + } + } + }) + } +} diff --git a/plugins/core-blocks/index.js b/plugins/core-blocks/index.js new file mode 100644 index 0000000..79791d4 --- /dev/null +++ b/plugins/core-blocks/index.js @@ -0,0 +1,233 @@ +'use strict' + +// Custom blocks (CPE BlockDefinitions): /gb for global blocks, /lb for blocks of a single level. + +const PRESETS = { + barrier: { name: 'Barrier', collide: 2, draw: 4, textures: all(49), transmitsLight: true, sound: 0, fallback: 20 }, + lamp: { name: 'Lamp', fullBright: true, textures: all(24), sound: 6, fallback: 41 }, + pane: { name: 'Glass pane', draw: 1, textures: all(49), transmitsLight: true, sound: 6, min: [0, 0, 7], max: [16, 16, 9], fallback: 20 }, + stoneslab: { name: 'Stone slab', textures: { ...all(1) }, min: [0, 0, 0], max: [16, 8, 16], fallback: 44 }, + carpet: { name: 'White carpet', textures: all(79), sound: 7, min: [0, 0, 0], max: [16, 1, 16], collide: 0, fallback: 36 }, + plank: { name: 'Wood slab', textures: all(4), sound: 1, max: [16, 8, 16], fallback: 44 }, + ladder: { name: 'Ladder', textures: all(4), collide: 7, draw: 1, min: [2, 0, 2], max: [14, 16, 14], sound: 1, fallback: 5 }, + fence: { name: 'Fence post', textures: all(4), min: [6, 0, 6], max: [10, 16, 10], sound: 1, fallback: 5 }, + jelly: { name: 'Slime', textures: all(25), draw: 3, speed: 0.5, sound: 7, fallback: 25 }, + speed: { name: 'Speed pad', textures: all(83), speed: 3, max: [16, 2, 16], collide: 0, sound: 5, fallback: 29 } +} + +function all (tex) { return { top: tex, bottom: tex, left: tex, right: tex, front: tex, back: tex } } + +module.exports = { + name: 'core-blocks', + version: '2.0.0', + description: 'Create and edit custom blocks for everyone (/gb) or for one level (/lb)', + author: 'MCScript', + + load (ctx) { + const { server, Blocks, CommandError } = ctx + + const scopeFor = (global, player) => global + ? { + label: 'global', + get: id => server.blockDefs[id] || null, + set: def => server.setGlobalBlock(def), + remove: id => server.removeGlobalBlock(id), + all: () => Object.values(server.blockDefs) + } + : { + label: `level ${player.level.name}`, + get: id => player.level.blockDefs[id] || null, + set: def => server.setLevelBlock(player.level, def), + remove: id => server.removeLevelBlock(player.level, id), + all: () => Object.values(player.level.blockDefs) + } + + const idArg = (value) => { + const id = Number(value) + if (!Number.isInteger(id) || id < 1 || id > Blocks.MAX_BLOCK) throw new CommandError(`Block id must be a number between 1 and ${Blocks.MAX_BLOCK} (66+ for new blocks; above 255 needs an up to date client).`) + return id + } + const bool = v => ['1', 'true', 'yes', 'on', 'y'].includes(String(v).toLowerCase()) + const enumArg = (table, value, label) => { + const v = String(value).toLowerCase().replace(/[\s_-]/g, '') + if (/^\d+$/.test(v)) return Number(v) + if (table[v] === undefined) throw new CommandError(`${label}: ${Object.keys(table).join(', ')}`) + return table[v] + } + const coords = (args) => { + const n = args.map(Number) + if (n.length !== 3 || !n.every(v => Number.isInteger(v) && v >= 0 && v <= 16)) throw new CommandError('Give three numbers between 0 and 16.') + return n + } + + // Applies "prop value" to a definition + function edit (def, prop, values, player) { + const value = values.join(' ') + switch (prop) { + case 'name': def.name = value.slice(0, 64); break + case 'collide': case 'collision': case 'solidity': def.collide = enumArg(Blocks.COLLIDE, value, 'Collide types'); break + case 'speed': { + const s = Number(value) + if (!(s >= 0.25 && s <= 3.96)) throw new CommandError('Speed must be between 0.25 and 3.96.') + def.speed = s + break + } + case 'toptex': def.textures.top = tex(value); break + case 'bottomtex': def.textures.bottom = tex(value); break + case 'sidetex': def.textures.left = def.textures.right = def.textures.front = def.textures.back = tex(value); break + case 'lefttex': def.textures.left = tex(value); break + case 'righttex': def.textures.right = tex(value); break + case 'fronttex': def.textures.front = tex(value); break + case 'backtex': def.textures.back = tex(value); break + case 'alltex': case 'texture': { const t = tex(value); def.textures = all(t); break } + case 'blockslight': def.transmitsLight = !bool(value); break + case 'sound': case 'walksound': def.sound = enumArg(Blocks.SOUNDS, value, 'Sounds'); break + case 'fullbright': case 'glow': def.fullBright = bool(value); break + case 'shape': case 'height': + if (value === 'sprite') { def.sprite = true } else { + const h = Number(value) + if (!(h >= 1 && h <= 16)) throw new CommandError('Shape: sprite or a height between 1 and 16.') + def.sprite = false + def.min = [0, 0, 0] + def.max = [16, h, 16] + } + break + case 'min': def.min = coords(values); def.sprite = false; break + case 'max': def.max = coords(values); def.sprite = false; break + case 'draw': def.draw = enumArg(Blocks.DRAW, value, 'Draw modes'); break + case 'fogdensity': case 'fog': def.fogDensity = Math.max(0, Math.min(255, Number(value) || 0)); break + case 'fogcolor': { + const hex = value.replace('#', '') + if (!/^[0-9a-f]{6}$/i.test(hex)) throw new CommandError('Fog color must be hex like 336699.') + const n = parseInt(hex, 16) + def.fogColor = [(n >> 16) & 255, (n >> 8) & 255, n & 255] + break + } + case 'fallback': { + const b = player.level.parseBlock(value) + if (b === null || b > Blocks.MAX_CPE) throw new CommandError('Fallback must be a standard block (0-65).') + def.fallback = b + break + } + default: + throw new CommandError('Properties: name, collide, speed, alltex, toptex, sidetex, bottomtex, left/right/front/backtex, blockslight, sound, fullbright, shape, min, max, draw, fogdensity, fogcolor, fallback') + } + } + + function tex (value) { + const t = Number(value) + if (!Number.isInteger(t) || t < 0 || t > 255) throw new CommandError('Texture ids go from 0 to 255 (position in terrain.png, left to right, top to bottom).') + return t + } + + function describe (player, def) { + const d = Blocks.normalize(def) + const name = k => Object.keys(Blocks[k]).find(n => Blocks[k][n] === d[k === 'COLLIDE' ? 'collide' : k === 'DRAW' ? 'draw' : 'sound']) + player.message(`&eBlock &f${d.id}&e: &f${d.name}`) + player.message(`&7 collide ${name('COLLIDE')}, speed ${d.speed}, draw ${name('DRAW')}, sound ${name('SOUNDS')}, ${d.fullBright ? 'glows' : 'no glow'}, ${d.transmitsLight ? 'lets light through' : 'blocks light'}`) + player.message(`&7 textures top ${d.textures.top}, sides ${d.textures.left}/${d.textures.right}/${d.textures.front}/${d.textures.back}, bottom ${d.textures.bottom}`) + player.message(`&7 ${d.sprite ? 'sprite' : `box min ${d.min.join(',')} max ${d.max.join(',')}`}, fallback ${player.level.blockName(d.fallback)}`) + } + + function handler (global) { + return (player, args, { usage }) => { + const scope = scopeFor(global, player) + const sub = (args.shift() || '').toLowerCase() + + if (sub === 'list') { + const defs = scope.all().sort((a, b) => a.id - b.id) + if (!defs.length) return player.message(`&eNo ${scope.label} custom blocks.`) + player.message(`&eCustom blocks (${scope.label}): ` + defs.map(d => `&f${d.id}&7:${d.name}`).join('&7, ')) + return + } + if (sub === 'presets') { + return player.message(`&ePresets: &f${Object.keys(PRESETS).join(', ')}&e. Use &f${global ? '/gb' : '/lb'} preset `) + } + if (!sub || !args[0]) return usage() + + if (sub === 'copy') { + const src = idArg(args[0]); const dest = idArg(args[1]) + const base = player.level.getBlockDef(src) || Blocks.coreDefinition(src) + const def = scope.set({ ...JSON.parse(JSON.stringify(base)), id: dest }) + return player.message(`&aCopied block ${src} to ${dest} (${def.name}).`) + } + + const id = idArg(args.shift()) + const existing = scope.get(id) + + switch (sub) { + case 'add': + case 'create': { + if (existing) throw new CommandError(`Block ${id} already exists. Use edit or remove it first.`) + if (id <= Blocks.MAX_CPE) player.message(`&eNote: ${id} is a standard block; your definition will replace how it looks.`) + const name = args.join(' ') || `Custom ${id}` + const def = scope.set({ ...Blocks.coreDefinition(1), id, name, fallback: 1 }) + player.message(`&aCreated ${scope.label} block ${id} (${def.name}). Customize it with &f${global ? '/gb' : '/lb'} edit ${id} `) + break + } + case 'preset': { + const preset = PRESETS[(args[0] || '').toLowerCase()] + if (!preset) throw new CommandError(`Presets: ${Object.keys(PRESETS).join(', ')}`) + const def = scope.set({ ...Blocks.coreDefinition(1), ...JSON.parse(JSON.stringify(preset)), id }) + player.message(`&aCreated ${def.name} as block ${id}.`) + break + } + case 'edit': + case 'set': { + if (!existing) throw new CommandError(`There is no ${scope.label} block ${id}.`) + if (!args[0]) return usage() + const def = Blocks.normalize(JSON.parse(JSON.stringify(existing))) + edit(def, args.shift().toLowerCase(), args, player) + scope.set(def) + player.message(`&aUpdated block ${id}.`) + break + } + case 'remove': + case 'delete': { + if (!existing) throw new CommandError(`There is no ${scope.label} block ${id}.`) + scope.remove(id) + player.message(`&aRemoved block ${id}.`) + break + } + case 'info': { + const def = existing || player.level.getBlockDef(id) + if (!def) throw new CommandError(`Block ${id} is not a custom block.`) + describe(player, def) + break + } + default: + return usage() + } + } + } + + const help = [ + 'add [name] - create (copy of stone); preset ; copy ', + 'edit - properties: name, collide, speed, alltex, toptex, sidetex, bottomtex, blockslight, sound, fullbright, shape, min, max, draw, fogdensity, fogcolor, fallback', + 'remove , info , list, presets' + ] + + ctx.command({ + name: 'gb', + aliases: ['globalblock'], + category: 'blocks', + rank: 'Admin', + usage: '/gb ...', + description: 'Manages custom blocks available in every level', + help, + run: handler(true) + }) + + ctx.command({ + name: 'lb', + aliases: ['levelblock'], + category: 'blocks', + rank: 'Operator', + usage: '/lb ...', + description: 'Manages custom blocks of your current level', + help, + inGame: true, + run: handler(false) + }) + } +} diff --git a/plugins/core-building/block-log.js b/plugins/core-building/block-log.js new file mode 100644 index 0000000..12132b5 --- /dev/null +++ b/plugins/core-building/block-log.js @@ -0,0 +1,120 @@ +'use strict' + +// Append-only log of block changes, one binary file per level: +// data/plugins/core-building/history/.log +// Each record is 14 bytes: index u32, from u16, to u16, time (seconds) u32, player name id u16. +// Player names are stored once in names.json. + +const fs = require('fs') +const path = require('path') + +const RECORD = 14 + +class BlockLog { + constructor (dir, { maxBytes = 64 * 1024 * 1024 } = {}) { + this.dir = dir + this.maxBytes = maxBytes + this.buffers = new Map() // level -> [Buffer] + fs.mkdirSync(dir, { recursive: true }) + this.namesFile = path.join(dir, 'names.json') + this.names = fs.existsSync(this.namesFile) ? JSON.parse(fs.readFileSync(this.namesFile, 'utf8')) : [] + this.nameIds = new Map(this.names.map((n, i) => [n, i])) + this.namesDirty = false + } + + file (level) { return path.join(this.dir, `${level}.log`) } + + nameId (name) { + let id = this.nameIds.get(name) + if (id === undefined) { + id = this.names.length + if (id > 0xFFFF) id = 0xFFFF // extremely unlikely: reuse the last slot + else { this.names.push(name); this.nameIds.set(name, id); this.namesDirty = true } + } + return id + } + + // changes: [[index, from, to], ...] + append (level, name, changes, time = Date.now()) { + if (!changes.length) return + const id = this.nameId(name) + const secs = Math.floor(time / 1000) + const buf = Buffer.alloc(changes.length * RECORD) + changes.forEach(([index, from, to], n) => { + const o = n * RECORD + buf.writeUInt32LE(index, o) + buf.writeUInt16LE(from, o + 4) + buf.writeUInt16LE(to, o + 6) + buf.writeUInt32LE(secs, o + 8) + buf.writeUInt16LE(id, o + 12) + }) + const list = this.buffers.get(level) || [] + list.push(buf) + this.buffers.set(level, list) + } + + flush () { + if (this.namesDirty) { + fs.writeFileSync(this.namesFile, JSON.stringify(this.names)) + this.namesDirty = false + } + for (const [level, list] of this.buffers) { + const file = this.file(level) + fs.appendFileSync(file, Buffer.concat(list)) + this.buffers.delete(level) + if (fs.statSync(file).size > this.maxBytes) this._compact(file) + } + } + + // Keeps the newest half of an oversized log + _compact (file) { + const data = fs.readFileSync(file) + const keep = Math.floor(data.length / 2 / RECORD) * RECORD + fs.writeFileSync(file + '.tmp', data.subarray(data.length - keep)) + fs.renameSync(file + '.tmp', file) + } + + _decode (buf, o) { + return { + index: buf.readUInt32LE(o), + from: buf.readUInt16LE(o + 4), + to: buf.readUInt16LE(o + 6), + time: buf.readUInt32LE(o + 8) * 1000, + name: this.names[buf.readUInt16LE(o + 12)] || '?' + } + } + + _all (level) { + const file = this.file(level) + const parts = [] + if (fs.existsSync(file)) parts.push(fs.readFileSync(file)) + for (const b of this.buffers.get(level) || []) parts.push(b) + return Buffer.concat(parts) + } + + // Newest `max` records, oldest first + recent (level, max) { + const buf = this._all(level) + const count = Math.floor(buf.length / RECORD) + const out = [] + for (let n = Math.max(0, count - max); n < count; n++) out.push(this._decode(buf, n * RECORD)) + return out + } + + // Records matching a filter, newest first + search (level, { name = null, since = 0 } = {}) { + const buf = this._all(level) + const lower = name && name.toLowerCase() + const out = [] + for (let o = buf.length - RECORD; o >= 0; o -= RECORD) { + const time = buf.readUInt32LE(o + 8) * 1000 + if (time < since) break + const r = this._decode(buf, o) + if (lower && r.name.toLowerCase() !== lower) continue + out.push(r) + } + return out + } +} + +module.exports = { BlockLog, RECORD } diff --git a/plugins/core-building/font.js b/plugins/core-building/font.js new file mode 100644 index 0000000..3ea637f --- /dev/null +++ b/plugins/core-building/font.js @@ -0,0 +1,98 @@ +'use strict' + +// 5x7 bitmap font used by /write. Each glyph is 7 rows of 5 bits (left to right, top to bottom). +// Lowercase letters use the uppercase glyphs; accents are removed (á -> A, ñ -> N). + +const GLYPHS = { + A: '01110 10001 10001 11111 10001 10001 10001', + B: '11110 10001 10001 11110 10001 10001 11110', + C: '01110 10001 10000 10000 10000 10001 01110', + D: '11110 10001 10001 10001 10001 10001 11110', + E: '11111 10000 10000 11110 10000 10000 11111', + F: '11111 10000 10000 11110 10000 10000 10000', + G: '01110 10001 10000 10111 10001 10001 01111', + H: '10001 10001 10001 11111 10001 10001 10001', + I: '01110 00100 00100 00100 00100 00100 01110', + J: '00111 00010 00010 00010 00010 10010 01100', + K: '10001 10010 10100 11000 10100 10010 10001', + L: '10000 10000 10000 10000 10000 10000 11111', + M: '10001 11011 10101 10101 10001 10001 10001', + N: '10001 10001 11001 10101 10011 10001 10001', + O: '01110 10001 10001 10001 10001 10001 01110', + P: '11110 10001 10001 11110 10000 10000 10000', + Q: '01110 10001 10001 10001 10101 10010 01101', + R: '11110 10001 10001 11110 10100 10010 10001', + S: '01111 10000 10000 01110 00001 00001 11110', + T: '11111 00100 00100 00100 00100 00100 00100', + U: '10001 10001 10001 10001 10001 10001 01110', + V: '10001 10001 10001 10001 10001 01010 00100', + W: '10001 10001 10001 10101 10101 10101 01010', + X: '10001 10001 01010 00100 01010 10001 10001', + Y: '10001 10001 10001 01010 00100 00100 00100', + Z: '11111 00001 00010 00100 01000 10000 11111', + 0: '01110 10001 10011 10101 11001 10001 01110', + 1: '00100 01100 00100 00100 00100 00100 01110', + 2: '01110 10001 00001 00010 00100 01000 11111', + 3: '11111 00010 00100 00010 00001 10001 01110', + 4: '00010 00110 01010 10010 11111 00010 00010', + 5: '11111 10000 11110 00001 00001 10001 01110', + 6: '00110 01000 10000 11110 10001 10001 01110', + 7: '11111 00001 00010 00100 01000 01000 01000', + 8: '01110 10001 10001 01110 10001 10001 01110', + 9: '01110 10001 10001 01111 00001 00010 01100', + '!': '00100 00100 00100 00100 00100 00000 00100', + '?': '01110 10001 00001 00010 00100 00000 00100', + '.': '00000 00000 00000 00000 00000 01100 01100', + ',': '00000 00000 00000 00000 01100 00100 01000', + ':': '00000 01100 01100 00000 01100 01100 00000', + ';': '00000 01100 01100 00000 01100 00100 01000', + '-': '00000 00000 00000 11111 00000 00000 00000', + '+': '00000 00100 00100 11111 00100 00100 00000', + '=': '00000 00000 11111 00000 11111 00000 00000', + "'": '01100 00100 01000 00000 00000 00000 00000', + '"': '01010 01010 00000 00000 00000 00000 00000', + '/': '00000 00001 00010 00100 01000 10000 00000', + '(': '00010 00100 01000 01000 01000 00100 00010', + ')': '01000 00100 00010 00010 00010 00100 01000', + '#': '01010 01010 11111 01010 11111 01010 01010', + '<': '00010 00100 01000 10000 01000 00100 00010', + '>': '01000 00100 00010 00001 00010 00100 01000', + _: '00000 00000 00000 00000 00000 00000 11111', + '*': '00000 00100 10101 01110 10101 00100 00000', + '&': '01100 10010 10100 01000 10101 10010 01101', + '@': '01110 10001 10111 10101 10111 10000 01110', + '%': '11000 11001 00010 00100 01000 10011 00011', + $: '00100 01111 10100 01110 00101 11110 00100', + '¡': '00100 00000 00100 00100 00100 00100 00100', + '¿': '00100 00000 00100 01000 10000 10001 01110' +} + +const WIDTH = 5 +const HEIGHT = 7 +const SPACE_WIDTH = 3 + +const parsed = Object.fromEntries(Object.entries(GLYPHS).map(([ch, rows]) => [ch, rows.split(' ')])) + +function glyph (ch) { + if (parsed[ch]) return parsed[ch] + const base = ch.normalize('NFD').replace(/[̀-ͯ]/g, '').toUpperCase() + return parsed[base] || null +} + +// Returns the filled pixels of a text as [x, y] pairs (y = 0 at the bottom row) +function layout (text) { + const pixels = [] + let cursor = 0 + for (const ch of text) { + if (ch === ' ') { cursor += SPACE_WIDTH + 1; continue } + const rows = glyph(ch) + if (!rows) { cursor += SPACE_WIDTH + 1; continue } + rows.forEach((row, r) => { + for (let c = 0; c < WIDTH; c++) if (row[c] === '1') pixels.push([cursor + c, HEIGHT - 1 - r]) + }) + cursor += WIDTH + 1 + } + return { pixels, width: Math.max(0, cursor - 1), height: HEIGHT } +} + +module.exports = { layout, glyph, WIDTH, HEIGHT } diff --git a/plugins/core-building/index.js b/plugins/core-building/index.js new file mode 100644 index 0000000..2a5cdd7 --- /dev/null +++ b/plugins/core-building/index.js @@ -0,0 +1,637 @@ +'use strict' + +// Drawing and editing tools: /cuboid, /replace, /line, /sphere, /fill, /copy, /paste, /undo, /redo, /paint, /about... +// Other plugins (like zones) can filter draw operations through the 'drawOperation' event. + +const path = require('path') +const { BlockLog } = require('./block-log') +const font = require('./font') + +const MAX_UNDO_OPS = 30 +const HISTORY_PER_LEVEL = 300000 + +module.exports = { + name: 'core-building', + version: '2.0.0', + description: 'Building and drawing commands with undo/redo and block history', + author: 'MCScript', + + load (ctx) { + const { server, CommandError } = ctx + const undo = new Map() // player name -> [{ level, changes: [[index, old, new]], time }] + const redo = new Map() + const history = new Map() // level name -> Map(index -> { name, time, from, to }) + const clipboards = new Map() + // every change is also written to disk, so history and /undoplayer survive restarts + const log = new BlockLog(path.join(ctx.dataDir, 'history')) + ctx.setInterval(() => log.flush(), 5000) + ctx.onUnload(() => log.flush()) + + const loadHistory = (level) => { + const map = new Map() + for (const r of log.recent(level.name, HISTORY_PER_LEVEL)) { + map.delete(r.index) + map.set(r.index, { name: r.name, time: r.time, from: r.from, to: r.to }) + } + history.set(level.name, map) + } + for (const level of server.levels.loaded.values()) loadHistory(level) + ctx.on('levelLoad', ({ level }) => loadHistory(level)) + + // helpers + + const key = p => p.name.toLowerCase() + + async function select (player, count, label) { + const level = player.level + try { + const marks = await player.selectBlocks(count, label) + if (player.level !== level) return null + return marks + } catch (err) { + if (err.cancelled) return null + throw err + } + } + + function blockArg (player, arg) { + const level = player.level + let block + if (arg === undefined) { + if (!player.supports('HeldBlock')) throw new CommandError('Specify a block, e.g. stone or 1.') + block = player.heldBlock + } else { + block = level.parseBlock(arg) + if (block === null) throw new CommandError(`Unknown block "${arg}".`) + } + if (!server.blockPerms.canPlace(player, block)) throw new CommandError(`You can't place ${level.blockName(block)}.`) + return block + } + + function checkVolume (player, volume) { + const limit = player.isConsole ? Infinity : player.rank.drawLimit + if (volume > limit) throw new CommandError(`You tried to change ${volume.toLocaleString()} blocks, your limit is ${Number(limit).toLocaleString()}.`) + } + + function box (a, b) { + return { + x1: Math.min(a.x, b.x), + y1: Math.min(a.y, b.y), + z1: Math.min(a.z, b.z), + x2: Math.max(a.x, b.x), + y2: Math.max(a.y, b.y), + z2: Math.max(a.z, b.z) + } + } + + function recordHistory (level, name, changes) { + let map = history.get(level.name) + if (!map) history.set(level.name, (map = new Map())) + const time = Date.now() + log.append(level.name, name, changes, time) + for (const [index, from, to] of changes) { + map.delete(index) + map.set(index, { name, time, from, to }) + } + while (map.size > HISTORY_PER_LEVEL) map.delete(map.keys().next().value) + } + + function pushUndo (player, level, changes, merge = false) { + if (player.isConsole || !changes.length) return + const stack = undo.get(key(player)) || [] + const last = stack[stack.length - 1] + if (merge && last && last.manual && last.level === level && Date.now() - last.time < 60000) { + last.changes.push(...changes) + last.time = Date.now() + } else { + stack.push({ level, changes, time: Date.now(), manual: merge }) + } + // respect the rank's undo limit (total blocks) and max operations + let total = stack.reduce((n, op) => n + op.changes.length, 0) + while (stack.length > 1 && (stack.length > MAX_UNDO_OPS || total > player.rank.maxUndo)) total -= stack.shift().changes.length + undo.set(key(player), stack) + if (!merge) redo.delete(key(player)) + } + + // Applies [[x, y, z, block], ...] as a player, with permission checks + function apply (player, level, changes, opName) { + if (!level.canBuild(player)) { + throw new CommandError(`You are not allowed to build in ${level.name}.`) + } + checkVolume(player, changes.length) + const allowed = [] + const denied = new Set() + for (const c of changes) { + const [x, y, z, block] = c + if (!level.inBounds(x, y, z)) continue + const old = level.getBlock(x, y, z) + if (old === block) continue + if (!player.isConsole && !server.blockPerms.canDelete(player, old)) { denied.add(old); continue } + allowed.push(c) + } + const ev = server.events.fire('drawOperation', { player, level, changes: allowed, name: opName }) + if (ev.cancelled) throw new CommandError(ev.cancelReason || 'You can\'t draw here.') + const record = [] + for (const [x, y, z, block] of ev.changes) { + const index = level.index(x, y, z) + record.push([index, level.getAt(index), block]) + } + level.setBlocks(ev.changes) + pushUndo(player, level, record) + recordHistory(level, player.name, record) + let msg = `&e${opName}: changed &f${record.length.toLocaleString()}&e block${record.length === 1 ? '' : 's'}.` + if (denied.size) msg += ` &7(skipped blocks you can't delete: ${[...denied].map(b => level.blockName(b)).join(', ')})` + player.message(msg) + return record.length + } + + function * cuboid (b, block, mode) { + 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++) { + const edgeX = x === b.x1 || x === b.x2 + const edgeY = y === b.y1 || y === b.y2 + const edgeZ = z === b.z1 || z === b.z2 + if (mode === 'hollow' && !(edgeX || edgeY || edgeZ)) continue + if (mode === 'walls' && !(edgeX || edgeZ)) continue + if (mode === 'wire' && [edgeX, edgeY, edgeZ].filter(Boolean).length < 2) continue + yield [x, y, z, block] + } + } + } + } + + const volumeOf = b => (b.x2 - b.x1 + 1) * (b.y2 - b.y1 + 1) * (b.z2 - b.z1 + 1) + + // commands + + ctx.command({ + name: 'cuboid', + aliases: ['z', 'box', 'cube'], + category: 'building', + rank: 'Builder', + usage: '/cuboid [block] [solid|hollow|walls|wire]', + description: 'Fills the box between two marked corners', + inGame: true, + async run (player, args) { + const modes = ['solid', 'hollow', 'walls', 'wire'] + let mode = 'solid' + if (args.length && modes.includes(args[args.length - 1].toLowerCase())) mode = args.pop().toLowerCase() + const block = blockArg(player, args[0]) + const marks = await select(player, 2, 'Cuboid') + if (!marks) return + const b = box(marks[0], marks[1]) + if (mode === 'solid') checkVolume(player, volumeOf(b)) + apply(player, player.level, [...cuboid(b, block, mode)], 'Cuboid') + } + }) + + ctx.command({ + name: 'replace', + aliases: ['r', 'rp'], + category: 'building', + rank: 'Builder', + usage: '/replace ', + description: 'Replaces blocks inside a marked box', + inGame: true, + async run (player, args, { usage }) { + if (args.length < 2) return usage() + const level = player.level + const from = new Set(args[0].split(',').map(a => { + const b = level.parseBlock(a) + if (b === null) throw new CommandError(`Unknown block "${a}".`) + return b + })) + const to = blockArg(player, args[1]) + const marks = await select(player, 2, 'Replace') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, volumeOf(b)) + const changes = [] + for (const [x, y, z] of cuboid(b, 0, 'solid')) if (from.has(level.getBlock(x, y, z))) changes.push([x, y, z, to]) + apply(player, level, changes, 'Replace') + } + }) + + ctx.command({ + name: 'replaceall', + aliases: ['ra'], + category: 'building', + rank: 'Operator', + usage: '/replaceall ', + description: 'Replaces a block in the whole level', + inGame: true, + run (player, args, { usage }) { + if (args.length < 2) return usage() + const level = player.level + const from = level.parseBlock(args[0]) + if (from === null) throw new CommandError(`Unknown block "${args[0]}".`) + const to = blockArg(player, args[1]) + const changes = [] + for (let i = 0; i < level.volume; i++) { + if (level.getAt(i) !== from) continue + const { x, y, z } = level.unpack(i) + changes.push([x, y, z, to]) + } + apply(player, level, changes, 'Replace all') + } + }) + + ctx.command({ + name: 'line', + aliases: ['l'], + category: 'building', + rank: 'Builder', + usage: '/line [block]', + description: 'Draws a straight line between two marks', + inGame: true, + async run (player, args) { + const block = blockArg(player, args[0]) + const marks = await select(player, 2, 'Line') + if (!marks) return + const [a, b] = marks + const steps = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y), Math.abs(b.z - a.z)) + const changes = [] + for (let i = 0; i <= steps; i++) { + const t = steps === 0 ? 0 : i / steps + changes.push([Math.round(a.x + (b.x - a.x) * t), Math.round(a.y + (b.y - a.y) * t), Math.round(a.z + (b.z - a.z) * t), block]) + } + apply(player, player.level, changes, 'Line') + } + }) + + ctx.command({ + name: 'sphere', + aliases: ['sp', 'ball'], + category: 'building', + rank: 'Builder', + usage: '/sphere [block] [radius] [hollow]', + description: 'Draws a sphere. Mark the center (and a point on the edge if no radius is given)', + inGame: true, + async run (player, args) { + const hollow = args.length && args[args.length - 1].toLowerCase() === 'hollow' + if (hollow) args.pop() + // "/sphere stone 5", "/sphere 5" (block in hand) or "/sphere stone" (mark the radius) + let radius = null + const numeric = a => /^\d+$/.test(a) + if (args.length >= 2 && numeric(args[args.length - 1])) radius = Number(args.pop()) + else if (args.length === 1 && numeric(args[0]) && player.supports('HeldBlock')) radius = Number(args.pop()) + const block = blockArg(player, args[0]) + const marks = await select(player, radius === null ? 2 : 1, 'Sphere') + if (!marks) return + const c = marks[0] + if (radius === null) radius = Math.round(Math.hypot(marks[1].x - c.x, marks[1].y - c.y, marks[1].z - c.z)) + if (radius < 1 || radius > 200) throw new CommandError('Radius must be between 1 and 200.') + checkVolume(player, Math.round(4 / 3 * Math.PI * radius ** 3)) + const changes = [] + const r2 = (radius + 0.5) ** 2 + const inner = (radius - 0.5) ** 2 + for (let y = -radius; y <= radius; y++) { + for (let z = -radius; z <= radius; z++) { + for (let x = -radius; x <= radius; x++) { + const d = x * x + y * y + z * z + if (d > r2 || (hollow && d < inner)) continue + changes.push([c.x + x, c.y + y, c.z + z, block]) + } + } + } + apply(player, player.level, changes, 'Sphere') + } + }) + + ctx.command({ + name: 'fill', + aliases: ['f', 'floodfill'], + category: 'building', + rank: 'AdvBuilder', + usage: '/fill [block] [3d|layer]', + description: 'Flood fills the connected area of the block you mark', + inGame: true, + async run (player, args) { + let mode = '3d' + if (args.length && ['3d', 'layer'].includes(args[args.length - 1].toLowerCase())) mode = args.pop().toLowerCase() + const block = blockArg(player, args[0]) + const marks = await select(player, 1, 'Fill') + if (!marks) return + const level = player.level + const start = marks[0] + const target = level.getBlock(start.x, start.y, start.z) + if (target === block) throw new CommandError('That area is already that block.') + const limit = player.rank.drawLimit + const seen = new Set() + const queue = [level.index(start.x, start.y, start.z)] + const changes = [] + const dirs = mode === 'layer' ? [[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]] : [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]] + seen.add(queue[0]) + while (queue.length) { + const i = queue.pop() + const { x, y, z } = level.unpack(i) + changes.push([x, y, z, block]) + if (changes.length > limit) throw new CommandError(`The area is bigger than your draw limit (${limit}).`) + for (const [dx, dy, dz] of dirs) { + const nx = x + dx; const ny = y + dy; const nz = z + dz + if (!level.inBounds(nx, ny, nz)) continue + const ni = level.index(nx, ny, nz) + if (seen.has(ni) || level.getAt(ni) !== target) continue + seen.add(ni) + queue.push(ni) + } + } + apply(player, level, changes, 'Fill') + } + }) + + ctx.command({ + name: 'place', + aliases: ['pb'], + category: 'building', + rank: 'Builder', + usage: '/place [x y z]', + description: 'Places a block at your feet or at coordinates', + inGame: true, + run (player, args, { usage }) { + if (!args[0]) return usage() + const block = blockArg(player, args[0]) + let pos = player.blockPos + if (args.length === 4) { + const [x, y, z] = args.slice(1).map(Number) + if (![x, y, z].every(Number.isInteger)) return usage() + pos = { x, y, z } + } + apply(player, player.level, [[pos.x, pos.y, pos.z, block]], 'Place') + } + }) + + ctx.command({ + name: 'copy', + aliases: ['c'], + category: 'building', + rank: 'AdvBuilder', + usage: '/copy [air]', + description: 'Copies the blocks between two marks (add "air" to also copy air)', + inGame: true, + async run (player, args) { + const withAir = (args[0] || '').toLowerCase() === 'air' + const marks = await select(player, 2, 'Copy') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, volumeOf(b)) + const level = player.level + const blocks = [] + for (const [x, y, z] of cuboid(b, 0, 'solid')) { + const block = level.getBlock(x, y, z) + if (block !== 0 || withAir) blocks.push([x - b.x1, y - b.y1, z - b.z1, block]) + } + clipboards.set(key(player), { blocks, size: [b.x2 - b.x1 + 1, b.y2 - b.y1 + 1, b.z2 - b.z1 + 1] }) + player.message(`&eCopied &f${blocks.length}&e blocks. Use &f/paste&e to place them.`) + } + }) + + ctx.command({ + name: 'paste', + aliases: ['ps'], + category: 'building', + rank: 'AdvBuilder', + usage: '/paste [rotate 90|180|270]', + description: 'Pastes the copied blocks at the marked position (the lowest corner)', + inGame: true, + async run (player, args) { + const clip = clipboards.get(key(player)) + if (!clip) throw new CommandError('You have not copied anything. Use /copy first.') + const rot = (args[0] || '').toLowerCase() === 'rotate' ? Number(args[1]) : 0 + if (![0, 90, 180, 270].includes(rot)) throw new CommandError('Rotation must be 90, 180 or 270.') + const marks = await select(player, 1, 'Paste') + if (!marks) return + const o = marks[0] + const [sx, , sz] = clip.size + const changes = clip.blocks.map(([x, y, z, block]) => { + let rx = x; let rz = z + if (rot === 90) { rx = sz - 1 - z; rz = x } else if (rot === 180) { rx = sx - 1 - x; rz = sz - 1 - z } else if (rot === 270) { rx = z; rz = sx - 1 - x } + return [o.x + rx, o.y + y, o.z + rz, block] + }) + apply(player, player.level, changes, 'Paste') + } + }) + + ctx.command({ + name: 'undo', + aliases: ['u'], + category: 'building', + usage: '/undo [count]', + description: 'Undoes your last changes', + inGame: true, + run (player, args) { + const count = Math.max(1, Math.min(MAX_UNDO_OPS, Number(args[0]) || 1)) + const stack = undo.get(key(player)) || [] + if (!stack.length) throw new CommandError('Nothing to undo.') + let total = 0 + for (let n = 0; n < count && stack.length; n++) { + const op = stack.pop() + const level = op.level + if (server.levels.get(level.name) !== level) { player.message(`&7Skipped changes in ${level.name} (not loaded).`); continue } + const changes = [] + const undone = [] + for (let i = op.changes.length - 1; i >= 0; i--) { + const [index, from, to] = op.changes[i] + if (level.getAt(index) !== to) continue // changed again later by someone else + const { x, y, z } = level.unpack(index) + changes.push([x, y, z, from]) + undone.push([index, to, from]) + } + level.setBlocks(changes) + recordHistory(level, player.name + ' (undo)', undone) + const r = redo.get(key(player)) || [] + r.push({ level, changes: op.changes, time: Date.now() }) + redo.set(key(player), r.slice(-MAX_UNDO_OPS)) + total += changes.length + } + player.message(`&eUndone &f${total}&e block changes. &7(/redo to restore)`) + } + }) + + ctx.command({ + name: 'undoplayer', + aliases: ['up', 'undoothers'], + category: 'building', + usage: '/undoplayer [time, default 30m] [level]', + description: 'Undoes a player\'s changes in a time span (also after restarts). Undoing others needs Operator.', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = args[0] + const ms = args[1] ? ctx.text.parseDuration(args[1]) : 30 * 60000 + if (!ms) return usage() + const level = args[2] ? server.levels.get(args[2]) : player.level + if (!level) throw new CommandError('That level is not loaded.') + const self = !player.isConsole && target.toLowerCase() === player.name.toLowerCase() + if (!self) { + if (player.permission < server.ranks.permissionOf('Operator')) throw new CommandError('Only operators can undo other players.') + const record = server.playerDB.get(target) + if (record && !player.isConsole && (server.ranks.get(record.rank) || server.ranks.default).permission >= player.permission) { + throw new CommandError(`You can't undo ${target}'s changes, their rank is not lower than yours.`) + } + } + // newest first: remember the newest "to" and the oldest "from" of each block + const blocks = new Map() + for (const r of log.search(level.name, { name: target, since: Date.now() - ms })) { + const b = blocks.get(r.index) + if (b) b.from = r.from + else blocks.set(r.index, { from: r.from, to: r.to }) + } + const changes = [] + const undone = [] + for (const [index, b] of blocks) { + if (level.getAt(index) !== b.to) continue // someone changed it afterwards + const { x, y, z } = level.unpack(index) + changes.push([x, y, z, b.from]) + undone.push([index, b.to, b.from]) + } + level.setBlocks(changes) + recordHistory(level, `${player.name} (undo)`, undone) + player.message(`&eUndid &f${changes.length}&e block changes by ${target} in the last ${ctx.text.formatDuration(ms)}.`) + } + }) + + ctx.command({ + name: 'redo', + category: 'building', + usage: '/redo', + description: 'Redoes what you last undid', + inGame: true, + run (player) { + const stack = redo.get(key(player)) || [] + const op = stack.pop() + if (!op) throw new CommandError('Nothing to redo.') + const changes = [] + for (const [index, from, to] of op.changes) { + if (op.level.getAt(index) !== from) continue + const { x, y, z } = op.level.unpack(index) + changes.push([x, y, z, to]) + } + op.level.setBlocks(changes) + const stackUndo = undo.get(key(player)) || [] + stackUndo.push({ level: op.level, changes: op.changes, time: Date.now() }) + undo.set(key(player), stackUndo) + player.message(`&eRedone &f${changes.length}&e block changes.`) + } + }) + + ctx.command({ + name: 'paint', + aliases: ['p'], + category: 'building', + rank: 'Builder', + usage: '/paint', + description: 'Toggles paint mode: breaking a block replaces it with the block you hold', + inGame: true, + run (player) { + if (!player.supports('HeldBlock')) throw new CommandError('Your client does not support paint mode.') + player.data['building.paint'] = !player.data['building.paint'] + player.message(`&ePaint mode ${player.data['building.paint'] ? '&aON' : '&cOFF'}&e.`) + } + }) + + ctx.command({ + name: 'about', + aliases: ['b', 'binfo', 'blockinfo'], + category: 'building', + usage: '/about', + description: 'Shows information and history of a block you mark', + inGame: true, + async run (player) { + const marks = await select(player, 1, 'About') + if (!marks) return + const { x, y, z } = marks[0] + const level = player.level + const id = level.getBlock(x, y, z) + player.message(`&eBlock (${x}, ${y}, ${z}): &f${level.blockName(id)}&7 (id ${id})`) + const h = history.get(level.name) + const entry = h && h.get(level.index(x, y, z)) + if (entry) { + player.message(`&7 Last changed by &f${entry.name}&7 ${ctx.text.formatDuration(Date.now() - entry.time)} ago: ${level.blockName(entry.from)} -> ${level.blockName(entry.to)}`) + } else { + player.message('&7 No changes recorded since the server started.') + } + } + }) + + ctx.command({ + name: 'write', + aliases: ['text', 'writetext'], + category: 'building', + rank: 'Builder', + usage: '/write [scale 1-4] ', + description: 'Writes text with blocks. Mark where it starts, then a block in the direction to write', + inGame: true, + async run (player, args, { usage }) { + if (args.length < 2) return usage() + const block = blockArg(player, args.shift()) + let scale = 1 + if (args.length > 1 && /^[1-4]$/.test(args[0])) scale = Number(args.shift()) + const message = args.join(' ') + const { pixels, width } = font.layout(message) + if (!pixels.length) throw new CommandError('Nothing to write (unsupported characters).') + checkVolume(player, pixels.length * scale * scale) + const marks = await select(player, 2, 'Write') + if (!marks) return + const [a, b] = marks + // write along the horizontal axis that points the most from the first mark to the second + const dx = b.x - a.x; const dz = b.z - a.z + const dir = Math.abs(dx) >= Math.abs(dz) ? { x: Math.sign(dx) || 1, z: 0 } : { x: 0, z: Math.sign(dz) || 1 } + const changes = [] + for (const [px, py] of pixels) { + for (let sx = 0; sx < scale; sx++) { + for (let sy = 0; sy < scale; sy++) { + const along = px * scale + sx + changes.push([a.x + dir.x * along, a.y + py * scale + sy, a.z + dir.z * along, block]) + } + } + } + apply(player, player.level, changes, `Write (${width * scale} blocks long)`) + } + }) + + ctx.command({ + name: 'measure', + aliases: ['ms'], + category: 'building', + usage: '/measure [block]', + description: 'Measures the box between two marks (and counts a block if given)', + inGame: true, + async run (player, args) { + const count = args[0] !== undefined ? player.level.parseBlock(args[0]) : null + if (args[0] !== undefined && count === null) throw new CommandError('Unknown block.') + const marks = await select(player, 2, 'Measure') + if (!marks) return + const b = box(marks[0], marks[1]) + let msg = `&eSize: &f${b.x2 - b.x1 + 1} x ${b.y2 - b.y1 + 1} x ${b.z2 - b.z1 + 1}&e = &f${volumeOf(b).toLocaleString()}&e blocks` + if (count !== null) { + let n = 0 + for (const [x, y, z] of cuboid(b, 0, 'solid')) if (player.level.getBlock(x, y, z) === count) n++ + msg += `, &f${n}&e of them are ${player.level.blockName(count)}` + } + player.message(msg) + } + }) + + require('./more')(ctx, { select, blockArg, checkVolume, box, apply, clipboards, key }) + + // manual building + + ctx.on('blockChange', (ev) => { + if (!ev.placing && ev.player.data['building.paint']) ev.block = ev.player.heldBlock + }, { priority: 'low' }) + + ctx.on('blockChange', (ev) => { + if (ev.cancelled) return + const index = ev.level.index(ev.x, ev.y, ev.z) + const change = [[index, ev.oldBlock, ev.block]] + pushUndo(ev.player, ev.level, change, true) + recordHistory(ev.level, ev.player.name, change) + }, { priority: 'monitor' }) + + ctx.on('playerAbort', ({ player }) => { player.data['building.paint'] = false }) + ctx.on('levelUnload', ({ level }) => { history.delete(level.name) }, { priority: 'monitor' }) + + module.exports.api = { apply, history } + } +} diff --git a/plugins/core-building/more.js b/plugins/core-building/more.js new file mode 100644 index 0000000..7073a50 --- /dev/null +++ b/plugins/core-building/more.js @@ -0,0 +1,532 @@ +'use strict' + +// More drawing tools from MCGalaxy: /hollow /outline /pyramid /tree /torus /spheroid /mirror /spin /mark +// /drill /rainbow /maze /center /calculate /bind /mode + +const RAINBOW = [21, 22, 23, 24, 28, 29, 31] + +module.exports = function more (ctx, h) { + const { server, CommandError } = ctx + const { select, blockArg, checkVolume, box, apply, clipboards, key } = h + + const eachIn = function * (b) { + 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++) yield [x, y, z] + } + const volumeOf = b => (b.x2 - b.x1 + 1) * (b.y2 - b.y1 + 1) * (b.z2 - b.z1 + 1) + const NEIGHBORS = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]] + + ctx.command({ + name: 'hollow', + category: 'building', + rank: 'AdvBuilder', + usage: '/hollow [only block]', + description: 'Removes the blocks inside a selection that don\'t touch air', + inGame: true, + async run (player, args) { + const level = player.level + const only = args[0] !== undefined ? level.parseBlock(args[0]) : null + if (args[0] !== undefined && only === null) throw new CommandError('Unknown block.') + const marks = await select(player, 2, 'Hollow') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, volumeOf(b)) + const changes = [] + for (const [x, y, z] of eachIn(b)) { + const block = level.getBlock(x, y, z) + if (block === 0 || (only !== null && block !== only)) continue + const enclosed = NEIGHBORS.every(([dx, dy, dz]) => level.inBounds(x + dx, y + dy, z + dz) && level.getBlock(x + dx, y + dy, z + dz) !== 0) + if (enclosed) changes.push([x, y, z, 0]) + } + apply(player, level, changes, 'Hollow') + } + }) + + ctx.command({ + name: 'outline', + category: 'building', + rank: 'AdvBuilder', + usage: '/outline ', + description: 'Surrounds a type of block with another one inside a selection', + inGame: true, + async run (player, args, { usage }) { + if (args.length < 2) return usage() + const level = player.level + const target = level.parseBlock(args[0]) + if (target === null) throw new CommandError('Unknown block.') + const block = blockArg(player, args[1]) + const marks = await select(player, 2, 'Outline') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, volumeOf(b)) + const changes = new Map() + for (const [x, y, z] of eachIn(b)) { + if (level.getBlock(x, y, z) !== target) continue + for (const [dx, dy, dz] of NEIGHBORS) { + const nx = x + dx; const ny = y + dy; const nz = z + dz + if (level.inBounds(nx, ny, nz) && level.getBlock(nx, ny, nz) === 0) changes.set(level.index(nx, ny, nz), [nx, ny, nz, block]) + } + } + apply(player, level, [...changes.values()], 'Outline') + } + }) + + ctx.command({ + name: 'pyramid', + aliases: ['pd'], + category: 'building', + rank: 'AdvBuilder', + usage: '/pyramid [block] [hollow]', + description: 'Builds a pyramid on the base you mark (two corners)', + inGame: true, + async run (player, args) { + const hollow = (args[args.length - 1] || '').toLowerCase() === 'hollow' + if (hollow) args.pop() + const block = blockArg(player, args[0]) + const marks = await select(player, 2, 'Pyramid base') + if (!marks) return + const b = box(marks[0], marks[1]) + const y0 = marks[0].y + const changes = [] + for (let layer = 0; ; layer++) { + const x1 = b.x1 + layer; const x2 = b.x2 - layer; const z1 = b.z1 + layer; const z2 = b.z2 - layer + if (x1 > x2 || z1 > z2) break + for (let x = x1; x <= x2; x++) { + for (let z = z1; z <= z2; z++) { + const edge = x === x1 || x === x2 || z === z1 || z === z2 + if (!hollow || edge) changes.push([x, y0 + layer, z, block]) + } + } + } + apply(player, player.level, changes, 'Pyramid') + } + }) + + const TREES = { + oak (x, y, z, add, rand) { + const h = 4 + Math.floor(rand() * 3) + for (let dy = h - 2; dy <= h + 1; dy++) { + const r = dy >= h ? 1 : 2 + for (let dx = -r; dx <= r; dx++) { + for (let dz = -r; dz <= r; dz++) { + if (Math.abs(dx) === r && Math.abs(dz) === r && rand() < 0.5) continue + add(x + dx, y + dy, z + dz, 18) + } + } + } + for (let dy = 0; dy < h; dy++) add(x, y + dy, z, 17) + }, + big (x, y, z, add, rand) { + const h = 8 + Math.floor(rand() * 4) + for (let dy = 3; dy <= h + 2; dy++) { + const r = Math.max(1, Math.round(4 - Math.abs(dy - (h - 2)) * 0.6)) + for (let dx = -r; dx <= r; dx++) for (let dz = -r; dz <= r; dz++) if (dx * dx + dz * dz <= r * r + 1) add(x + dx, y + dy, z + dz, 18) + } + for (let dy = 0; dy < h; dy++) add(x, y + dy, z, 17) + }, + cactus (x, y, z, add, rand) { + const h = 3 + Math.floor(rand() * 3) + for (let dy = 0; dy < h; dy++) add(x, y + dy, z, 25) + }, + bush (x, y, z, add) { + for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) add(x + dx, y, z + dz, 18) + add(x, y + 1, z, 18) + } + } + + ctx.command({ + name: 'tree', + category: 'building', + rank: 'Builder', + usage: `/tree [${Object.keys(TREES).join('|')}]`, + description: 'Grows a tree on the block you mark', + inGame: true, + async run (player, args) { + const type = (args[0] || 'oak').toLowerCase() + const grow = TREES[type] + if (!grow) throw new CommandError(`Tree types: ${Object.keys(TREES).join(', ')}`) + const marks = await select(player, 1, 'Tree') + if (!marks) return + const { x, y, z } = marks[0] + const level = player.level + const changes = [] + // leaves never replace solid blocks; logs replace everything but bedrock + grow(x, y + 1, z, (cx, cy, cz, b) => { + if (!level.inBounds(cx, cy, cz)) return + if (b === 18 && level.getBlock(cx, cy, cz) !== 0) return + changes.push([cx, cy, cz, b]) + }, Math.random) + apply(player, level, changes, 'Tree') + } + }) + + ctx.command({ + name: 'torus', + aliases: ['donut'], + category: 'building', + rank: 'AdvBuilder', + usage: '/torus ', + description: 'Draws a horizontal ring around the block you mark', + inGame: true, + async run (player, args, { usage }) { + if (args.length < 3) return usage() + const block = blockArg(player, args[0]) + const R = Number(args[1]); const r = Number(args[2]) + if (!(R >= 2 && R <= 100 && r >= 1 && r < R)) throw new CommandError('Radius 2-100, tube radius smaller than the radius.') + checkVolume(player, Math.round(2 * Math.PI * Math.PI * R * r * r)) + const marks = await select(player, 1, 'Torus center') + if (!marks) return + const c = marks[0] + const changes = [] + const ext = Math.ceil(R + r) + for (let dx = -ext; dx <= ext; dx++) { + for (let dz = -ext; dz <= ext; dz++) { + const ring = Math.hypot(dx, dz) - R + for (let dy = -Math.ceil(r); dy <= Math.ceil(r); dy++) { + if (ring * ring + dy * dy <= r * r + 0.5) changes.push([c.x + dx, c.y + dy, c.z + dz, block]) + } + } + } + apply(player, player.level, changes, 'Torus') + } + }) + + ctx.command({ + name: 'spheroid', + aliases: ['ellipsoid', 'e'], + category: 'building', + rank: 'AdvBuilder', + usage: '/spheroid [block] [hollow]', + description: 'Draws the ellipsoid that fits in a selection', + inGame: true, + async run (player, args) { + const hollow = (args[args.length - 1] || '').toLowerCase() === 'hollow' + if (hollow) args.pop() + const block = blockArg(player, args[0]) + const marks = await select(player, 2, 'Spheroid') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, volumeOf(b)) + const cx = (b.x1 + b.x2) / 2; const cy = (b.y1 + b.y2) / 2; const cz = (b.z1 + b.z2) / 2 + const rx = (b.x2 - b.x1) / 2 + 0.5; const ry = (b.y2 - b.y1) / 2 + 0.5; const rz = (b.z2 - b.z1) / 2 + 0.5 + const inside = (x, y, z) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 + ((z - cz) / rz) ** 2 <= 1 + const changes = [] + for (const [x, y, z] of eachIn(b)) { + if (!inside(x, y, z)) continue + if (hollow && NEIGHBORS.every(([dx, dy, dz]) => inside(x + dx, y + dy, z + dz))) continue + changes.push([x, y, z, block]) + } + apply(player, player.level, changes, 'Spheroid') + } + }) + + // clipboard transforms + + const clip = (player) => { + const c = clipboards.get(key(player)) + if (!c) throw new CommandError('You have not copied anything. Use /copy first.') + return c + } + + ctx.command({ + name: 'mirror', + aliases: ['flip'], + category: 'building', + rank: 'AdvBuilder', + usage: '/mirror ', + description: 'Mirrors what you copied along an axis', + inGame: true, + run (player, args, { usage }) { + const axis = ['x', 'y', 'z'].indexOf((args[0] || '').toLowerCase()) + if (axis === -1) return usage() + const c = clip(player) + for (const b of c.blocks) b[axis] = c.size[axis] - 1 - b[axis] + player.message(`&eMirrored the copy along ${'xyz'[axis]}.`) + } + }) + + ctx.command({ + name: 'spin', + aliases: ['rotate'], + category: 'building', + rank: 'AdvBuilder', + usage: '/spin <90|180|270>', + description: 'Rotates what you copied around the vertical axis', + inGame: true, + run (player, args, { usage }) { + const deg = Number(args[0]) + if (![90, 180, 270].includes(deg)) return usage() + const c = clip(player) + for (let n = 0; n < deg / 90; n++) { + const [sx, , sz] = c.size + for (const b of c.blocks) { const x = b[0]; b[0] = sz - 1 - b[2]; b[2] = x } + c.size = [sz, c.size[1], sx] + } + player.message(`&eRotated the copy ${deg} degrees.`) + } + }) + + ctx.command({ + name: 'mark', + aliases: ['m'], + category: 'building', + usage: '/mark [x y z]', + description: 'Marks your position (or coordinates) for the current selection', + inGame: true, + run (player, args) { + if (!player.selection) throw new CommandError('You are not marking anything right now.') + let p = player.blockPos + if (args.length === 3) { + const [x, y, z] = args.map(Number) + if (![x, y, z].every(Number.isInteger)) throw new CommandError('Coordinates must be whole numbers.') + p = { x, y, z } + } + const level = player.level + p = { x: Math.max(0, Math.min(level.width - 1, p.x)), y: Math.max(0, Math.min(level.height - 1, p.y)), z: Math.max(0, Math.min(level.length - 1, p.z)) } + player.addMark(p.x, p.y, p.z) + } + }) + + ctx.command({ + name: 'drill', + category: 'building', + rank: 'AdvBuilder', + usage: '/drill [distance]', + description: 'Digs a 3x3 tunnel from the block you mark, in the direction you look', + inGame: true, + async run (player, args) { + const dist = Math.min(200, Math.max(1, Number(args[0]) || 20)) + const marks = await select(player, 1, 'Drill') + if (!marks) return + const angle = player.yaw * 2 * Math.PI / 256 + const dx = Math.round(Math.sin(angle)); const dz = Math.round(-Math.cos(angle)) + const side = dx !== 0 ? [0, 0, 1] : [1, 0, 0] + const { x, y, z } = marks[0] + const level = player.level + const changes = [] + for (let i = 0; i < dist; i++) { + for (let a = -1; a <= 1; a++) { + for (let dy = -1; dy <= 1; dy++) { + const cx = x + dx * i + side[0] * a; const cy = y + dy; const cz = z + dz * i + side[2] * a + const b = level.getBlock(cx, cy, cz) + if (b !== 0 && b !== 7) changes.push([cx, cy, cz, 0]) + } + } + } + apply(player, level, changes, 'Drill') + } + }) + + ctx.command({ + name: 'rainbow', + category: 'building', + rank: 'AdvBuilder', + usage: '/rainbow', + description: 'Paints the solid blocks of a selection with rainbow colored wool', + inGame: true, + async run (player) { + const marks = await select(player, 2, 'Rainbow') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, volumeOf(b)) + const level = player.level + const changes = [] + for (const [x, y, z] of eachIn(b)) { + if (level.getBlock(x, y, z) === 0) continue + changes.push([x, y, z, RAINBOW[(x + y + z) % RAINBOW.length]]) + } + apply(player, level, changes, 'Rainbow') + } + }) + + ctx.command({ + name: 'maze', + category: 'building', + rank: 'AdvBuilder', + usage: '/maze [block] [height]', + description: 'Builds a random maze on the area you mark', + inGame: true, + async run (player, args) { + const height = Number(args[1]) || 3 + const block = blockArg(player, args[0] || 'stone') + const marks = await select(player, 2, 'Maze') + if (!marks) return + const b = box(marks[0], marks[1]) + const w = Math.floor((b.x2 - b.x1) / 2); const l = Math.floor((b.z2 - b.z1) / 2) + if (w < 2 || l < 2) throw new CommandError('The area is too small for a maze.') + checkVolume(player, (b.x2 - b.x1 + 1) * (b.z2 - b.z1 + 1) * height) + // grid of cells: walls everywhere, then carve paths with a depth first search + const W = w * 2 + 1; const L = l * 2 + 1 + const open = new Uint8Array(W * L) + const stack = [[0, 0]] + open[1 + 1 * W] = 1 + const visited = new Set(['0,0']) + while (stack.length) { + const [cx, cz] = stack[stack.length - 1] + const next = [[1, 0], [-1, 0], [0, 1], [0, -1]] + .map(([ox, oz]) => [cx + ox, cz + oz, ox, oz]) + .filter(([nx, nz]) => nx >= 0 && nz >= 0 && nx < w && nz < l && !visited.has(nx + ',' + nz)) + if (!next.length) { stack.pop(); continue } + const [nx, nz, ox, oz] = next[Math.floor(Math.random() * next.length)] + visited.add(nx + ',' + nz) + open[(cx * 2 + 1 + ox) + (cz * 2 + 1 + oz) * W] = 1 + open[(nx * 2 + 1) + (nz * 2 + 1) * W] = 1 + stack.push([nx, nz]) + } + open[1] = 1 // entrance + open[(W - 2) + (L - 1) * W] = 1 // exit + const changes = [] + for (let gx = 0; gx < W; gx++) { + for (let gz = 0; gz < L; gz++) { + for (let dy = 0; dy < height; dy++) changes.push([b.x1 + gx, b.y1 + dy, b.z1 + gz, open[gx + gz * W] ? 0 : block]) + } + } + apply(player, player.level, changes, 'Maze') + } + }) + + ctx.command({ + name: 'center', + aliases: ['centre'], + category: 'building', + rank: 'Builder', + usage: '/center', + description: 'Places gold at the center of a selection', + inGame: true, + async run (player) { + const marks = await select(player, 2, 'Center') + if (!marks) return + const b = box(marks[0], marks[1]) + const xs = [Math.floor((b.x1 + b.x2) / 2), Math.ceil((b.x1 + b.x2) / 2)] + const ys = [Math.floor((b.y1 + b.y2) / 2), Math.ceil((b.y1 + b.y2) / 2)] + const zs = [Math.floor((b.z1 + b.z2) / 2), Math.ceil((b.z1 + b.z2) / 2)] + const changes = new Map() + for (const x of xs) for (const y of ys) for (const z of zs) changes.set(`${x},${y},${z}`, [x, y, z, 41]) + apply(player, player.level, [...changes.values()], 'Center') + player.message(`&eCenter: &f${(b.x1 + b.x2) / 2} ${(b.y1 + b.y2) / 2} ${(b.z1 + b.z2) / 2}`) + } + }) + + ctx.command({ + name: 'calculate', + aliases: ['calc'], + category: 'other', + usage: '/calculate ', + description: 'Calculates things like 12*(3+4)/2, sqrt(2) or 2^10', + run (player, args, { usage, raw }) { + if (!raw) return usage() + let result + try { + result = evaluate(raw) + } catch (err) { + throw new CommandError(err.message) + } + player.message(`&e${raw} = &f${Number.isInteger(result) ? result : Number(result.toFixed(6))}`) + } + }) + + // placing one block puts another one + + ctx.on('blockChange', (ev) => { + if (!ev.placing) return + const p = ev.player + const replacement = p.data['building.mode'] ?? (p.data['building.binds'] && p.data['building.binds'].get(ev.block)) + if (replacement === undefined || replacement === null) return + if (!server.blockPerms.canPlace(p, replacement)) return + ev.block = replacement + }, { priority: 'low' }) + + ctx.command({ + name: 'bind', + category: 'building', + rank: 'Builder', + usage: '/bind [replacement] | /bind clear', + description: 'Placing the first block places the second one instead', + inGame: true, + run (player, args, { usage }) { + const binds = player.data['building.binds'] || (player.data['building.binds'] = new Map()) + if ((args[0] || '').toLowerCase() === 'clear') { binds.clear(); return player.message('&eBinds cleared.') } + const level = player.level + const from = level.parseBlock(args[0]) + if (from === null) return usage() + if (!args[1]) { binds.delete(from); return player.message(`&e${level.blockName(from)} is no longer bound.`) } + const to = blockArg(player, args[1]) + binds.set(from, to) + player.message(`&ePlacing ${level.blockName(from)} now places ${level.blockName(to)}.`) + } + }) + + ctx.command({ + name: 'mode', + category: 'building', + rank: 'Builder', + usage: '/mode [block]', + description: 'Every block you place becomes this block (no block: turn off)', + inGame: true, + run (player, args) { + if (!args[0]) { + player.data['building.mode'] = null + return player.message('&eMode off.') + } + const block = blockArg(player, args[0]) + player.data['building.mode'] = block + player.message(`&eEvery block you place is now ${player.level.blockName(block)}. Type &f/mode&e to stop.`) + } + }) + ctx.on('playerAbort', ({ player }) => { player.data['building.mode'] = null }) +} + +// A small expression evaluator: + - * / % ^, parentheses, pi, e and a few functions +function evaluate (input) { + const tokens = input.toLowerCase().match(/\d+(\.\d+)?|[a-z]+|[-+*/%^(),]/g) + if (!tokens || tokens.join('') !== input.toLowerCase().replace(/\s+/g, '')) throw new CalcError('I can only do numbers, + - * / % ^, parentheses and sqrt, abs, sin, cos, tan, log, round, floor, ceil.') + const FUNCS = { sqrt: Math.sqrt, abs: Math.abs, sin: Math.sin, cos: Math.cos, tan: Math.tan, log: Math.log10, ln: Math.log, round: Math.round, floor: Math.floor, ceil: Math.ceil } + let i = 0 + const peek = () => tokens[i] + const take = () => tokens[i++] + const expr = () => { + let v = term() + while (peek() === '+' || peek() === '-') v = take() === '+' ? v + term() : v - term() + return v + } + const term = () => { + let v = power() + while (peek() === '*' || peek() === '/' || peek() === '%') { + const op = take(); const r = power() + v = op === '*' ? v * r : op === '/' ? v / r : v % r + } + return v + } + const power = () => { + const base = unary() + if (peek() === '^') { take(); return base ** power() } + return base + } + const unary = () => { + if (peek() === '-') { take(); return -unary() } + if (peek() === '+') { take(); return unary() } + return atom() + } + const atom = () => { + const t = take() + if (t === undefined) throw new CalcError('The expression is incomplete.') + if (t === '(') { const v = expr(); if (take() !== ')') throw new CalcError('Missing ")".'); return v } + if (/^\d/.test(t)) return Number(t) + if (t === 'pi') return Math.PI + if (t === 'e') return Math.E + if (FUNCS[t]) { + if (take() !== '(') throw new CalcError(`${t} needs parentheses, like ${t}(2).`) + const v = expr() + if (take() !== ')') throw new CalcError('Missing ")".') + return FUNCS[t](v) + } + throw new CalcError(`I don't know "${t}".`) + } + const result = expr() + if (i < tokens.length) throw new CalcError(`Unexpected "${tokens[i]}".`) + if (!Number.isFinite(result)) throw new CalcError('The result is not a number.') + return result +} + +class CalcError extends Error {} +module.exports.evaluate = evaluate +module.exports.CalcError = CalcError diff --git a/plugins/core-essentials/index.js b/plugins/core-essentials/index.js new file mode 100644 index 0000000..bde412b --- /dev/null +++ b/plugins/core-essentials/index.js @@ -0,0 +1,502 @@ +'use strict' + +// Everyday commands: teleporting, private messages, player info, models, nicknames, AFK... + +const AFK_KEY = 'essentials.afk' + +module.exports = { + name: 'core-essentials', + version: '2.0.0', + description: 'Basic commands every server needs', + author: 'MCScript', + + defaultConfig: { + autoAfkMinutes: 5, + allowedModels: ['humanoid', 'chicken', 'creeper', 'pig', 'sheep', 'skeleton', 'spider', 'zombie', 'sit', 'head', 'chibi', 'giant', 'corpse', 'sheep_nofur', 'block'], + modelRank: 'Guest', + anyModelRank: 'Operator' + }, + + load (ctx) { + const { server, config, text, CommandError } = ctx + const lastWhisper = new Map() // player name -> name of last person who messaged them + + const find = (player, name) => { + if (!name) throw new CommandError('You need to specify a player.') + const target = server.findPlayer(name, player) + if (!target) throw new CommandError('Player not found.') + return target + } + + const isAfk = p => !!p.data[AFK_KEY] + const setAfk = (p, afk, reason) => { + if (isAfk(p) === afk) return + p.data[AFK_KEY] = afk ? { since: Date.now(), reason } : null + server.broadcast(afk ? `&7- ${p.coloredName} &7is AFK${reason ? ' (' + reason + ')' : ''}` : `&7- ${p.coloredName} &7is no longer AFK`) + server.tabList.update(p) + } + + // teleporting + + ctx.command({ + name: 'spawn', + category: 'essentials', + usage: '/spawn', + description: 'Teleports you to the spawn of the level', + inGame: true, + run (player) { + const s = player.level.spawn + player.teleport(s.x, s.y, s.z, s.yaw, s.pitch) + } + }) + + ctx.command({ + name: 'main', + category: 'essentials', + usage: '/main', + description: 'Takes you to the main level', + inGame: true, + run (player) { + if (player.level === server.levels.main) { + const s = player.level.spawn + return player.teleport(s.x, s.y, s.z, s.yaw, s.pitch) + } + player.changeLevel(server.levels.main) + } + }) + + ctx.command({ + name: 'tp', + aliases: ['teleport', 'move'], + category: 'essentials', + rank: 'Builder', + usage: '/tp | /tp ', + description: 'Teleports you to a player or coordinates', + inGame: true, + run (player, args, { usage }) { + if (args.length === 3 && args.every(a => /^~?-?\d+(\.\d+)?$/.test(a) || a === '~')) { + const cur = player.feetPos + const [x, y, z] = args.map((a, i) => { + const base = [cur.x, cur.y, cur.z][i] + if (a.startsWith('~')) return base + (Number(a.slice(1)) || 0) + return Number(a) + (i === 1 ? 0 : 0.5) + }) + return player.teleport(x, y, z) + } + if (args.length !== 1) return usage() + const target = find(player, args[0]) + if (target === player) throw new CommandError('You are already here.') + if (player.teleportTo(target)) player.message(`&eTeleported to ${target.coloredName}&e.`) + } + }) + + ctx.command({ + name: 'tphere', + aliases: ['summon', 's'], + category: 'essentials', + rank: 'Operator', + usage: '/tphere ', + description: 'Brings a player to you', + inGame: true, + run (player, args, { usage }) { + if (!args[0]) return usage() + const targets = args[0].toLowerCase() === 'all' ? server.online.filter(p => p !== player) : [find(player, args[0])] + for (const target of targets) { + if (target.permission > player.permission) { player.message(`&cYou can't summon ${target.coloredName}&c.`); continue } + if (target.teleportTo(player)) target.message(`&eYou were summoned by ${player.coloredName}&e.`) + } + } + }) + + // chat + + const whisper = (from, to, msg) => { + if (to.data.ignoring && to.data.ignoring.has(from.name.toLowerCase())) return from.message(`&c${to.name} is ignoring you.`) + from.message(`&9[&fme &9-> ${to.coloredName}&9] &f${msg}`) + to.message(`&9[${from.coloredName} &9-> &fme&9] &f${msg}`) + lastWhisper.set(to.name.toLowerCase(), from.name) + ctx.log.info(`[PM] ${from.name} -> ${to.name}: ${msg}`) + } + + ctx.command({ + name: 'msg', + aliases: ['tell', 'whisper', 'w', 'pm'], + category: 'chat', + usage: '/msg ', + description: 'Sends a private message', + run (player, args, { usage }) { + if (args.length < 2) return usage() + const target = find(player, args[0]) + whisper(player, target, text.sanitize(args.slice(1).join(' '))) + } + }) + + ctx.command({ + name: 'reply', + aliases: ['re'], + category: 'chat', + usage: '/reply ', + description: 'Replies to the last private message you got', + run (player, args, { usage }) { + if (!args.length) return usage() + const last = lastWhisper.get(player.name.toLowerCase()) + const target = last && server.findPlayerExact(last) + if (!target) throw new CommandError('Nobody to reply to.') + whisper(player, target, text.sanitize(args.join(' '))) + } + }) + + ctx.command({ + name: 'ignore', + category: 'chat', + usage: '/ignore ', + description: 'Stops receiving private messages from a player (toggle)', + inGame: true, + run (player, args, { usage }) { + if (!args[0]) return usage() + player.data.ignoring = player.data.ignoring || new Set() + const name = args[0].toLowerCase() + if (player.data.ignoring.delete(name)) return player.message(`&eNo longer ignoring ${args[0]}.`) + player.data.ignoring.add(name) + player.message(`&eNow ignoring private messages from ${args[0]}.`) + } + }) + + ctx.command({ + name: 'me', + category: 'chat', + usage: '/me ', + description: 'Describes an action, e.g. /me waves', + run (player, args, { usage }) { + if (!args.length) return usage() + server.broadcast(`&d* ${text.stripColors(player.coloredName)} ${text.sanitize(args.join(' '))}`) + } + }) + + ctx.command({ + name: 'say', + aliases: ['broadcast'], + category: 'chat', + rank: 'Operator', + usage: '/say ', + description: 'Broadcasts a message to everyone', + run (player, args, { usage }) { + if (!args.length) return usage() + server.broadcast(text.convertPercentCodes(args.join(' '))) + } + }) + + ctx.command({ + name: 'announce', + category: 'chat', + rank: 'Operator', + usage: '/announce ', + description: 'Shows a big message in the middle of everyone\'s screen', + run (player, args, { usage }) { + if (!args.length) return usage() + const msg = text.convertPercentCodes(args.join(' ')) + server.broadcast(msg, null, 'announce') + } + }) + + ctx.command({ + name: 'rules', + category: 'essentials', + usage: '/rules [player]', + description: 'Shows the server rules', + run (player, args) { + const target = args[0] && player.permission >= server.ranks.permissionOf('Operator') ? find(player, args[0]) : player + target.message('&eServer rules:') + for (const line of server.config.rules || []) target.message(line) + if (target !== player) player.message(`&eSent the rules to ${target.coloredName}&e.`) + } + }) + + // information + + ctx.command({ + name: 'players', + aliases: ['who', 'online', 'list'], + category: 'essentials', + usage: '/players', + description: 'Lists the players online', + run (player) { + const visible = server.online.filter(p => !p.hidden || player.permission >= p.permission) + player.message(`&eThere ${visible.length === 1 ? 'is' : 'are'} &f${visible.length}&e player${visible.length === 1 ? '' : 's'} online:`) + for (const rank of [...server.ranks.all].reverse()) { + const list = visible.filter(p => p.rank === rank) + if (!list.length) continue + player.message(`${rank.color}${rank.name}&7: ` + list.map(p => `${p.coloredName}${isAfk(p) ? '&7(afk)' : ''}${p.hidden ? '&7(hidden)' : ''}&7 (${p.level.name})`).join('&7, ')) + } + } + }) + + ctx.command({ + name: 'whois', + aliases: ['info', 'whowas', 'seen'], + category: 'essentials', + usage: '/whois [player]', + description: 'Shows information about a player (online or not)', + run (player, args) { + const name = args[0] || player.name + const online = server.matchPlayers(name) + const record = online.length === 1 ? online[0].record : server.playerDB.find(name) + if (!record) throw new CommandError(`No player named "${name}" has joined this server.`) + const rank = server.ranks.get(record.rank) || server.ranks.default + 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}`) + 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` : ''}`) + } else { + player.message(`&7 Last seen: &f${text.formatDuration(Date.now() - record.lastLogin)} ago`) + } + player.message(`&7 First joined: &f${new Date(record.firstLogin).toISOString().slice(0, 10)}&7, logins: &f${record.logins}`) + const spent = record.timeSpent + (onlinePlayer ? Date.now() - onlinePlayer.joinedAt : 0) + player.message(`&7 Time spent: &f${text.formatDuration(spent)}`) + player.message(`&7 Blocks: &f${record.blocksPlaced}&7 placed, &f${record.blocksDeleted}&7 deleted; messages: &f${record.messages}`) + if (record.ban) player.message(`&7 &cBanned&7 by ${record.ban.by}: ${record.ban.reason || 'no reason'}`) + if (player.permission >= server.ranks.permissionOf('Admin')) player.message(`&7 IP: &f${record.lastIp}`) + } + }) + + ctx.command({ + name: 'serverinfo', + aliases: ['sinfo', 'version', 'about-server'], + category: 'essentials', + usage: '/serverinfo', + description: 'Information about the server', + run (player) { + const up = text.formatDuration(Date.now() - server.startedAt) + player.message(`&eThis server runs &bMCScript ${server.version}&e, a ClassiCube server written in JavaScript.`) + player.message(`&7 Uptime: &f${up}&7, players: &f${server.online.length}/${server.config.maxPlayers}&7, levels loaded: &f${server.levels.loaded.size}`) + player.message(`&7 Registered players: &f${server.playerDB.all().length}&7, plugins: &f${server.plugins.list().length}`) + player.message(`&7 Node ${process.version}, memory ${(process.memoryUsage().rss / 1048576).toFixed(0)} MB`) + } + }) + + ctx.command({ + name: 'ping', + category: 'essentials', + usage: '/ping [player]', + description: 'Shows the connection latency', + run (player, args) { + const target = args[0] ? find(player, args[0]) : player + if (target.isConsole) throw new CommandError('The console has no ping.') + if (!target.supports('TwoWayPing')) throw new CommandError(`${target.name}'s client doesn't support measuring ping.`) + player.message(target.pingMs === null ? '&eMeasuring... try again in a few seconds.' : `&e${target.name}'s ping: &f${target.pingMs}ms`) + } + }) + + ctx.command({ + name: 'where', + aliases: ['pos', 'coords'], + category: 'essentials', + usage: '/where [player]', + description: 'Shows the position of a player', + inGame: true, + run (player, args) { + const target = args[0] ? find(player, args[0]) : player + const p = target.blockPos + player.message(`&e${target.coloredName}&e is at &f${p.x} ${p.y} ${p.z}&e in &f${target.level.name}&e (yaw ${Math.round(target.yaw * 360 / 256)}, pitch ${Math.round(target.pitch * 360 / 256)})`) + } + }) + + ctx.command({ + name: 'time', + category: 'essentials', + usage: '/time', + description: 'Shows the server time', + run (player) { player.message(`&eServer time: &f${new Date().toLocaleString()}`) } + }) + + // appearance + + ctx.command({ + name: 'model', + aliases: ['setmodel'], + category: 'essentials', + rank: config.modelRank, + usage: '/model [player] (models: chicken, creeper, pig, sit, block id...)', + description: 'Changes how you (or another player) look', + inGame: true, + run (player, args, { usage }) { + if (!args[0]) return usage() + let model = args[0].toLowerCase() + let target = player + if (args[1]) { + if (player.permission < server.ranks.permissionOf('Operator')) throw new CommandError('Only operators can change other players\' models.') + target = find(player, args[1]) + } + const blockId = player.level.parseBlock(model) + if (/^\d+$/.test(model) || (blockId !== null && !config.allowedModels.includes(model))) { + if (blockId === null) throw new CommandError('Unknown block.') + model = String(blockId) + } else if (!config.allowedModels.includes(model.split('|')[0]) && player.permission < server.ranks.permissionOf(config.anyModelRank)) { + throw new CommandError(`Allowed models: ${config.allowedModels.join(', ')}`) + } + target.setModel(model) + player.message(`&eChanged ${target === player ? 'your' : target.name + '\'s'} model to &f${model}&e.`) + } + }) + + ctx.command({ + name: 'skin', + category: 'essentials', + rank: 'Builder', + usage: '/skin [player]', + description: 'Changes your skin to another player\'s skin or an image URL', + inGame: true, + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = args[1] ? find(player, args[1]) : player + if (target !== player && player.permission < server.ranks.permissionOf('Operator')) throw new CommandError('Only operators can change other players\' skins.') + const skin = args[0].toLowerCase() === 'reset' ? null : args[0] + if (skin && skin.length > 64) throw new CommandError('Skin URLs can be at most 64 characters.') + target.setSkin(skin) + player.message(`&eSkin ${skin ? 'set to &f' + skin : 'reset'}&e.`) + } + }) + + const setField = (field, label, validate) => (player, args, { usage }) => { + if (!args[0]) return usage() + const target = find(player, args[0]) + if (target !== player && target.permission >= player.permission && !player.isConsole) throw new CommandError(`You can't change the ${label} of ${target.name}.`) + let value = args.slice(1).join(' ') || null + if (value && validate) value = validate(value) + target.record[field] = value + server.playerDB.save() + target.respawnForOthers() + player.message(`&e${label[0].toUpperCase() + label.slice(1)} of ${target.name} ${value ? 'set to &f' + value : 'removed'}&e.`) + } + + ctx.command({ + name: 'nick', + aliases: ['nickname'], + category: 'essentials', + rank: 'AdvBuilder', + usage: '/nick [nickname]', + description: 'Sets or removes a nickname', + run: setField('nick', 'nickname', v => { + v = text.stripColors(v) + if (v.length > 30) throw new CommandError('Nicknames can be at most 30 characters.') + return v + }) + }) + + ctx.command({ + name: 'color', + aliases: ['colour'], + category: 'essentials', + rank: 'AdvBuilder', + usage: '/color [color]', + description: 'Sets the name color of a player (e.g. red, &c, lime)', + run: setField('color', 'color', v => { + const c = text.parseColor(v) + if (!c) throw new CommandError(`Unknown color. Use one of: ${Object.keys(text.COLORS).join(', ')}`) + return c + }) + }) + + ctx.command({ + name: 'title', + category: 'essentials', + rank: 'Operator', + usage: '/title [title]', + description: 'Sets a title shown before the name in chat', + run: setField('title', 'title', v => { + if (text.stripColors(v).length > 20) throw new CommandError('Titles can be at most 20 characters.') + return text.convertPercentCodes(v) + }) + }) + + // CPE toys + + ctx.command({ + name: 'hold', + aliases: ['holdblock'], + category: 'essentials', + usage: '/hold [lock]', + description: 'Puts a block in your hand', + inGame: true, + run (player, args, { usage }) { + const block = player.level.parseBlock(args[0]) + if (block === null) return usage() + if (!player.supports('HeldBlock')) throw new CommandError('Your client does not support this.') + player.holdBlock(block, args[1] === 'lock') + player.message(`&eYou are now holding &f${player.level.blockName(block)}&e.`) + } + }) + + ctx.command({ + name: 'reach', + aliases: ['clickdistance'], + category: 'essentials', + rank: 'Operator', + usage: '/reach [player]', + description: 'Changes how far away you can place blocks (default 5)', + inGame: true, + run (player, args, { usage }) { + const dist = Number(args[0]) + if (!(dist >= 1 && dist <= 1024)) return usage() + const target = args[1] ? find(player, args[1]) : player + if (!target.supports('ClickDistance')) throw new CommandError('That client does not support changing the reach.') + target.setReach(dist) + player.message(`&eReach of ${target.name} set to &f${dist}&e blocks.`) + } + }) + + ctx.command({ + name: 'fly', + aliases: ['hacks'], + category: 'essentials', + rank: 'Operator', + usage: '/fly [player]', + description: 'Allows or forbids hacks (fly, noclip, speed) for a player', + inGame: true, + run (player, args, { usage }) { + const mode = (args[0] || '').toLowerCase() + if (!['on', 'off'].includes(mode)) return usage() + const target = args[1] ? find(player, args[1]) : player + if (!target.supports('HackControl')) throw new CommandError('That client does not support hack control.') + const on = mode === 'on' + target.setHacks({ flying: on, noClip: on, speeding: on, spawnControl: true, thirdPerson: true }) + player.message(`&eHacks ${on ? 'allowed' : 'disabled'} for ${target.name}.`) + } + }) + + // AFK + + ctx.command({ + name: 'afk', + category: 'essentials', + usage: '/afk [reason]', + description: 'Marks you as away from keyboard', + inGame: true, + run (player, args) { + setAfk(player, !isAfk(player), args.join(' ') || null) + } + }) + + ctx.on('playerChat', ({ player }) => setAfk(player, false), { priority: 'monitor' }) + ctx.on('playerMove', ({ player, from, to }) => { + if (isAfk(player) && Math.hypot(to.x - from.x, to.z - from.z) > 8) setAfk(player, false) + }, { priority: 'monitor' }) + ctx.on('playerCommand', ({ player, label }) => { if (label !== 'afk' && !player.isConsole) setAfk(player, false) }, { priority: 'monitor' }) + + if (config.autoAfkMinutes > 0) { + ctx.setInterval(() => { + const limit = Date.now() - config.autoAfkMinutes * 60000 + for (const p of server.online) if (!isAfk(p) && p.lastActivity < limit) setAfk(p, true, 'auto') + }, 15000) + } + + require('./more')(ctx, { find }) + + ctx.on('tabListEntry', (ev) => { + if (isAfk(ev.player)) ev.listName += ' &7(AFK)' + ev.groupRank = Math.max(0, 255 - Math.max(0, Math.min(255, ev.player.permission))) + }) + } +} diff --git a/plugins/core-essentials/more.js b/plugins/core-essentials/more.js new file mode 100644 index 0000000..99f33f1 --- /dev/null +++ b/plugins/core-essentials/more.js @@ -0,0 +1,536 @@ +'use strict' + +// More everyday commands, mostly the ones MCGalaxy players expect: +// /back /ascend /descend /tpa /kill /clear /roll /8ball /hug /faq /news /oprules /view /top /search +// /blocks /pclients /whonick /tcolor /send /inbox /loginmessage /logoutmessage /lastcmd and chat emotes. + +const fs = require('fs') +const path = require('path') + +// (name) -> CP437 character, like MCGalaxy's emotes +const EMOTES = { + smile: '☺', + darksmile: '☻', + heart: '♥', + diamond: '♦', + club: '♣', + spade: '♠', + bullet: '•', + note: '♪', + notes: '♫', + sun: '☼', + male: '♂', + female: '♀', + right: '→', + left: '←', + up: '↑', + down: '↓', + updown: '↕', + leftright: '↔', + arrowup: '▲', + arrowdown: '▼', + arrowright: '►', + arrowleft: '◄', + house: '⌂', + block: '█', + half: '½', + quarter: '¼', + degree: '°', + infinity: '∞', + pi: 'π', + sigma: 'Σ', + alpha: 'α', + beta: 'ß', + mu: 'µ' +} + +const TEXT_FILES = { + faq: ['&eFrequently asked questions:', '&fHow do I build? &7Place and break blocks. Ask an operator if you are not allowed to build.', '&fHow do I get a higher rank? &7Build something nice and ask an operator.'], + news: ['&eNo news yet. Edit config/text/news.txt to add some.'], + oprules: ['&eRules for operators:', '&f1. &7Be fair and explain why you punish someone.', '&f2. &7Do not abuse commands on other players.'] +} + +const EIGHT_BALL = [ + 'It is certain.', 'Without a doubt.', 'Yes, definitely.', 'Most likely.', 'Signs point to yes.', 'Ask again later.', + 'Better not tell you now.', 'Cannot predict now.', 'Don\'t count on it.', 'My reply is no.', 'Very doubtful.', 'Outlook not so good.' +] + +module.exports = function more (ctx, { find }) { + const { server, text, CommandError } = ctx + const isOp = p => p.isConsole || p.permission >= server.ranks.permissionOf('Operator') + + // chat emotes: "(heart)" -> ♥ + ctx.on('playerChat', (ev) => { + if (ev.player.data.noEmotes) return + ev.message = ev.message.replace(/\(([a-z]+)\)/gi, (m, name) => EMOTES[name.toLowerCase()] || m) + }, { priority: 'low' }) + + ctx.command({ + name: 'emotes', + aliases: ['emote'], + category: 'chat', + usage: '/emotes [list]', + description: 'Toggles replacing (heart), (smile)... in your messages with symbols', + run (player, args) { + if ((args[0] || '').toLowerCase() === 'list') { + return player.message('&eEmotes: &f' + Object.entries(EMOTES).map(([k, v]) => `(${k}) ${v}`).join('&7, &f')) + } + player.data.noEmotes = !player.data.noEmotes + player.message(`&eEmotes are now ${player.data.noEmotes ? 'off' : 'on'}.`) + } + }) + + // teleport helpers + + ctx.command({ + name: 'back', + category: 'essentials', + rank: 'Builder', + usage: '/back', + description: 'Takes you back to where you were before your last teleport', + inGame: true, + run (player) { + const prev = player.previousPosition + if (!prev) throw new CommandError('You have not teleported anywhere yet.') + let level = server.levels.get(prev.level) + if (!level) { + if (!server.levels.exists(prev.level)) throw new CommandError('That level no longer exists.') + level = server.levels.load(prev.level) + } + if (level !== player.level && !player.changeLevel(level)) return + player.teleport(prev.x, prev.y, prev.z, prev.yaw, prev.pitch) + } + }) + + const freeSpot = (level, x, y, z) => level.getBlock(x, y, z) === 0 && level.getBlock(x, y + 1, z) === 0 && level.getBlock(x, y - 1, z) !== 0 + const vertical = (dir) => (player) => { + const { x, y, z } = player.blockPos + const level = player.level + for (let ny = y + dir; ny > 0 && ny < level.height - 1; ny += dir) { + if (freeSpot(level, x, ny, z)) return player.teleport(x + 0.5, ny, z + 0.5) + } + throw new CommandError(`No free spot ${dir > 0 ? 'above' : 'below'} you.`) + } + + ctx.command({ name: 'ascend', category: 'essentials', rank: 'Builder', usage: '/ascend', description: 'Moves you to the next free floor above', inGame: true, run: vertical(1) }) + ctx.command({ name: 'descend', category: 'essentials', rank: 'Builder', usage: '/descend', description: 'Moves you to the next free floor below', inGame: true, run: vertical(-1) }) + + const requests = new Map() // target name -> { from, at } + ctx.command({ + name: 'tpa', + aliases: ['tprequest'], + category: 'essentials', + usage: '/tpa | /tpa accept | /tpa deny', + description: 'Asks a player if you can teleport to them', + inGame: true, + run (player, args, { usage }) { + const sub = (args[0] || '').toLowerCase() + if (!sub) return usage() + if (sub === 'accept' || sub === 'deny') { + const req = requests.get(player.name.toLowerCase()) + requests.delete(player.name.toLowerCase()) + const from = req && Date.now() - req.at < 120000 && server.findPlayerExact(req.from) + if (!from) throw new CommandError('You have no pending teleport requests.') + if (sub === 'deny') { from.message(`&c${player.name} denied your teleport request.`); return player.message('&eRequest denied.') } + from.teleportTo(player) + return player.message(`&e${from.name} teleported to you.`) + } + const target = find(player, args[0]) + if (target === player) throw new CommandError('You can\'t send a request to yourself.') + requests.set(target.name.toLowerCase(), { from: player.name, at: Date.now() }) + target.message(`&e${player.coloredName}&e wants to teleport to you. Type &f/tpa accept&e or &f/tpa deny`) + player.message(`&eRequest sent to ${target.name}.`) + } + }) + + ctx.command({ + name: 'kill', + aliases: ['respawn'], + category: 'essentials', + usage: '/kill [player] [message]', + description: 'Sends you (or someone else) back to the level spawn', + run (player, args) { + const target = args[0] ? find(player, args[0]) : player + if (target.isConsole) throw new CommandError('Specify a player.') + if (target !== player && !isOp(player)) throw new CommandError('Only operators can kill other players.') + const s = target.level.spawn + target.teleport(s.x, s.y, s.z, s.yaw, s.pitch) + const msg = args.slice(1).join(' ') + server.broadcast(`&c${target.coloredName}&c ${msg || (target === player ? 'killed themselves' : 'was killed by ' + player.name)}.`, p => p.level === target.level) + } + }) + + // chat fun + + ctx.command({ + name: 'clear', + aliases: ['clearchat', 'cls'], + category: 'chat', + usage: '/clear [all]', + description: 'Clears your chat (operators can clear everyone\'s)', + run (player, args) { + const targets = (args[0] || '').toLowerCase() === 'all' && isOp(player) ? server.online : [player] + for (const t of targets) for (let i = 0; i < 20; i++) t.message(' ') + } + }) + + ctx.command({ + name: 'roll', + aliases: ['dice'], + category: 'chat', + usage: '/roll [min] [max]', + description: 'Rolls a random number (1 to 6 by default)', + run (player, args) { + let min = args.length > 1 ? Number(args[0]) : 1 + let max = args.length > 1 ? Number(args[1]) : args.length ? Number(args[0]) : 6 + if (!Number.isInteger(min) || !Number.isInteger(max)) throw new CommandError('Use whole numbers.') + if (min > max) [min, max] = [max, min] + const n = min + Math.floor(Math.random() * (max - min + 1)) + server.broadcast(`&e${player.coloredName}&e rolled &f${n}&e (${min}-${max})`) + } + }) + + ctx.command({ + name: '8ball', + category: 'chat', + usage: '/8ball ', + description: 'Asks the magic 8 ball', + run (player, args, { usage, raw }) { + if (!args.length) return usage() + const answer = EIGHT_BALL[Math.floor(Math.random() * EIGHT_BALL.length)] + server.broadcast(`&e${player.coloredName}&e asked the 8 ball: &f${text.sanitize(raw)}`) + server.broadcast(`&5The 8 ball says: &f${answer}`) + } + }) + + const social = (verb, past) => (player, args, { usage }) => { + if (!args[0]) return usage() + const target = find(player, args[0]) + server.broadcast(`&d* ${player.coloredName}&d ${past} ${target.coloredName}&d${args[1] ? ' ' + text.sanitize(args.slice(1).join(' ')) : ''}.`) + } + ctx.command({ name: 'hug', category: 'chat', usage: '/hug ', description: 'Hugs someone', run: social('hug', 'hugged') }) + ctx.command({ name: 'high5', aliases: ['highfive'], category: 'chat', usage: '/high5 ', description: 'High fives someone', run: social('high five', 'high fived') }) + + // text files: config/text/.txt + + const textDir = path.join(server.root, 'config', 'text') + const readText = (name) => { + const file = path.join(textDir, `${name}.txt`) + if (!fs.existsSync(file)) { + if (!TEXT_FILES[name]) return null + fs.mkdirSync(textDir, { recursive: true }) + fs.writeFileSync(file, TEXT_FILES[name].join('\n') + '\n') + } + return fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(l => l.trim()) + } + const showText = (name) => (player) => { + for (const line of readText(name) || []) player.message(text.convertPercentCodes(line)) + } + ctx.command({ name: 'faq', category: 'essentials', usage: '/faq', description: 'Frequently asked questions (config/text/faq.txt)', run: showText('faq') }) + ctx.command({ name: 'news', category: 'essentials', usage: '/news', description: 'Server news (config/text/news.txt)', run: showText('news') }) + ctx.command({ name: 'oprules', category: 'moderation', rank: 'Operator', usage: '/oprules', description: 'Rules for operators (config/text/oprules.txt)', run: showText('oprules') }) + ctx.command({ + name: 'view', + category: 'essentials', + usage: '/view [file]', + description: 'Shows a text file from config/text', + run (player, args) { + if (!args[0]) { + const files = fs.existsSync(textDir) ? fs.readdirSync(textDir).filter(f => f.endsWith('.txt')).map(f => f.slice(0, -4)) : [] + return player.message(files.length ? `&eFiles: &f${files.join(', ')}` : '&eThere are no text files yet.') + } + const name = args[0].toLowerCase() + if (!/^[a-z0-9_-]+$/.test(name)) throw new CommandError('Invalid file name.') + const lines = readText(name) + if (!lines) throw new CommandError(`config/text/${name}.txt does not exist.`) + for (const line of lines) player.message(text.convertPercentCodes(line)) + } + }) + + // information + + const STATS = { + blocks: ['Blocks placed', r => r.blocksPlaced], + deleted: ['Blocks deleted', r => r.blocksDeleted], + time: ['Time played', r => r.timeSpent, v => text.formatDuration(v)], + logins: ['Logins', r => r.logins], + messages: ['Messages', r => r.messages], + kicks: ['Kicks', r => r.kicks] + } + ctx.command({ + name: 'top', + aliases: ['leaderboard'], + category: 'essentials', + usage: `/top <${Object.keys(STATS).join('|')}>`, + description: 'Shows the top 10 players', + run (player, args, { usage }) { + const stat = STATS[(args[0] || '').toLowerCase()] + if (!stat) return usage() + const [label, get, fmt = v => v.toLocaleString()] = stat + const top = server.playerDB.all().filter(r => get(r) > 0).sort((a, b) => get(b) - get(a)).slice(0, 10) + player.message(`&e${label}:`) + top.forEach((r, i) => player.message(`&f${i + 1}. ${r.name} &7- ${fmt(get(r))}`)) + if (!top.length) player.message('&7Nobody yet.') + } + }) + + ctx.command({ + name: 'search', + aliases: ['find'], + category: 'essentials', + usage: '/search ', + description: 'Searches players, commands, blocks or levels by name', + run (player, args, { usage }) { + const what = (args[0] || '').toLowerCase() + const q = (args[1] || '').toLowerCase() + if (!q) return usage() + let results + if (what.startsWith('player')) results = server.playerDB.all().map(r => r.name) + else if (what.startsWith('command')) results = server.commands.available(player).map(c => c.name) + else if (what.startsWith('block')) { + results = [] + for (let id = 0; id <= ctx.Blocks.MAX_BLOCK; id++) if (id <= ctx.Blocks.MAX_CPE || player.level.getBlockDef(id)) results.push(`${player.level.blockName(id)} (${id})`) + } else if (what.startsWith('level')) results = server.levels.listFiles() + else return usage() + const found = results.filter(r => r.toLowerCase().includes(q)) + player.message(found.length ? `&eFound ${found.length}: &f${found.slice(0, 40).join(', ')}${found.length > 40 ? '...' : ''}` : '&eNothing found.') + } + }) + + ctx.command({ + name: 'blocks', + category: 'essentials', + usage: '/blocks [custom]', + description: 'Lists the blocks of this level', + inGame: true, + run (player, args) { + const level = player.level + const custom = (args[0] || '').toLowerCase() === 'custom' + const list = [] + for (let id = custom ? ctx.Blocks.MAX_CPE + 1 : 1; id <= ctx.Blocks.MAX_BLOCK; id++) { + if (id > ctx.Blocks.MAX_CPE && !level.getBlockDef(id)) continue + list.push(`&f${id}&7:${level.blockName(id)}`) + } + player.message(list.length ? list.join('&7, ') : '&eNo custom blocks here.') + } + }) + + ctx.command({ + name: 'pclients', + aliases: ['clients'], + category: 'essentials', + usage: '/pclients', + description: 'Shows which client each player is using', + run (player) { + const byClient = new Map() + for (const p of server.online) { + const key = p.appName + (p.conn.isWebSocket ? ' (web)' : '') + byClient.set(key, [...(byClient.get(key) || []), p.name]) + } + for (const [client, names] of byClient) player.message(`&f${client}&7: ${names.join(', ')}`) + } + }) + + ctx.command({ + name: 'whonick', + aliases: ['realname'], + category: 'essentials', + usage: '/whonick ', + description: 'Finds out who is using a nickname', + run (player, args, { usage }) { + if (!args[0]) return usage() + const q = args.join(' ').toLowerCase() + const found = server.online.filter(p => p.record.nick && text.stripColors(p.record.nick).toLowerCase().includes(q)) + player.message(found.length ? found.map(p => `&f${p.record.nick}&e is &f${p.name}`).join('&7, ') : '&eNobody online uses that nickname.') + } + }) + + ctx.command({ + name: 'lastcmd', + aliases: ['last'], + category: 'moderation', + rank: 'Operator', + usage: '/lastcmd [player]', + description: 'Shows the last command a player used', + run (player, args) { + const targets = args[0] ? [find(player, args[0])] : server.online + for (const t of targets) { + const last = t.data.lastCommand + player.message(`&f${t.name}&7: ${last ? `${last.line} (${text.formatDuration(Date.now() - last.at)} ago)` : 'no commands yet'}`) + } + } + }) + + ctx.command({ + name: 'tcolor', + aliases: ['titlecolor'], + category: 'essentials', + rank: 'Operator', + usage: '/tcolor [color]', + description: 'Sets the color of a player\'s title', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = find(player, args[0]) + const color = args[1] ? text.parseColor(args[1]) : null + if (args[1] && !color) throw new CommandError('Unknown color.') + target.record.titleColor = color + server.playerDB.save() + target.respawnForOthers() + player.message(`&eTitle color of ${target.name} ${color ? 'set' : 'reset'}.`) + } + }) + + const setMessage = (field, label) => (player, args, { usage }) => { + if (!args[0]) return usage() + const record = server.playerDB.find(args[0]) + if (!record) throw new CommandError('Unknown player.') + const self = !player.isConsole && record.name.toLowerCase() === player.name.toLowerCase() + if (!self && !isOp(player)) throw new CommandError(`Only operators can change other players' ${label}.`) + const msg = text.sanitize(text.convertPercentCodes(args.slice(1).join(' '))) + if (msg.length > 60) throw new CommandError('That message is too long (60 characters max).') + record[field] = msg || null + server.playerDB.save() + player.message(`&e${label[0].toUpperCase() + label.slice(1)} of ${record.name} ${msg ? 'set to: &f' + msg : 'reset'}`) + } + ctx.command({ name: 'loginmessage', aliases: ['loginmsg'], category: 'chat', rank: 'AdvBuilder', usage: '/loginmessage [message]', description: 'Sets the message shown when a player joins', run: setMessage('loginMessage', 'login message') }) + ctx.command({ name: 'logoutmessage', aliases: ['logoutmsg'], category: 'chat', rank: 'AdvBuilder', usage: '/logoutmessage [message]', description: 'Sets the message shown when a player leaves', run: setMessage('logoutMessage', 'logout message') }) + + // CPE text colors and entity properties + + ctx.command({ + name: 'ccols', + aliases: ['customcolors', 'customcolours'], + category: 'chat', + rank: 'Admin', + usage: '/ccols |remove |list>', + description: 'Adds custom chat color codes (e.g. &h) for clients that support TextColors', + run (player, args, { usage }) { + const sub = (args[0] || 'list').toLowerCase() + const colors = server.config.customColors || (server.config.customColors = {}) + if (sub === 'list') { + const list = Object.entries(colors).map(([c, hex]) => `&${c}${c} (#${hex})`) + return player.message(list.length ? `&eCustom colors: ${list.join('&f, ')}` : '&eNo custom colors.') + } + const code = args[1] + if (!code || code.length !== 1 || /[0-9a-f&%\s]/i.test(code)) throw new CommandError('The code must be one character that is not 0-9, a-f, & or %.') + if (sub === 'add') { + const hex = (args[2] || '').replace('#', '').toLowerCase() + if (!/^[0-9a-f]{6}$/.test(hex)) return usage() + colors[code] = hex + text.extraColorCodes.add(code) + } else if (sub === 'remove') { + delete colors[code] + text.extraColorCodes.delete(code) + } else { + return usage() + } + server.saveConfig() + const n = parseInt(colors[code] || 'ffffff', 16) + for (const p of server.online) { + if (!p.supports('TextColors')) continue + p.conn.write('setTextColor', { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, a: colors[code] ? 255 : 0, code: code.charCodeAt(0) }) + } + player.message(sub === 'add' ? `&eAdded color &${code}${code}&e.` : `&eRemoved color ${code}.`) + } + }) + + const setProperty = (player, target, key, value) => { + target.data[key] = value + target.respawnForOthers() + // the player's own view only needs the property packets + if (target.supports('EntityProperty')) { + const scale = target.data.modelScale || 1 + const rot = target.data.entityRotation || [0, 0, 0] + for (const type of [3, 4, 5]) target.conn.write('setEntityProperty', { id: -1, type, value: Math.round(scale * 1000) }) + rot.forEach((deg, type) => target.conn.write('setEntityProperty', { id: -1, type, value: Math.round(deg) })) + } + if (target !== player) player.message(`&eUpdated ${target.name}.`) + } + + ctx.command({ + name: 'modelscale', + aliases: ['scale'], + category: 'essentials', + rank: 'AdvBuilder', + usage: '/modelscale <0.25-3> [player]', + description: 'Makes you (or someone) bigger or smaller', + inGame: true, + run (player, args, { usage }) { + const scale = Number(args[0]) + if (!(scale >= 0.25 && scale <= 3)) return usage() + const target = args[1] ? find(player, args[1]) : player + if (target !== player && !isOp(player)) throw new CommandError('Only operators can scale other players.') + setProperty(player, target, 'modelScale', scale === 1 ? null : scale) + } + }) + + ctx.command({ + name: 'entityrot', + aliases: ['rotation'], + category: 'essentials', + rank: 'AdvBuilder', + usage: '/entityrot [player]', + description: 'Tilts how you (or someone) look', + inGame: true, + run (player, args, { usage }) { + const axis = ['x', 'y', 'z'].indexOf((args[0] || '').toLowerCase()) + const deg = Number(args[1]) + if (axis === -1 || !Number.isFinite(deg)) return usage() + const target = args[2] ? find(player, args[2]) : player + if (target !== player && !isOp(player)) throw new CommandError('Only operators can rotate other players.') + const rot = [...(target.data.entityRotation || [0, 0, 0])] + rot[axis] = deg % 360 + setProperty(player, target, 'entityRotation', rot.some(v => v) ? rot : null) + } + }) + + // offline mail + + const mail = ctx.loadData('inbox.json', {}) + const saveMail = () => ctx.saveData('inbox.json', mail) + ctx.command({ + name: 'send', + aliases: ['mail'], + category: 'chat', + usage: '/send ', + description: 'Leaves a message for a player, even if they are offline', + run (player, args, { usage }) { + if (args.length < 2) return usage() + const record = server.playerDB.find(args[0]) + if (!record) throw new CommandError('Unknown player.') + const box = mail[record.name.toLowerCase()] || (mail[record.name.toLowerCase()] = []) + if (box.length >= 30) throw new CommandError(`${record.name}'s inbox is full.`) + box.push({ from: player.isConsole ? 'Console' : player.name, message: text.sanitize(args.slice(1).join(' ')), at: Date.now() }) + saveMail() + player.message(`&eMessage sent to ${record.name}.`) + const online = server.findPlayerExact(record.name) + if (online) online.message(`&eYou have a new message from ${player.name}. Type &f/inbox`) + } + }) + + ctx.command({ + name: 'inbox', + category: 'chat', + usage: '/inbox [clear|del ]', + description: 'Reads the messages people sent you with /send', + inGame: true, + run (player, args) { + const key = player.name.toLowerCase() + const box = mail[key] || [] + const sub = (args[0] || '').toLowerCase() + if (sub === 'clear') { delete mail[key]; saveMail(); return player.message('&eInbox cleared.') } + if (sub === 'del') { + const i = Number(args[1]) - 1 + if (!box[i]) throw new CommandError('No message with that number.') + box.splice(i, 1); saveMail() + return player.message('&eMessage deleted.') + } + if (!box.length) return player.message('&eYour inbox is empty.') + box.forEach((m, i) => player.message(`&f${i + 1}. &7[${text.formatDuration(Date.now() - m.at)} ago] &f${m.from}&7: ${m.message}`)) + } + }) + ctx.on('playerJoin', ({ player }) => { + const box = mail[player.name.toLowerCase()] + if (box && box.length) player.message(`&eYou have ${box.length} message${box.length > 1 ? 's' : ''}. Type &f/inbox`) + }) +} diff --git a/plugins/core-moderation/index.js b/plugins/core-moderation/index.js new file mode 100644 index 0000000..e00d727 --- /dev/null +++ b/plugins/core-moderation/index.js @@ -0,0 +1,372 @@ +'use strict' + +// Ranks, kicks, bans (temporary and IP), mutes, freeze, vanish and anti-spam. + +module.exports = { + name: 'core-moderation', + version: '2.0.0', + description: 'Moderation tools and anti-spam', + author: 'MCScript', + + defaultConfig: { + whitelist: { enabled: false, players: [] }, + antiSpam: { + enabled: true, + maxMessages: 6, + perSeconds: 5, + muteSeconds: 15, + maxBlocks: 250, + blocksPerSeconds: 5, + exemptRank: 'Operator' + } + }, + + load (ctx) { + const { server, config, text, CommandError } = ctx + const ranks = server.ranks + const ipBans = ctx.loadData('ipbans.json', {}) + const saveIpBans = () => ctx.saveData('ipbans.json', ipBans) + + const actorName = p => p.isConsole ? 'Console' : p.name + const find = (player, name) => { + if (!name) throw new CommandError('You need to specify a player.') + const target = server.findPlayer(name, player) + if (!target) throw new CommandError('Player not found.') + return target + } + // player record for online or offline players + const findRecord = (name) => { + const online = server.matchPlayers(name) + if (online.length === 1) return online[0].record + const r = server.playerDB.find(name) + if (!r) throw new CommandError(`No player named "${name}" has joined this server.`) + return r + } + const rankOf = record => ranks.get(record.rank) || ranks.default + const checkHigher = (player, record) => { + if (player.isConsole) return + if (record.name.toLowerCase() === player.name.toLowerCase()) throw new CommandError('You can\'t do that to yourself.') + if (rankOf(record).permission >= player.permission) throw new CommandError(`You can't do that to ${record.name}, their rank is not lower than yours.`) + } + // Optional duration as first argument: "/ban name 2h reason" + const takeDuration = (args) => { + const ms = text.parseDuration(args[0]) + if (ms !== null && ms > 0) { args.shift(); return ms } + return null + } + + // ranks + + ctx.command({ + name: 'rank', + aliases: ['setrank', 'pass'], + category: 'moderation', + rank: 'Operator', + usage: '/rank ', + description: 'Changes the rank of a player', + run (player, args, { usage }) { + if (args.length < 2) return usage() + const record = findRecord(args[0]) + const rank = ranks.get(args[1]) + if (!rank) throw new CommandError(`Unknown rank. Ranks: ${ranks.all.map(r => r.name).join(', ')}`) + checkHigher(player, record) + if (!player.isConsole && rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') + server.setRank(record.name, rank, actorName(player)) + server.broadcast(`&e${record.name} is now ${rank.color}${rank.name}&e.`) + } + }) + + const step = (dir) => (player, args, { usage }) => { + if (!args[0]) return usage() + const record = findRecord(args[0]) + checkHigher(player, record) + const all = ranks.all.filter(r => r.permission >= 0) + const i = all.indexOf(rankOf(record)) + const rank = all[i + dir] + if (!rank) throw new CommandError(`${record.name} can't be ${dir > 0 ? 'promoted' : 'demoted'} any further.`) + if (!player.isConsole && rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') + server.setRank(record.name, rank, actorName(player)) + server.broadcast(`&e${record.name} was ${dir > 0 ? 'promoted' : 'demoted'} to ${rank.color}${rank.name}&e.`) + } + + ctx.command({ name: 'promote', category: 'moderation', rank: 'Operator', usage: '/promote ', description: 'Moves a player up one rank', run: step(1) }) + ctx.command({ name: 'demote', category: 'moderation', rank: 'Operator', usage: '/demote ', description: 'Moves a player down one rank', run: step(-1) }) + + ctx.command({ + name: 'ranks', + aliases: ['viewranks'], + category: 'moderation', + usage: '/ranks [rank]', + description: 'Lists the ranks, or the players in a rank', + run (player, args) { + if (!args[0]) { + player.message('&eRanks: ' + ranks.all.map(r => `${r.color}${r.name}&7 (${r.permission})`).join('&7, ')) + return + } + const rank = ranks.get(args[0]) + if (!rank) throw new CommandError('Unknown rank.') + const members = server.playerDB.all().filter(r => rankOf(r) === rank).map(r => r.name) + player.message(`${rank.color}${rank.name}&e (${members.length}): &f${members.slice(0, 60).join(', ') || 'nobody'}`) + } + }) + + // kick / ban + + ctx.command({ + name: 'kick', + aliases: ['k'], + category: 'moderation', + rank: 'Operator', + usage: '/kick [reason]', + description: 'Disconnects a player', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = find(player, args[0]) + checkHigher(player, target.record) + const reason = args.slice(1).join(' ') || 'You were kicked' + target.record.kicks++ + server.broadcast(`&e${target.coloredName}&e was kicked by ${player.coloredName}&e: &f${reason}`) + target.kick(`Kicked by ${actorName(player)}: ${reason}`) + } + }) + + ctx.command({ + name: 'ban', + aliases: ['tempban', 'tb'], + category: 'moderation', + rank: 'Operator', + usage: '/ban [duration e.g. 30m, 2h, 7d] [reason]', + description: 'Bans a player, optionally for a limited time', + run (player, args, { usage }) { + if (!args[0]) return usage() + const record = findRecord(args.shift()) + checkHigher(player, record) + const duration = takeDuration(args) + const reason = args.join(' ') || null + record.ban = { by: actorName(player), reason, at: Date.now(), until: duration ? Date.now() + duration : null } + server.playerDB.save() + const time = duration ? ` for ${text.formatDuration(duration)}` : '' + server.broadcast(`&c${record.name} was banned${time} by ${actorName(player)}${reason ? ': &f' + reason : ''}`) + const online = server.findPlayerExact(record.name) + if (online) online.kick(`Banned${time}${reason ? ': ' + reason : ''}`) + } + }) + + ctx.command({ + name: 'unban', + category: 'moderation', + rank: 'Operator', + usage: '/unban ', + description: 'Removes a ban', + run (player, args, { usage }) { + if (!args[0]) return usage() + const record = server.playerDB.find(args[0]) + if (!record || !record.ban) throw new CommandError(`${args[0]} is not banned.`) + record.ban = null + if (rankOf(record).permission < 0) record.rank = ranks.default.name + server.playerDB.save() + server.broadcast(`&a${record.name} was unbanned by ${actorName(player)}.`) + } + }) + + ctx.command({ + name: 'banip', + aliases: ['ipban'], + category: 'moderation', + rank: 'Admin', + usage: '/banip [reason]', + description: 'Bans an IP address', + run (player, args, { usage }) { + if (!args[0]) return usage() + let ip = args[0] + if (!/^[0-9a-f.:]+$/i.test(ip)) { + const record = findRecord(ip) + checkHigher(player, record) + ip = record.lastIp + } + if (!ip) throw new CommandError('Unknown IP.') + if (!player.isConsole && ip === player.ip) throw new CommandError('You can\'t ban your own IP.') + ipBans[ip] = { by: actorName(player), reason: args.slice(1).join(' ') || null, at: Date.now() } + saveIpBans() + for (const p of server.online) if (p.ip === ip) p.kick('Your IP has been banned') + player.message(`&aBanned IP ${ip}.`) + } + }) + + ctx.command({ + name: 'unbanip', + category: 'moderation', + rank: 'Admin', + usage: '/unbanip ', + description: 'Removes an IP ban', + run (player, args, { usage }) { + if (!args[0]) return usage() + if (!ipBans[args[0]]) throw new CommandError('That IP is not banned.') + delete ipBans[args[0]] + saveIpBans() + player.message(`&aUnbanned IP ${args[0]}.`) + } + }) + + ctx.command({ + name: 'bans', + aliases: ['banlist'], + category: 'moderation', + rank: 'Operator', + usage: '/bans', + description: 'Lists banned players and IPs', + run (player) { + const banned = server.playerDB.all().filter(r => r.ban) + player.message(`&eBanned players (${banned.length}): &f${banned.map(r => r.name + (r.ban.until ? '&7(temp)&f' : '')).join(', ') || 'none'}`) + const ips = Object.keys(ipBans) + player.message(`&eBanned IPs (${ips.length}): &f${ips.join(', ') || 'none'}`) + } + }) + + // Enforce bans when someone connects + ctx.on('playerConnecting', (ev) => { + const ipBan = ipBans[ev.ip] + if (ipBan) return ev.cancel(`Your IP is banned${ipBan.reason ? ': ' + ipBan.reason : ''}`) + const record = server.playerDB.get(ev.name) + if (!record || !record.ban) return + if (record.ban.until && record.ban.until < Date.now()) { + record.ban = null + server.playerDB.save() + return + } + const left = record.ban.until ? ` (${text.formatDuration(record.ban.until - Date.now())} left)` : '' + ev.cancel(`Banned${left}${record.ban.reason ? ': ' + record.ban.reason : ''}`) + }, { priority: 'critical' }) + + ctx.command({ + name: 'sudo', + aliases: ['runas'], + category: 'moderation', + rank: 'Owner', + usage: '/sudo ', + description: 'Makes a player run a command (or say something)', + run (player, args, { usage }) { + if (args.length < 2) return usage() + const target = find(player, args[0]) + if (!player.isConsole && target.permission >= player.permission && target !== player) throw new CommandError('You can only use /sudo on lower ranks.') + const line = args.slice(1).join(' ') + if (line.startsWith('/')) server.commands.execute(target, line) + else target.chat(line) + } + }) + + // mute / freeze / vanish + + ctx.command({ + name: 'mute', + category: 'moderation', + rank: 'Operator', + usage: '/mute [duration] [reason]', + description: 'Prevents a player from chatting (default 1 hour)', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = find(player, args.shift()) + checkHigher(player, target.record) + const duration = takeDuration(args) || 3600000 + target.record.muteUntil = Date.now() + duration + target.record.muteReason = args.join(' ') || null + server.playerDB.save() + server.broadcast(`&e${target.coloredName}&e was muted for ${text.formatDuration(duration)}.`) + } + }) + + ctx.command({ + name: 'unmute', + category: 'moderation', + rank: 'Operator', + usage: '/unmute ', + description: 'Allows a muted player to chat again', + run (player, args, { usage }) { + if (!args[0]) return usage() + const record = findRecord(args[0]) + record.muteUntil = null + server.playerDB.save() + server.broadcast(`&e${record.name} was unmuted.`) + } + }) + + ctx.command({ + name: 'freeze', + aliases: ['fz'], + category: 'moderation', + rank: 'Operator', + usage: '/freeze ', + description: 'Stops a player from moving (toggle)', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = find(player, args[0]) + checkHigher(player, target.record) + target.frozen = !target.frozen + server.broadcast(`&e${target.coloredName}&e was ${target.frozen ? 'frozen' : 'unfrozen'} by ${player.coloredName}&e.`) + } + }) + + ctx.command({ + name: 'vanish', + aliases: ['hide', 'v'], + category: 'moderation', + rank: 'Operator', + usage: '/vanish', + description: 'Makes you invisible to lower ranks (toggle)', + inGame: true, + run (player) { + player.hidden = !player.hidden + for (const other of server.players) { + if (other === player || !other.spawned) continue + if (other.level === player.level) { + if (player.hidden) other.hideEntity(player.id) + else other.showEntity(player.entityInfo()) + } + if (player.hidden && other.permission < player.permission && other.supports('ExtPlayerList')) { + other.conn.write('extRemovePlayerName', { nameId: player.id }) + } + } + if (!player.hidden) server.tabList.update(player) + player.message(player.hidden ? '&eYou are now hidden.' : '&eYou are visible again.') + if (player.hidden) server.broadcast(`&c- ${player.coloredName} &eleft the game`, p => p.permission < player.permission) + else server.broadcast(`&a+ ${player.coloredName} &ejoined the game`, p => p.permission < player.permission) + } + }) + + require('./more')(ctx, { find, findRecord, rankOf, checkHigher, actorName }) + + // anti-spam + + const spam = config.antiSpam + if (spam && spam.enabled) { + const exempt = p => p.permission >= ranks.permissionOf(spam.exemptRank) + const track = (p, key, max, seconds) => { + const now = Date.now() + const list = (p.data[key] = (p.data[key] || []).filter(t => t > now - seconds * 1000)) + list.push(now) + return list.length > max + } + + ctx.on('playerChat', (ev) => { + const p = ev.player + if (exempt(p)) return + if (track(p, 'mod.chat', spam.maxMessages, spam.perSeconds)) { + p.record.muteUntil = Date.now() + spam.muteSeconds * 1000 + p.data['mod.chat'] = [] + ev.cancel(`&cYou have been muted for ${spam.muteSeconds} seconds for spamming.`) + server.broadcast(`&e${p.coloredName}&e was auto-muted for spamming.`, other => other !== p) + } + }, { priority: 'high' }) + + ctx.on('blockChange', (ev) => { + const p = ev.player + if (exempt(p)) return + if (track(p, 'mod.blocks', spam.maxBlocks, spam.blocksPerSeconds)) { + ev.cancel() + server.broadcast(`&c${p.name} was kicked for building too fast (possible griefing tool).`) + p.kick('Building too fast') + } + }, { priority: 'high' }) + } + } +} diff --git a/plugins/core-moderation/more.js b/plugins/core-moderation/more.js new file mode 100644 index 0000000..9cb2572 --- /dev/null +++ b/plugins/core-moderation/more.js @@ -0,0 +1,407 @@ +'use strict' + +// Extra moderation commands in the spirit of MCGalaxy: +// /warn /xban /temprank /report /whitelist /moveall /moderate /voice /opchat /adminchat /rankmsg +// /baninfo /banedit /follow /p2p /rankinfo /playeredit /limit /patrol + +module.exports = function more (ctx, h) { + const { server, config, text, CommandError } = ctx + const { find, findRecord, rankOf, checkHigher, actorName } = h + const ranks = server.ranks + + // warnings: the third one kicks + + ctx.command({ + name: 'warn', + category: 'moderation', + rank: 'Operator', + usage: '/warn [reason]', + description: 'Warns a player. On the third warning they are kicked', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = find(player, args[0]) + checkHigher(player, target.record) + const reason = args.slice(1).join(' ') || 'no reason given' + const warnings = (target.record.warnings || []).filter(w => Date.now() - w.at < 86400000) + warnings.push({ by: actorName(player), reason, at: Date.now() }) + target.record.warnings = warnings + server.playerDB.save() + server.broadcast(`&e${target.coloredName}&e was warned by ${player.coloredName}&e: &f${reason} &7(${warnings.length}/3)`) + target.message(`&cYou have been warned: ${reason}`, 'announce') + if (warnings.length >= 3) { + target.record.warnings = [] + target.kick(`Warned 3 times: ${reason}`) + } + } + }) + + ctx.command({ + name: 'xban', + category: 'moderation', + rank: 'Admin', + usage: '/xban [reason]', + description: 'Bans a player and their IP, and undoes their changes of the last day', + async run (player, args, { usage }) { + if (!args[0]) return usage() + const record = findRecord(args[0]) + checkHigher(player, record) + const reason = args.slice(1).join(' ') + const level = player.isConsole ? '' : ` ${player.level.name}` + if (!player.isConsole) await server.commands.execute(player, `/undoplayer ${record.name} 1d${level}`) + if (record.lastIp) await server.commands.execute(player, `/banip ${record.lastIp} ${reason}`) + await server.commands.execute(player, `/ban ${record.name} ${reason}`) + } + }) + + // temporary ranks + + const checkTempRanks = () => { + for (const record of server.online.map(p => p.record)) { + const t = record.tempRank + if (t && t.until < Date.now()) { + record.tempRank = null + server.setRank(record.name, ranks.get(t.previous) || ranks.default, 'temprank expired') + const p = server.findPlayerExact(record.name) + if (p) p.message(`&eYour temporary rank expired, you are ${p.rank.color}${p.rank.name}&e again.`) + } + } + } + ctx.setInterval(checkTempRanks, 30000) + ctx.on('playerJoin', () => checkTempRanks()) + + ctx.command({ + name: 'temprank', + aliases: ['tr', 'tempr'], + category: 'moderation', + rank: 'Admin', + usage: '/temprank ', + description: 'Gives a rank for a limited time', + run (player, args, { usage }) { + if (args.length < 3) return usage() + const record = findRecord(args[0]) + const rank = ranks.get(args[1]) + const ms = text.parseDuration(args[2]) + if (!rank || !ms) return usage() + checkHigher(player, record) + if (!player.isConsole && rank.permission >= player.permission) throw new CommandError('You can only give ranks lower than your own.') + const previous = record.tempRank ? record.tempRank.previous : rankOf(record).name + server.setRank(record.name, rank, actorName(player)) + record.tempRank = { previous, until: Date.now() + ms, by: actorName(player) } + server.playerDB.save() + server.broadcast(`&e${record.name} is ${rank.color}${rank.name}&e for ${text.formatDuration(ms)}.`) + } + }) + + // reports + + const reports = ctx.loadData('reports.json', []) + ctx.command({ + name: 'report', + category: 'moderation', + usage: '/report | /report list | /report clear', + description: 'Reports a player to the staff', + run (player, args, { usage }) { + const sub = (args[0] || '').toLowerCase() + const staff = player.isConsole || player.permission >= ranks.permissionOf('Operator') + if (sub === 'list' && staff) { + if (!reports.length) return player.message('&eNo reports.') + reports.slice(-15).forEach(r => player.message(`&f${r.target}&7 by ${r.by} (${text.formatDuration(Date.now() - r.at)} ago): ${r.reason}`)) + return + } + if (sub === 'clear' && staff) { + reports.length = 0 + ctx.saveData('reports.json', reports) + return player.message('&eReports cleared.') + } + if (args.length < 2) return usage() + const record = findRecord(args[0]) + const reason = text.sanitize(args.slice(1).join(' ')) + reports.push({ target: record.name, by: actorName(player), reason, at: Date.now() }) + while (reports.length > 200) reports.shift() + ctx.saveData('reports.json', reports) + player.message('&eThanks, the staff will look into it.') + server.broadcast(`&c[Report] &f${actorName(player)} reported ${record.name}: ${reason}`, p => p.permission >= ranks.permissionOf('Operator')) + } + }) + + // whitelist + + ctx.on('playerConnecting', (ev) => { + const wl = config.whitelist + if (!wl || !wl.enabled) return + const record = server.playerDB.get(ev.name) + const staff = record && rankOf(record).permission >= ranks.permissionOf('Operator') + if (!staff && !wl.players.includes(ev.name.toLowerCase())) ev.cancel('You are not on the whitelist') + }, { priority: 'critical' }) + + ctx.command({ + name: 'whitelist', + aliases: ['wl'], + category: 'moderation', + rank: 'Admin', + usage: '/whitelist [player]', + description: 'Only lets listed players (and staff) join', + run (player, args, { usage }) { + const wl = config.whitelist || (config.whitelist = { enabled: false, players: [] }) + const sub = (args[0] || '').toLowerCase() + const name = (args[1] || '').toLowerCase() + if (sub === 'list') return player.message(`&eWhitelist (${wl.enabled ? 'on' : 'off'}): &f${wl.players.join(', ') || 'empty'}`) + if (sub === 'on' || sub === 'off') { + wl.enabled = sub === 'on' + } else if (sub === 'add' && name) { + if (!wl.players.includes(name)) wl.players.push(name) + } else if (sub === 'remove' && name) { + wl.players = wl.players.filter(n => n !== name) + } else { + return usage() + } + ctx.saveConfig() + player.message(`&eWhitelist ${wl.enabled ? 'on' : 'off'}, ${wl.players.length} player(s).`) + } + }) + + ctx.command({ + name: 'moveall', + category: 'moderation', + rank: 'Admin', + usage: '/moveall ', + description: 'Moves every player to a level', + run (player, args, { usage }) { + if (!args[0]) return usage() + const name = server.levels.resolveName(args[0]) + if (!name) throw new CommandError('Unknown level.') + const level = server.levels.load(name) + for (const p of server.online) if (p.level !== level) p.changeLevel(level, { force: true }) + player.message(`&eEveryone was moved to ${level.name}.`) + } + }) + + // moderated chat: only voiced players and staff can talk + + let moderated = false + ctx.on('playerChat', (ev) => { + if (!moderated) return + const p = ev.player + if (p.data.voice || p.permission >= ranks.permissionOf('Operator')) return + ev.cancel('&cChat is moderated right now. Ask the staff for a voice.') + }, { priority: 'critical' }) + + ctx.command({ + name: 'moderate', + category: 'moderation', + rank: 'Operator', + usage: '/moderate', + description: 'Toggles moderated chat (only staff and /voice players can talk)', + run () { + moderated = !moderated + server.broadcast(moderated ? '&cChat is now moderated.' : '&aChat is no longer moderated.') + } + }) + + ctx.command({ + name: 'voice', + category: 'moderation', + rank: 'Operator', + usage: '/voice ', + description: 'Lets a player talk while chat is moderated (toggle)', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = find(player, args[0]) + target.data.voice = !target.data.voice + server.broadcast(`&e${target.coloredName}&e ${target.data.voice ? 'got a voice' : 'lost their voice'}.`) + } + }) + + // staff chats: "#message" for operators, "+message" for admins + + const staffChat = (minRank, label, color) => (sender, message) => { + const perm = ranks.permissionOf(minRank) + server.broadcast(`${color}[${label}] ${sender.coloredName || sender.name}${color}: &f${text.sanitize(message)}`, p => p.permission >= perm) + if (sender.isConsole !== true && sender.permission < perm) sender.message(`${color}[${label}] (sent)`) + } + const opChat = staffChat('Operator', 'Op', '&c') + const adminChat = staffChat('Admin', 'Admin', '&e') + + ctx.on('playerChat', (ev) => { + const m = ev.message + if (m.length < 2) return + if (m[0] === '#' && ev.player.permission >= ranks.permissionOf('Operator')) { ev.cancel(); opChat(ev.player, m.slice(1)) } + if (m[0] === '+' && ev.player.permission >= ranks.permissionOf('Admin')) { ev.cancel(); adminChat(ev.player, m.slice(1)) } + }, { priority: 'critical' }) + + ctx.command({ name: 'opchat', aliases: ['oc'], category: 'chat', usage: '/opchat ', description: 'Sends a message only operators can read (or start a message with #)', run (p, args, { usage, raw }) { if (!raw) return usage(); opChat(p, raw) } }) + ctx.command({ name: 'adminchat', aliases: ['ac'], category: 'chat', usage: '/adminchat ', description: 'Sends a message only admins can read (or start a message with +)', run (p, args, { usage, raw }) { if (!raw) return usage(); adminChat(p, raw) } }) + + ctx.command({ + name: 'rankmsg', + aliases: ['rm'], + category: 'chat', + rank: 'Operator', + usage: '/rankmsg ', + description: 'Sends a message to everyone with a rank (and higher)', + run (player, args, { usage }) { + const rank = ranks.get(args[0]) + if (!rank || args.length < 2) return usage() + server.broadcast(`${rank.color}[${rank.name}+] ${player.coloredName}&f: ${text.sanitize(args.slice(1).join(' '))}`, p => p.permission >= rank.permission || p === player) + } + }) + + // ban details + + ctx.command({ + name: 'baninfo', + category: 'moderation', + rank: 'Operator', + usage: '/baninfo ', + description: 'Shows why and until when a player is banned', + run (player, args, { usage }) { + if (!args[0]) return usage() + const record = findRecord(args[0]) + const ban = record.ban + if (!ban) return player.message(`&e${record.name} is not banned.`) + player.message(`&e${record.name} was banned by &f${ban.by}&e ${text.formatDuration(Date.now() - ban.at)} ago.`) + player.message(`&7Reason: &f${ban.reason || 'none'}&7, ${ban.until ? 'ends in ' + text.formatDuration(ban.until - Date.now()) : 'permanent'}`) + } + }) + + ctx.command({ + name: 'banedit', + category: 'moderation', + rank: 'Operator', + usage: '/banedit ', + description: 'Changes the reason of a ban', + run (player, args, { usage }) { + if (args.length < 2) return usage() + const record = findRecord(args[0]) + if (!record.ban) throw new CommandError(`${record.name} is not banned.`) + record.ban.reason = args.slice(1).join(' ') + server.playerDB.save() + player.message('&eBan reason updated.') + } + }) + + // watching players + + const followers = new Map() // follower -> target + ctx.setInterval(() => { + for (const [follower, target] of followers) { + if (follower.conn.closed || target.conn.closed) { followers.delete(follower); continue } + if (follower.level !== target.level) { follower.changeLevel(target.level, { force: true, silent: true }); continue } + const d = Math.hypot(follower.pos.x - target.pos.x, follower.pos.z - target.pos.z) / 32 + if (d > 4) follower.teleportTo(target) + } + }, 500) + + ctx.command({ + name: 'follow', + category: 'moderation', + rank: 'Operator', + usage: '/follow [player]', + description: 'Keeps you close to a player (run it again to stop)', + inGame: true, + run (player, args) { + if (!args[0] || followers.get(player)) { + followers.delete(player) + return player.message('&eYou stopped following.') + } + const target = find(player, args[0]) + if (target === player) throw new CommandError('You can\'t follow yourself.') + followers.set(player, target) + player.message(`&eFollowing ${target.name}. Type &f/follow&e to stop.`) + } + }) + + ctx.command({ + name: 'p2p', + category: 'moderation', + rank: 'Operator', + usage: '/p2p ', + description: 'Teleports a player to another player', + run (player, args, { usage }) { + if (args.length < 2) return usage() + const a = find(player, args[0]) + const b = find(player, args[1]) + if (a !== player) checkHigher(player, a.record) + if (a.teleportTo(b)) player.message(`&e${a.name} was teleported to ${b.name}.`) + } + }) + + ctx.command({ + name: 'patrol', + category: 'moderation', + rank: 'Operator', + usage: '/patrol', + description: 'Teleports you to a lower ranked player you have not visited recently', + inGame: true, + run (player) { + const visited = player.data.patrolled || (player.data.patrolled = new Map()) + const candidates = server.online.filter(p => p !== player && p.permission < player.permission && Date.now() - (visited.get(p.name) || 0) > 60000) + if (!candidates.length) throw new CommandError('Nobody to patrol right now.') + const target = candidates[Math.floor(Math.random() * candidates.length)] + visited.set(target.name, Date.now()) + player.teleportTo(target) + player.message(`&ePatrolling ${target.name}.`) + } + }) + + ctx.command({ + name: 'rankinfo', + aliases: ['ri'], + category: 'moderation', + usage: '/rankinfo ', + description: 'Shows the rank changes of a player', + run (player, args, { usage }) { + if (!args[0]) return usage() + const record = findRecord(args[0]) + const history = record.rankHistory || [] + if (!history.length) return player.message(`&e${record.name} has always been ${rankOf(record).name}.`) + for (const h of history.slice(-10)) { + player.message(`&7${new Date(h.at).toISOString().slice(0, 10)}: &f${h.from} &7-> &f${h.to}${h.by ? ' &7by ' + h.by : ''}`) + } + } + }) + + // editing data + + const EDITABLE = { logins: 'number', timeSpent: 'number', blocksPlaced: 'number', blocksDeleted: 'number', messages: 'number', kicks: 'number', nick: 'text', title: 'text', model: 'text', skin: 'text' } + ctx.command({ + name: 'playeredit', + aliases: ['pe'], + category: 'moderation', + rank: 'Admin', + usage: '/playeredit [value]', + description: `Edits saved player data. Fields: ${Object.keys(EDITABLE).join(', ')}`, + run (player, args, { usage }) { + if (args.length < 2) return usage() + const record = findRecord(args[0]) + const field = Object.keys(EDITABLE).find(f => f.toLowerCase() === args[1].toLowerCase()) + if (!field) throw new CommandError(`Fields: ${Object.keys(EDITABLE).join(', ')}`) + const raw = args.slice(2).join(' ') + let value = raw || null + if (EDITABLE[field] === 'number') { + value = Number(raw) + if (!Number.isFinite(value) || value < 0) throw new CommandError('That field needs a number.') + } + record[field] = value + server.playerDB.save() + player.message(`&e${record.name}'s ${field} is now &f${value ?? 'empty'}`) + } + }) + + ctx.command({ + name: 'limit', + category: 'moderation', + rank: 'Owner', + usage: '/limit ', + description: 'Changes how many blocks a rank can draw or undo at once', + run (player, args, { usage }) { + const rank = ranks.get(args[0]) + const kind = (args[1] || '').toLowerCase() + const amount = Number(args[2]) + if (!rank || !['draw', 'undo'].includes(kind) || !Number.isInteger(amount) || amount < 0) return usage() + rank[kind === 'draw' ? 'drawLimit' : 'maxUndo'] = amount + ranks.save() + player.message(`&e${rank.color}${rank.name}&e can now ${kind} ${amount.toLocaleString()} blocks.`) + } + }) +} diff --git a/plugins/core-worlds/index.js b/plugins/core-worlds/index.js new file mode 100644 index 0000000..c414176 --- /dev/null +++ b/plugins/core-worlds/index.js @@ -0,0 +1,482 @@ +'use strict' + +// Multiple levels: create, load, visit, import, backup and customize environment (colors, texture pack, weather). + +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' }, + sunset: { skyColor: 'ffa322', cloudColor: '9a6551', fogColor: 'ffa322', shadowColor: '7f6c60', sunColor: 'fffaf0' }, + night: { skyColor: '0b1026', cloudColor: '2a2a44', fogColor: '0b1026', shadowColor: '3a3a4a', sunColor: '7a7a8a' }, + midnight: { skyColor: '000000', cloudColor: '000000', fogColor: '000000', shadowColor: '2f2f2f', sunColor: '4f4f4f' }, + cartoon: { skyColor: '00b9ff', cloudColor: 'ffffff', fogColor: '70d1ff', shadowColor: 'a0a0a0', sunColor: 'ffffff' }, + noir: { skyColor: '000000', cloudColor: '1f1f1f', fogColor: '000000', shadowColor: '1f1f1f', sunColor: '737373' }, + watery: { skyColor: '5f9ea0', cloudColor: '5f9ea0', fogColor: '5f9ea0', shadowColor: '3b6c6e', sunColor: 'c4f1f2' }, + hell: { skyColor: '4a0000', cloudColor: '6e1a00', fogColor: '8b1a00', shadowColor: '5a2a2a', sunColor: 'ff9a6a' } +} + +const COLOR_PROPS = { + sky: 'skyColor', + cloud: 'cloudColor', + clouds: 'cloudColor', + fog: 'fogColor', + shadow: 'shadowColor', + ambient: 'shadowColor', + sun: 'sunColor', + sunlight: 'sunColor', + skybox: 'skyboxColor' +} + +const NUMBER_PROPS = { + edgelevel: ['edgeHeight', true], + waterlevel: ['edgeHeight', true], + level: ['edgeHeight', true], + cloudsheight: ['cloudsHeight', true], + cloudheight: ['cloudsHeight', true], + maxfog: ['maxFog', true], + cloudspeed: ['cloudsSpeed', false], + cloudsspeed: ['cloudsSpeed', false], + weatherspeed: ['weatherSpeed', false], + weatherfade: ['weatherFade', false], + sidesoffset: ['sidesOffset', true], + skyboxhorspeed: ['skyboxHorSpeed', false], + skyboxverspeed: ['skyboxVerSpeed', false] +} + +const NAMED_COLORS = { + red: 'ff0000', + green: '00ff00', + blue: '0000ff', + white: 'ffffff', + black: '000000', + yellow: 'ffff00', + orange: 'ffa500', + purple: '800080', + pink: 'ffc0cb', + gray: '808080', + grey: '808080', + cyan: '00ffff', + lime: '32cd32' +} + +const WEATHER = { sun: 0, sunny: 0, clear: 0, rain: 1, raining: 1, snow: 2, snowing: 2 } + +function parseHex (value) { + value = String(value).toLowerCase().replace(/^#/, '') + if (NAMED_COLORS[value]) return NAMED_COLORS[value] + if (/^[0-9a-f]{3}$/.test(value)) return value.split('').map(c => c + c).join('') + if (/^[0-9a-f]{6}$/.test(value)) return value + return undefined +} + +module.exports = { + name: 'core-worlds', + version: '2.0.0', + description: 'Multiple levels, environment, texture packs, weather and backups', + author: 'MCScript', + + defaultConfig: { + maxVolume: 64 * 1024 * 1024, + autoLoad: true, + defaultSize: [128, 64, 128] + }, + + load (ctx) { + const { server, config, CommandError } = ctx + const levels = server.levels + + const levelArg = (player, name, { allowUnloaded = false } = {}) => { + if (!name) return player.level + const loaded = levels.get(name) || levels.get(levels.resolveName(name)) + if (loaded) return loaded + const real = levels.resolveName(name) + if (!real) { + const matches = levels.listFiles().filter(f => f.toLowerCase().includes(name.toLowerCase())) + if (matches.length === 1) return levelArg(player, matches[0], { allowUnloaded }) + throw new CommandError(matches.length ? `Several levels match: ${matches.slice(0, 10).join(', ')}` : `Level "${name}" does not exist.`) + } + if (!allowUnloaded) throw new CommandError(`Level "${real}" is not loaded. Use /load ${real}`) + return levels.load(real) + } + + const applyEnv = (level) => { + level.dirty = true + for (const p of level.players) if (p.spawned) p.sendEnv() + } + + // levels + + ctx.command({ + name: 'newlvl', + aliases: ['newlevel', 'mapcreate'], + category: 'world', + rank: 'Admin', + usage: '/newlvl [width height length] [type] [seed]', + description: `Creates a level. Types: ${Object.keys(server.generators).join(', ')}`, + run (player, args, { usage }) { + if (!args[0]) return usage() + const name = args.shift() + let [width, height, length] = config.defaultSize + if (args.length >= 3 && args.slice(0, 3).every(a => /^\d+$/.test(a))) { + [width, height, length] = args.splice(0, 3).map(Number) + } + const type = (args[0] || 'flat').toLowerCase() + const seed = args[1] !== undefined ? parseInt(args[1], 10) || hashSeed(args[1]) : undefined + for (const v of [width, height, length]) if (v < 16 || v > 8192) throw new CommandError('Each dimension must be between 16 and 8192 (more than 1023 needs an up to date client).') + if (width * height * length > config.maxVolume) throw new CommandError(`Level too big (max ${config.maxVolume} blocks).`) + player.message(`&eGenerating ${type} level &f${name}&e (${width}x${height}x${length})...`) + const level = levels.create(name, { width, height, length, type, seed, creator: player.isConsole ? null : player.name }) + player.message(`&aCreated level ${level.name}. Use &f/goto ${level.name}&a to visit it.`) + } + }) + + ctx.command({ + name: 'goto', + aliases: ['g', 'j', 'join', 'gotolevel'], + category: 'world', + usage: '/goto ', + description: 'Takes you to another level', + inGame: true, + run (player, args, { usage }) { + if (!args[0]) return usage() + const level = levelArg(player, args[0], { allowUnloaded: config.autoLoad }) + if (player.changeLevel(level)) player.message(`&eWelcome to &f${level.name}&e!`, 'status1') + } + }) + + ctx.command({ + name: 'levels', + aliases: ['worlds', 'maps', 'lvls'], + category: 'world', + usage: '/levels', + description: 'Lists all levels', + run (player) { + const loaded = [...levels.loaded.values()] + player.message(`&eLoaded (${loaded.length}): ` + loaded.map(l => { + const n = l.players.length + return `&f${l.name}${n ? `&7[${n}]` : ''}` + }).join('&7, ')) + const unloaded = levels.listFiles().filter(f => !levels.get(f)) + if (unloaded.length) player.message(`&eOn disk (${unloaded.length}): &7${unloaded.join(', ')}`) + } + }) + + ctx.command({ + name: 'load', + aliases: ['loadlevel'], + category: 'world', + rank: 'Operator', + usage: '/load ', + description: 'Loads a level from disk', + run (player, args, { usage }) { + if (!args[0]) return usage() + const level = levelArg(player, args[0], { allowUnloaded: true }) + player.message(`&aLevel ${level.name} is loaded.`) + } + }) + + ctx.command({ + name: 'unload', + aliases: ['unloadlevel'], + category: 'world', + rank: 'Operator', + usage: '/unload ', + description: 'Saves and unloads a level (players are sent to main)', + run (player, args, { usage }) { + if (!args[0]) return usage() + const level = levelArg(player, args[0]) + if (!levels.unload(level.name)) throw new CommandError('The level could not be unloaded.') + player.message(`&aUnloaded ${level.name}.`) + } + }) + + ctx.command({ + name: 'save', + category: 'world', + rank: 'Operator', + usage: '/save [level|all]', + description: 'Saves a level (or all of them)', + run (player, args) { + if ((args[0] || '').toLowerCase() === 'all') { + const n = levels.saveAll(false) + return player.message(`&aSaved ${n} level${n === 1 ? '' : 's'}.`) + } + const level = levelArg(player, args[0]) + levels.save(level) + player.message(`&aSaved ${level.name}.`) + } + }) + + ctx.command({ + name: 'deletelvl', + aliases: ['dellvl', 'deletelevel'], + category: 'world', + rank: 'Admin', + usage: '/deletelvl confirm', + description: 'Deletes a level (it is moved to levels/deleted)', + run (player, args, { usage }) { + if (!args[0]) return usage() + const real = levels.resolveName(args[0]) + if (!real) throw new CommandError(`Level "${args[0]}" does not exist.`) + if ((args[1] || '').toLowerCase() !== 'confirm') { + return player.message(`&eType &f/deletelvl ${real} confirm&e to delete ${real}.`) + } + levels.delete(real) + player.message(`&aDeleted ${real}. A copy was kept in levels/deleted.`) + } + }) + + ctx.command({ + name: 'import', + aliases: ['importlvl'], + category: 'world', + rank: 'Admin', + usage: '/import [new name]', + description: 'Imports a map file placed in the levels folder (MCGalaxy .lvl supported)', + run (player, args, { usage }) { + if (!args[0]) return usage() + const level = levels.import(args[0], args[1]) + player.message(`&aImported ${level.name} (${level.width}x${level.height}x${level.length}).`) + for (const w of level.importWarnings || []) player.message(`&e${w}`) + } + }) + + ctx.command({ + name: 'mapinfo', + aliases: ['levelinfo', 'mi'], + category: 'world', + usage: '/mapinfo [level]', + description: 'Shows information about a level', + run (player, args) { + const level = levelArg(player, args[0]) + const rankName = r => { const x = server.ranks.get(r ?? server.ranks.default.name); return x ? x.color + x.name : String(r) } + player.message(`&eLevel &f${level.name}&e: ${level.width}x${level.height}x${level.length} (${level.volume.toLocaleString()} blocks)`) + player.message(`&7 Created ${new Date(level.createdAt).toISOString().slice(0, 10)}${level.createdBy ? ' by ' + level.createdBy : ''}${level.generator ? ', generator ' + level.generator : ''}`) + 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 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`) + const backups = levels.listBackups(level.name) + if (backups.length) player.message(`&7 Backups: &f${backups.join(', ')}`) + } + }) + + ctx.command({ + name: 'map', + aliases: ['mapset', 'levelset'], + category: 'world', + rank: 'Operator', + usage: '/map ', + description: 'Changes settings of your current level', + help: ['motd: text shown while loading; can contain hack flags like -hax +fly -noclip. "ignore" = server motd', 'buildrank / visitrank: minimum rank to build in / go to this level', 'owner add|remove : owners can always build here'], + inGame: true, + run (player, args, { usage }) { + const level = player.level + const option = (args[0] || '').toLowerCase() + const value = args.slice(1).join(' ') + if (!value) return usage() + if (option === 'motd') { + level.motd = value.slice(0, 64) + for (const p of level.players) if (p.supports('InstantMOTD')) p._sendIdentification(level) + player.message(`&aMOTD of ${level.name} set to: &f${level.motd}`) + } else if (option === 'buildrank' || option === 'perbuild') { + const rank = server.ranks.get(value) + if (!rank) throw new CommandError('Unknown rank.') + level.buildRank = rank.name + for (const p of level.players) p.sendBlockPermissions() + player.message(`&a${rank.color}${rank.name}&a+ can now build in ${level.name}.`) + } else if (option === 'owner' || option === 'owners') { + const [action, who] = value.toLowerCase().split(/\s+/) + if (!['add', 'remove'].includes(action) || !who) throw new CommandError('Usage: /map owner ') + if (action === 'add' && !level.owners.includes(who)) level.owners.push(who) + if (action === 'remove') level.owners = level.owners.filter(o => o !== who) + for (const p of level.players) p.sendBlockPermissions() + player.message(`&aOwners of ${level.name}: &f${level.owners.join(', ') || 'none'}`) + } else if (option === 'visitrank' || option === 'pervisit') { + const rank = server.ranks.get(value) + if (!rank) throw new CommandError('Unknown rank.') + level.visitRank = rank.name + player.message(`&a${rank.color}${rank.name}&a+ can now visit ${level.name}.`) + } else { + return usage() + } + level.dirty = true + } + }) + + ctx.command({ + name: 'setspawn', + category: 'world', + rank: 'Operator', + usage: '/setspawn', + description: 'Sets the spawn of the level to your position', + inGame: true, + run (player) { + const p = player.feetPos + player.level.spawn = { x: p.x, y: p.y, z: p.z, yaw: player.yaw, pitch: player.pitch } + player.level.dirty = true + player.message('&aSpawn set to your position.') + } + }) + + require('./more')(ctx, { levelArg }) + + // backups + + ctx.command({ + name: 'backup', + category: 'world', + rank: 'Operator', + usage: '/backup [list]', + description: 'Creates a backup of your level now, or lists backups', + inGame: true, + run (player, args) { + const level = player.level + if ((args[0] || '').toLowerCase() === 'list') { + const list = levels.listBackups(level.name) + return player.message(list.length ? `&eBackups of ${level.name}: &f${list.join(', ')}` : '&eNo backups yet.') + } + const n = levels.backup(level) + player.message(`&aCreated backup ${n} of ${level.name}.`) + } + }) + + ctx.command({ + name: 'restore', + category: 'world', + rank: 'Admin', + usage: '/restore ', + description: 'Restores your level from a backup', + inGame: true, + run (player, args, { usage }) { + if (!/^\d+$/.test(args[0] || '')) return usage() + levels.restore(player.level, Number(args[0])) + server.broadcast(`&e${player.level.name} was restored to backup ${args[0]}.`, p => p.level === player.level) + } + }) + + // environment + + ctx.command({ + name: 'env', + aliases: ['environment'], + category: 'world', + rank: 'Operator', + usage: '/env ', + description: 'Changes the environment of your level', + help: [ + 'Colors (hex like ff8800 or names): sky, cloud, fog, shadow, sun, skybox', + 'Blocks: side, edge. Numbers: edgelevel, cloudsheight, maxfog, cloudspeed, weatherspeed, weatherfade, sidesoffset', + 'Other: weather , expfog , lighting , preset <' + Object.keys(PRESETS).join('|') + '>' + ], + inGame: true, + run (player, args, { usage }) { + const level = player.level + const prop = (args[0] || '').toLowerCase() + const value = (args[1] || '').toLowerCase() + if (!prop) { + const e = level.env + player.message(`&eEnvironment of ${level.name}:`) + player.message(`&7 Colors: sky &f${e.skyColor || 'default'}&7, cloud &f${e.cloudColor || 'default'}&7, fog &f${e.fogColor || 'default'}&7, shadow &f${e.shadowColor || 'default'}&7, sun &f${e.sunColor || 'default'}`) + player.message(`&7 Side &f${level.blockName(e.sideBlock)}&7, edge &f${level.blockName(e.edgeBlock)}&7, edge level &f${e.edgeHeight ?? 'default'}&7, clouds &f${e.cloudsHeight ?? 'default'}`) + return player.message('&eUse &f/help env&e to see what you can change.') + } + if (!value) return usage() + const reset = value === 'reset' || value === 'default' || value === 'normal' + const env = level.env + + if (prop === 'preset') { + const preset = PRESETS[value] + if (!preset) throw new CommandError(`Presets: ${Object.keys(PRESETS).join(', ')}`) + Object.assign(env, PRESETS.normal, preset) + } else if (prop === 'reset' || prop === 'all') { + Object.assign(env, server.levels.defaultEnv(), { texture: env.texture }) + } else if (COLOR_PROPS[prop]) { + const hex = reset ? null : parseHex(value) + if (hex === undefined) throw new CommandError('Use a hex color like ff8800, a short one like f80, or a name like red.') + env[COLOR_PROPS[prop]] = hex + } else if (prop === 'side' || prop === 'sideblock' || prop === 'edge' || prop === 'edgeblock' || prop === 'water' || prop === 'bedrock') { + const key = (prop.startsWith('edge') || prop === 'water') ? 'edgeBlock' : 'sideBlock' + const block = reset ? (key === 'edgeBlock' ? 8 : 7) : level.parseBlock(value) + if (block === null) throw new CommandError('Unknown block.') + env[key] = block + } else if (NUMBER_PROPS[prop]) { + const [key, integer] = NUMBER_PROPS[prop] + const defaults = server.levels.defaultEnv() + if (reset) env[key] = defaults[key] + else { + const n = Number(value) + if (!Number.isFinite(n)) throw new CommandError('That is not a number.') + env[key] = integer ? Math.round(n) : n + } + } else if (prop === 'weather') { + if (WEATHER[value] === undefined && !reset) throw new CommandError('Weather: sun, rain or snow.') + env.weather = reset ? 0 : WEATHER[value] + } else if (prop === 'expfog') { + env.expFog = value === 'on' || value === 'true' || value === '1' + } else if (prop === 'lighting') { + const modes = { classic: 1, fancy: 2 } + if (!reset && modes[value] === undefined) throw new CommandError('Lighting: classic, fancy or reset.') + env.lighting = reset ? null : modes[value] + } else { + return usage() + } + applyEnv(level) + player.message(`&aEnvironment updated (${prop}${reset ? ' reset' : ' = ' + value}).`) + } + }) + + ctx.command({ + name: 'weather', + category: 'world', + rank: 'Operator', + usage: '/weather ', + description: 'Changes the weather of your level', + inGame: true, + run (player, args, { usage }) { + const w = WEATHER[(args[0] || '').toLowerCase()] + if (w === undefined) return usage() + player.level.env.weather = w + applyEnv(player.level) + server.broadcast(`&eThe weather in ${player.level.name} is now &f${['sunny', 'rainy', 'snowy'][w]}&e.`, p => p.level === player.level) + } + }) + + ctx.command({ + name: 'texture', + aliases: ['tex', 'texturepack'], + category: 'world', + rank: 'Operator', + usage: '/texture [global]', + description: 'Sets the texture pack (.zip) or terrain (.png) of your level, or of all levels with "global"', + inGame: true, + run (player, args, { usage }) { + if (!args[0]) return usage() + const reset = args[0].toLowerCase() === 'reset' + const url = reset ? '' : args[0] + 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).') + if (url.length > 64) throw new CommandError('The URL is too long (max 64 characters). Use a URL shortener or a shorter host.') + } + if ((args[1] || '').toLowerCase() === 'global') { + server.config.defaultTexture = url + server.saveConfig() + for (const p of server.online) p.sendEnv() + player.message(`&aDefault texture pack ${reset ? 'reset' : 'set'}.`) + } else { + player.level.env.texture = url + applyEnv(player.level) + player.message(`&aTexture pack of ${player.level.name} ${reset ? 'reset' : 'set'}.`) + } + } + }) + } +} + +function hashSeed (str) { + let h = 2166136261 + for (const c of String(str)) h = Math.imul(h ^ c.charCodeAt(0), 16777619) + return h >>> 0 +} diff --git a/plugins/core-worlds/more.js b/plugins/core-worlds/more.js new file mode 100644 index 0000000..df2bf6a --- /dev/null +++ b/plugins/core-worlds/more.js @@ -0,0 +1,142 @@ +'use strict' + +// Level maintenance commands from MCGalaxy: /copylvl /renamelvl /resizelvl /lockdown /reload /fixgrass /unflood + +module.exports = function more (ctx, { levelArg }) { + const { server, CommandError } = ctx + const levels = server.levels + + ctx.command({ + name: 'copylvl', + aliases: ['copylevel'], + category: 'world', + rank: 'Admin', + usage: '/copylvl ', + description: 'Makes a copy of a level', + run (player, args, { usage }) { + if (args.length < 2) return usage() + levels.copy(args[0], args[1]) + player.message(`&aCopied ${args[0]} to ${args[1]}.`) + } + }) + + ctx.command({ + name: 'renamelvl', + aliases: ['renamelevel'], + category: 'world', + rank: 'Admin', + usage: '/renamelvl ', + description: 'Renames a level (its backups too)', + run (player, args, { usage }) { + if (args.length < 2) return usage() + levels.rename(args[0], args[1]) + server.broadcast(`&eLevel ${args[0]} is now called &f${args[1]}&e.`) + } + }) + + ctx.command({ + name: 'resizelvl', + aliases: ['resizelevel'], + category: 'world', + rank: 'Admin', + usage: '/resizelvl [confirm]', + description: 'Resizes your current level. Blocks outside the new size are lost', + inGame: true, + run (player, args, { usage }) { + const [w, h, l] = args.slice(0, 3).map(Number) + if (![w, h, l].every(v => Number.isInteger(v) && v >= 16 && v <= 8192)) return usage() + const level = player.level + const shrinking = w < level.width || h < level.height || l < level.length + if (shrinking && (args[3] || '').toLowerCase() !== 'confirm') { + return player.message(`&eThe level gets smaller and blocks will be lost. Type &f/resizelvl ${w} ${h} ${l} confirm&e (a backup is made first).`) + } + levels.backup(level) + levels.resize(level, w, h, l) + player.message(`&a${level.name} is now ${w}x${h}x${l}.`) + } + }) + + ctx.on('playerChangeLevel', (ev) => { + const locked = ev.to.meta.locked + if (locked && ev.player.permission < server.ranks.permissionOf('Admin')) ev.cancel(`&c${ev.to.name} is locked.`) + }, { priority: 'high' }) + + ctx.command({ + name: 'lockdown', + category: 'world', + rank: 'Admin', + usage: '/lockdown [level]', + description: 'Locks a level so only admins can go there (toggle)', + run (player, args) { + const level = levelArg(player, args[0]) + level.meta.locked = !level.meta.locked + level.dirty = true + server.broadcast(`&e${level.name} is now ${level.meta.locked ? '&clocked' : '&aunlocked'}&e.`) + } + }) + + ctx.command({ + name: 'reload', + aliases: ['reveal'], + category: 'world', + usage: '/reload [all]', + description: 'Sends the level to you again (operators: to everyone in it with "all")', + run (player, args) { + const all = (args[0] || '').toLowerCase() === 'all' + if (all && player.permission < server.ranks.permissionOf('Operator')) throw new CommandError('Only operators can reload for everyone.') + if (player.isConsole && !all) throw new CommandError('Use /reload all from the console.') + const level = player.level + const targets = all ? level.players : [player] + for (const p of targets) p.reloadLevel() + } + }) + + const transparent = new Set([0, 6, 8, 9, 18, 20, 37, 38, 39, 40, 44, 50, 51, 53, 54, 60]) + + ctx.command({ + name: 'fixgrass', + category: 'world', + rank: 'Operator', + usage: '/fixgrass [level]', + description: 'Turns covered grass into dirt and uncovered dirt into grass', + run (player, args) { + const level = levelArg(player, args[0]) + const changes = [] + for (let x = 0; x < level.width; x++) { + for (let z = 0; z < level.length; z++) { + for (let y = 0; y < level.height; y++) { + const b = level.getBlock(x, y, z) + if (b !== 2 && b !== 3) continue + const above = level.getBlock(x, y + 1, z) + const lit = y === level.height - 1 || transparent.has(above) + if (b === 2 && !lit) changes.push([x, y, z, 3]) + if (b === 3 && lit && above === 0) changes.push([x, y, z, 2]) + } + } + } + level.setBlocks(changes) + player.message(`&eFixed ${changes.length} grass and dirt blocks in ${level.name}.`) + } + }) + + ctx.command({ + name: 'unflood', + category: 'world', + rank: 'Operator', + usage: '/unflood [level]', + description: 'Removes all flowing water and lava from a level', + run (player, args) { + const level = levelArg(player, args[0]) + const changes = [] + for (let i = 0; i < level.volume; i++) { + const b = level.blocks[i] + if ((b === 8 || b === 10) && !(level.upper && level.upper[i])) { + const { x, y, z } = level.unpack(i) + changes.push([x, y, z, 0]) + } + } + level.setBlocks(changes) + player.message(`&eRemoved ${changes.length} flowing water and lava blocks.`) + } + }) +} diff --git a/plugins/custom-models/index.js b/plugins/custom-models/index.js new file mode 100644 index 0000000..90abb99 --- /dev/null +++ b/plugins/custom-models/index.js @@ -0,0 +1,127 @@ +'use strict' + +// Custom 3D models (CPE CustomModels v2). Comes with a few examples and loads your own from +// config/plugins/custom-models/*.json. Use them with /model or on bots (/bot model). +// +// Model format (sizes in pixels, 16 = one block; uv in pixels of the skin): +// { "name": "mymodel", "nameY": 34, "eyeY": 26, "collision": [8, 28, 8], +// "flags": { "usesHumanSkin": true, "calcHumanAnims": false }, +// "parts": [{ "min": [-4, 0, -4], "max": [4, 8, 4], "origin": [0, 0, 0], "rotation": [0, 0, 0], +// "uv": { "top": [u1, v1, u2, v2], "bottom": [...], "front": [...], "back": [...], "left": [...], "right": [...] }, +// "anims": [{ "type": "spin", "axis": "y", "a": 1 }] }] } + +const fs = require('fs') +const path = require('path') + +// UV boxes of the standard 64x64 skin +const box = (u, v, w, h, d) => ({ + top: [u + d, v, u + d + w, v + d], + bottom: [u + d + w, v, u + d + w * 2, v + d], + front: [u + d, v + d, u + d + w, v + d + h], + back: [u + d * 2 + w, v + d, u + d * 2 + w * 2, v + d + h], + left: [u + d + w, v + d, u + d * 2 + w, v + d + h], + right: [u, v + d, u + d, v + d + h] +}) +const HEAD = box(0, 0, 8, 8, 8) +const BODY = box(16, 16, 8, 12, 4) +const ARM = box(40, 16, 4, 12, 4) +const LEG = box(0, 16, 4, 12, 4) + +const EXAMPLES = [ + { + name: 'cube', + nameY: 18, + eyeY: 12, + collision: [14, 16, 14], + pickMin: [-8, 0, -8], + pickMax: [8, 16, 8], + flags: { usesHumanSkin: true }, + parts: [{ min: [-8, 0, -8], max: [8, 16, 8], uv: HEAD }] + }, + { + name: 'spinner', + nameY: 18, + eyeY: 12, + collision: [14, 16, 14], + pickMin: [-8, 0, -8], + pickMax: [8, 16, 8], + flags: { usesHumanSkin: true }, + parts: [{ min: [-8, 0, -8], max: [8, 16, 8], origin: [0, 8, 0], uv: HEAD, anims: [{ type: 'spin', axis: 'y', a: 1.5 }] }] + }, + { + name: 'bighead', + nameY: 42, + eyeY: 32, + collision: [8, 38, 8], + pickMin: [-8, 0, -8], + pickMax: [8, 40, 8], + flags: { usesHumanSkin: true, calcHumanAnims: true }, + parts: [ + { min: [-8, 24, -8], max: [8, 40, 8], origin: [0, 24, 0], uv: HEAD, anims: [{ type: 'head', axis: 'x', a: 1 }] }, + { min: [-4, 12, -2], max: [4, 24, 2], uv: BODY }, + { min: [-8, 12, -2], max: [-4, 24, 2], origin: [-6, 22, 0], uv: ARM, anims: [{ type: 'rightArmX', axis: 'x', a: 1 }, { type: 'rightArmZ', axis: 'z', a: 1 }] }, + { min: [4, 12, -2], max: [8, 24, 2], origin: [6, 22, 0], uv: ARM, anims: [{ type: 'leftArmX', axis: 'x', a: 1 }, { type: 'leftArmZ', axis: 'z', a: 1 }] }, + { min: [-4, 0, -2], max: [0, 12, 2], origin: [-2, 12, 0], uv: LEG, anims: [{ type: 'rightLegX', axis: 'x', a: 1 }] }, + { min: [0, 0, -2], max: [4, 12, 2], origin: [2, 12, 0], uv: LEG, anims: [{ type: 'leftLegX', axis: 'x', a: 1 }] } + ] + } +] + +module.exports = { + name: 'custom-models', + version: '1.0.0', + description: 'Custom 3D models for players and bots (CustomModels)', + author: 'MCScript', + + load (ctx) { + const { server, CommandError } = ctx + const dir = path.join(server.root, 'config', 'plugins', 'custom-models') + const loaded = new Set() + + const loadAll = () => { + for (const m of EXAMPLES) { server.defineModel(m); loaded.add(m.name) } + if (!fs.existsSync(dir)) return + for (const file of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) { + try { + const model = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) + if (!model.name) model.name = path.basename(file, '.json') + server.defineModel(model) + loaded.add(model.name.toLowerCase()) + } catch (err) { + ctx.log.warn(`Could not load model ${file}: ${err.message}`) + } + } + } + loadAll() + ctx.onUnload(() => { for (const name of loaded) server.removeModel(name) }) + + // make the models usable with /model + const essentials = server.plugins.get('core-essentials') + if (essentials) { + const allowed = essentials.ctx.config.allowedModels + for (const name of loaded) if (!allowed.includes(name)) allowed.push(name) + } + + ctx.command({ + name: 'cmodel', + aliases: ['custommodels'], + category: 'other', + usage: '/cmodel ', + description: 'Lists the custom models (use them with /model )', + run (player, args, { usage }) { + const sub = (args[0] || 'list').toLowerCase() + if (sub === 'list') { + player.message(`&eCustom models: &f${[...server.customModels.keys()].join(', ') || 'none'}`) + if (!player.isConsole && !player.supports('CustomModels', 2)) player.message('&7(Your client does not support custom models.)') + return + } + if (sub === 'reload') { + if (player.permission < server.ranks.permissionOf('Admin')) throw new CommandError('Only admins can reload models.') + loadAll() + return player.message(`&aLoaded ${loaded.size} models.`) + } + return usage() + } + }) + } +} diff --git a/plugins/economy/index.js b/plugins/economy/index.js new file mode 100644 index 0000000..fc3c9ac --- /dev/null +++ b/plugins/economy/index.js @@ -0,0 +1,231 @@ +'use strict' + +// Server money: earn it by playing, pay other players, and spend it in the shop +// on ranks, titles, name colors or a personal level. + +module.exports = { + name: 'economy', + version: '1.0.0', + description: 'Money, payments, income and a shop (ranks, titles, colors, personal levels)', + author: 'MCScript', + + defaultConfig: { + currency: 'coins', + startBalance: 50, + income: 10, + incomeMinutes: 10, + shop: { + ranks: { Builder: 200, AdvBuilder: 1000 }, + title: 150, + color: 100, + level: { price: 500, width: 64, height: 64, length: 64, type: 'flat', maxPerPlayer: 1 } + } + }, + + load (ctx) { + const { server, config, text, CommandError } = ctx + const data = ctx.loadData('balances.json', { balances: {}, levels: {} }) + const save = () => ctx.saveData('balances.json', data) + const cur = config.currency + const fmt = n => `&a${Math.floor(n).toLocaleString()} ${cur}&e` + + const key = name => String(name).toLowerCase() + const balance = name => data.balances[key(name)] ?? config.startBalance + const setBalance = (name, amount) => { data.balances[key(name)] = Math.max(0, Math.floor(amount)); save() } + const add = (name, amount) => setBalance(name, balance(name) + amount) + const take = (name, amount) => { + if (balance(name) < amount) return false + setBalance(name, balance(name) - amount) + return true + } + const knownName = (name) => { + const online = server.matchPlayers(name) + if (online.length === 1) return online[0].name + const record = server.playerDB.find(name) + if (!record) throw new CommandError(`No player named "${name}" has joined this server.`) + return record.name + } + const amountArg = (v) => { + const n = Number(v) + if (!Number.isInteger(n) || n <= 0) throw new CommandError('The amount must be a whole number above 0.') + return n + } + + // income for active players + if (config.income > 0 && config.incomeMinutes > 0) { + ctx.setInterval(() => { + for (const p of server.online) { + if (p.data['essentials.afk']) continue + add(p.name, config.income) + p.message(`&7+${config.income} ${cur} for playing`, 'bottom1') + } + }, config.incomeMinutes * 60000) + } + + ctx.command({ + name: 'money', + aliases: ['balance', 'bal', 'coins'], + category: 'essentials', + usage: '/money [player]', + description: 'Shows how much money you (or someone else) have', + run (player, args) { + if (!args[0] && player.isConsole) throw new CommandError('Specify a player.') + const name = args[0] ? knownName(args[0]) : player.name + player.message(`&e${name} has ${fmt(balance(name))}.`) + } + }) + + ctx.command({ + name: 'pay', + category: 'essentials', + usage: '/pay ', + description: 'Gives some of your money to another player', + inGame: true, + run (player, args, { usage }) { + if (args.length < 2) return usage() + const target = knownName(args[0]) + if (target.toLowerCase() === player.name.toLowerCase()) throw new CommandError('You can\'t pay yourself.') + const amount = amountArg(args[1]) + if (!take(player.name, amount)) throw new CommandError(`You only have ${text.stripColors(fmt(balance(player.name)))}.`) + add(target, amount) + player.message(`&eYou paid ${fmt(amount)} to ${target}.`) + const online = server.findPlayerExact(target) + if (online) online.message(`&e${player.coloredName}&e paid you ${fmt(amount)}.`) + } + }) + + ctx.command({ + name: 'baltop', + aliases: ['richest', 'moneytop'], + category: 'essentials', + usage: '/baltop', + description: 'The richest players', + run (player) { + const top = Object.entries(data.balances).sort((a, b) => b[1] - a[1]).slice(0, 10) + if (!top.length) return player.message('&eNobody has money yet.') + player.message('&eRichest players:') + top.forEach(([name, amount], i) => { + const record = server.playerDB.get(name) + player.message(`&f${i + 1}. ${record ? record.name : name} &7- ${fmt(amount)}`) + }) + } + }) + + ctx.command({ + name: 'eco', + aliases: ['economy'], + category: 'moderation', + rank: 'Admin', + usage: '/eco ', + description: 'Changes a player\'s money', + run (player, args, { usage }) { + const action = (args[0] || '').toLowerCase() + if (!['give', 'take', 'set'].includes(action) || args.length < 3) return usage() + const target = knownName(args[1]) + const amount = action === 'set' ? Math.max(0, Number(args[2]) | 0) : amountArg(args[2]) + if (action === 'give') add(target, amount) + else if (action === 'take') setBalance(target, balance(target) - amount) + else setBalance(target, amount) + player.message(`&e${target} now has ${fmt(balance(target))}.`) + } + }) + + const giveTake = (sign) => (player, args, { usage }) => { + if (args.length < 2) return usage() + return server.commands.execute(player, `/eco ${sign > 0 ? 'give' : 'take'} ${args[0]} ${args[1]}`) + } + ctx.command({ name: 'give', category: 'moderation', rank: 'Admin', usage: '/give ', description: 'Gives money to a player', run: giveTake(1) }) + ctx.command({ name: 'take', category: 'moderation', rank: 'Admin', usage: '/take ', description: 'Takes money from a player', run: giveTake(-1) }) + + // shop + + const shop = config.shop + ctx.command({ + name: 'shop', + aliases: ['store'], + category: 'essentials', + usage: '/shop', + description: 'Lists what you can buy with /buy', + run (player) { + player.message(`&eShop (you have ${fmt(player.isConsole ? 0 : balance(player.name))}):`) + for (const [rank, price] of Object.entries(shop.ranks || {})) { + const r = server.ranks.get(rank) + if (r) player.message(`&f/buy rank ${r.name} &7- ${r.color}${r.name}&7 rank for ${fmt(price)}`) + } + if (shop.title) player.message(`&f/buy title &7- a title before your name for ${fmt(shop.title)}`) + if (shop.color) player.message(`&f/buy color <color> &7- a name color for ${fmt(shop.color)}`) + if (shop.level) player.message(`&f/buy level &7- your own ${shop.level.width}x${shop.level.height}x${shop.level.length} level for ${fmt(shop.level.price)}`) + } + }) + + ctx.command({ + name: 'buy', + category: 'essentials', + usage: '/buy <rank|title|color|level> [value]', + description: 'Buys something from the /shop', + inGame: true, + run (player, args, { usage }) { + const item = (args[0] || '').toLowerCase() + const value = args.slice(1).join(' ') + const pay = (price) => { + if (!take(player.name, price)) throw new CommandError(`That costs ${text.stripColors(fmt(price))}, you have ${text.stripColors(fmt(balance(player.name)))}.`) + } + + if (item === 'rank') { + const rank = server.ranks.get(value) + const price = rank && shop.ranks && shop.ranks[rank.name] + if (!price) throw new CommandError('That rank is not for sale. See /shop') + if (player.permission >= rank.permission) throw new CommandError('You already have that rank or a higher one.') + const next = server.ranks.next(player.rank) + if (next !== rank) throw new CommandError(`You have to buy ${next ? next.name : 'the ranks'} first.`) + pay(price) + server.setRank(player.name, rank, 'shop') + server.broadcast(`&e${player.coloredName}&e bought the ${rank.color}${rank.name}&e rank!`) + } else if (item === 'title') { + if (!shop.title) throw new CommandError('Titles are not for sale.') + const title = text.stripColors(value) + if (!title || title.length > 16) throw new CommandError('Give a title of up to 16 characters.') + pay(shop.title) + player.record.title = title + server.playerDB.save() + player.respawnForOthers() + player.message(`&aYour title is now [${title}].`) + } else if (item === 'color') { + if (!shop.color) throw new CommandError('Colors are not for sale.') + const color = text.parseColor(value) + if (!color) throw new CommandError(`Unknown color. Use one of: ${Object.keys(text.COLORS).join(', ')}`) + pay(shop.color) + player.record.color = color + server.playerDB.save() + player.respawnForOthers() + player.message(`&aYour name color is now ${color}this&a.`) + } else if (item === 'level' || item === 'map') { + const cfg = shop.level + if (!cfg) throw new CommandError('Levels are not for sale.') + const mine = (data.levels[key(player.name)] || []).filter(n => server.levels.exists(n)) + if (mine.length >= (cfg.maxPerPlayer || 1)) throw new CommandError(`You already own ${mine.join(', ')}.`) + let name = `${player.name}` + for (let n = 2; server.levels.exists(name); n++) name = `${player.name}${n}` + pay(cfg.price) + let level + try { + level = server.levels.create(name, { width: cfg.width, height: cfg.height, length: cfg.length, type: cfg.type, creator: player.name }) + } catch (err) { + add(player.name, cfg.price) // refund + throw err + } + level.owners = [key(player.name)] + level.buildRank = server.ranks.highest.name // only owners (and the highest rank) build there + level.dirty = true + data.levels[key(player.name)] = [...mine, name] + save() + player.message(`&aYou bought the level &f${name}&a! Only you can build there. Use &f/goto ${name}&a.`) + } else { + return usage() + } + } + }) + + module.exports.api = { balance, add, take, setBalance, currency: cur } + } +} diff --git a/plugins/effects/index.js b/plugins/effects/index.js new file mode 100644 index 0000000..59d3f3f --- /dev/null +++ b/plugins/effects/index.js @@ -0,0 +1,155 @@ +'use strict' + +// Particle effects (CPE CustomParticles), cinematic bars (CinematicGui) and opening the block +// inventory (ToggleBlockList). Effects are defined in config/plugins/effects.json. + +// "texture" is [u1, v1, u2, v2] inside particles.png in 1/256 units ([0, 0, 15, 15] = first 8x8 icon) +const DEFAULT_EFFECTS = { + sparkle: { texture: [0, 0, 15, 15], tint: [255, 230, 90], count: 20, size: 0.2, spread: 0.8, speed: 1.2, gravity: -0.3, lifetime: 0.8, fullBright: true, collide: false }, + smoke: { texture: [0, 0, 15, 15], tint: [120, 120, 120], count: 30, size: 0.5, spread: 1, speed: 0.6, gravity: -1, lifetime: 1.5 }, + hearts: { texture: [0, 0, 15, 15], tint: [255, 80, 140], count: 8, size: 0.35, spread: 0.6, speed: 0.4, gravity: -0.6, lifetime: 1.2, fullBright: true, collide: false }, + fire: { texture: [0, 0, 15, 15], tint: [255, 120, 20], count: 25, size: 0.3, spread: 0.4, speed: 0.8, gravity: -1.5, lifetime: 0.7, fullBright: true }, + snow: { texture: [0, 0, 15, 15], tint: [240, 245, 255], count: 40, size: 0.15, spread: 3, speed: 0.2, gravity: 2, lifetime: 3, expireOnGround: true }, + magic: { texture: [0, 0, 15, 15], tint: [170, 90, 255], count: 35, size: 0.25, spread: 1.2, speed: 2, gravity: 0, lifetime: 0.6, fullBright: true, collide: false } +} + +module.exports = { + name: 'effects', + version: '1.0.0', + description: 'Particle effects, cinematic bars and inventory control', + author: 'MCScript', + + defaultConfig: { + joinEffect: 'sparkle', + explosionEffect: 'smoke', + effects: DEFAULT_EFFECTS + }, + + load (ctx) { + const { server, config, CommandError } = ctx + for (const [name, effect] of Object.entries({ ...DEFAULT_EFFECTS, ...config.effects })) server.defineParticle(name, effect) + + const at = (player) => ({ ...player.feetPos, y: player.feetPos.y + 1 }) + + if (config.joinEffect) { + ctx.on('playerSpawn', ({ player, level }) => { + ctx.setTimeout(() => { + if (!player.spawned || player.level !== level) return + const p = at(player) + server.spawnParticles(level, config.joinEffect, p.x, p.y, p.z) + }, 1500) + }) + } + if (config.explosionEffect) { + ctx.on('explosion', ({ level, x, y, z }) => server.spawnParticles(level, config.explosionEffect, x + 0.5, y + 0.5, z + 0.5)) + } + + const findTarget = (player, name) => { + if (!name) return player + const target = server.findPlayer(name, player) + if (!target) throw new CommandError('Player not found.') + return target + } + + ctx.command({ + name: 'effect', + aliases: ['particles', 'fx'], + category: 'other', + rank: 'Builder', + usage: '/effect <name|list> [player]', + description: 'Shows a particle effect at your position (or at a player)', + inGame: true, + run (player, args, { usage }) { + const name = (args[0] || '').toLowerCase() + if (!name) return usage() + if (name === 'list') return player.message(`&eEffects: &f${[...server.particleEffects.keys()].join(', ')}`) + if (!server.particleEffects.has(name)) throw new CommandError(`Unknown effect. Effects: ${[...server.particleEffects.keys()].join(', ')}`) + const target = findTarget(player, args[1]) + const p = at(target) + server.spawnParticles(target.level, name, p.x, p.y, p.z) + if (!player.supports('CustomParticles')) player.message('&7(Your client does not show custom particles.)') + } + }) + + ctx.command({ + name: 'cinematic', + aliases: ['bars'], + category: 'other', + rank: 'Operator', + usage: '/cinematic <on|off> [player|all]', + description: 'Shows black cinematic bars and hides the HUD', + run (player, args, { usage }) { + const mode = (args[0] || '').toLowerCase() + if (!['on', 'off'].includes(mode)) return usage() + const targets = (args[1] || '').toLowerCase() === 'all' ? server.online : [findTarget(player, args[1] || (player.isConsole ? null : player.name))] + const on = mode === 'on' + let shown = 0 + for (const t of targets) if (t.setCinematic({ hideCrosshair: on, hideHand: on, hideHotbar: on, barSize: on ? 0.12 : 0 })) shown++ + player.message(`&eCinematic mode ${on ? 'on' : 'off'} for ${shown} player(s).`) + } + }) + + ctx.command({ + name: 'blocklist', + aliases: ['inventory'], + category: 'other', + rank: 'Operator', + usage: '/blocklist <open|close> [player]', + description: 'Opens or closes the block inventory of a player', + run (player, args, { usage }) { + const mode = (args[0] || '').toLowerCase() + if (!['open', 'close'].includes(mode)) return usage() + const target = findTarget(player, args[1] || (player.isConsole ? null : player.name)) + if (!target.toggleBlockList(mode === 'open')) throw new CommandError('That client does not support this.') + } + }) + + ctx.command({ + name: 'explode', + aliases: ['boom'], + category: 'other', + rank: 'Operator', + usage: '/explode [player | x y z] [radius]', + description: 'Makes an explosion at a player or at coordinates (needs the physics plugin)', + run (player, args) { + const physics = ctx.getPlugin('physics') + if (!physics || !physics.explode) throw new CommandError('The physics plugin is not loaded.') + let level = player.level + let pos + let radius = 3 + if (args.length >= 3 && args.slice(0, 3).every(a => /^-?\d+$/.test(a))) { + pos = { x: Number(args[0]), y: Number(args[1]), z: Number(args[2]) } + if (args[3]) radius = Number(args[3]) + } else { + const target = findTarget(player, args[0] || (player.isConsole ? null : player.name)) + level = target.level + pos = target.blockPos + if (args[1]) radius = Number(args[1]) + } + if (!(radius >= 1 && radius <= 10)) throw new CommandError('Radius must be between 1 and 10.') + physics.explode(level, pos.x, pos.y, pos.z, radius) + } + }) + + ctx.command({ + name: 'slap', + category: 'other', + rank: 'Operator', + usage: '/slap <player>', + description: 'Sends a player flying up', + run (player, args, { usage }) { + if (!args[0]) return usage() + const target = findTarget(player, args[0]) + if (!target.setVelocity(0, 6, 0, { addX: true, addZ: true })) { + // older clients: teleport them up instead + const p = target.feetPos + target.teleport(p.x, Math.min(target.level.height - 2, p.y + 10), p.z) + } + server.broadcast(`&d${target.coloredName}&d was slapped into the air by ${player.coloredName}&d.`, p => p.level === target.level) + } + }) + + // log what clients report through NotifyAction (useful for plugins) + ctx.on('notifyAction', ({ player, action, value }) => ctx.log.debug(`${player.name}: ${action} ${value ?? ''}`)) + } +} diff --git a/plugins/example/index.js b/plugins/example/index.js new file mode 100644 index 0000000..177f4a3 --- /dev/null +++ b/plugins/example/index.js @@ -0,0 +1,104 @@ +'use strict' + +// Example plugin: a tour of the MCScript plugin API. Copy it (or use /pcreate <name>) to start your own. +// Full reference: docs/PLUGINS.md + +module.exports = { + name: 'example', + version: '1.0.0', + description: 'Example plugin showing commands, events, config, data and CPE features', + author: 'MCScript', + + // Written to config/plugins/example.json the first time. Edit that file and /preload example. + defaultConfig: { + greeting: '&aHello, {player}!', + blockTntNearSpawn: true, + tntSafeRadius: 20 + }, + + load (ctx) { + const { server, config, log, CommandError } = ctx + + // Persistent data lives in data/plugins/example/*.json + const stats = ctx.loadData('stats.json', { joins: {} }) + + // 1) A simple command + ctx.command({ + name: 'hello', + aliases: ['hi'], + category: 'other', + usage: '/hello [player]', + description: 'Says hello (example plugin)', + run (player, args) { + const target = args[0] ? server.findPlayer(args[0], player) : player + if (!target) return // findPlayer already told the player what went wrong + target.message(config.greeting.replace('{player}', target.name)) + // Message types (CPE MessageTypes): 'announce' shows in the middle of the screen + target.message(`&eWave from ${player.coloredName}`, 'smallAnnounce') + } + }) + + // 2) Events: count joins and show a status message (top right corner) + ctx.on('playerJoin', ({ player }) => { + const key = player.name.toLowerCase() + stats.joins[key] = (stats.joins[key] || 0) + 1 + ctx.saveData('stats.json', stats) + player.message(`&7Visit #${stats.joins[key]}`, 'status1') + }) + + // 3) Cancelling events: no TNT close to spawn (except for operators) + ctx.on('blockChange', (ev) => { + if (!config.blockTntNearSpawn || !ev.placing || ev.block !== 46) return + if (ev.player.permission >= server.ranks.permissionOf('Operator')) return + const s = ev.level.spawn + if (Math.hypot(ev.x - s.x, ev.z - s.z) < config.tntSafeRadius) ev.cancel('&cNo TNT near the spawn!') + }) + + // 4) CPE: launch the player into the air (VelocityControl) + ctx.command({ + name: 'launch', + category: 'other', + rank: 'Builder', + usage: '/launch [power 1-10]', + description: 'Launches you into the air', + inGame: true, + run (player, args) { + const power = Math.max(1, Math.min(10, Number(args[0]) || 3)) + if (!player.setVelocity(0, power, 0, { addX: true, addZ: true })) { + throw new CommandError('Your client does not support VelocityControl.') + } + } + }) + + // 5) Selections + timers: highlight an area for 10 seconds + ctx.command({ + name: 'highlight', + category: 'other', + usage: '/highlight', + description: 'Marks two corners and shows a colored box for 10 seconds', + inGame: true, + async run (player) { + let marks + try { + marks = await player.selectBlocks(2, 'Highlight') + } catch (err) { + return // cancelled with /abort + } + player.showSelection(1, 'Highlight', marks[0], marks[1], [0, 200, 255, 90]) + ctx.setTimeout(() => player.hideSelection(1), 10000) // timers are cleared if the plugin unloads + } + }) + + // 6) Expose an API that other plugins can use via ctx.getPlugin('example') + module.exports.api = { + joinsOf: name => stats.joins[name.toLowerCase()] || 0 + } + + log.info(`Loaded with ${Object.keys(stats.joins).length} known players`) + }, + + unload (ctx) { + // Commands, events and timers are removed automatically; clean up anything else here. + ctx.log.info('Bye!') + } +} diff --git a/plugins/minigames/ctf.js b/plugins/minigames/ctf.js new file mode 100644 index 0000000..9ba9cc6 --- /dev/null +++ b/plugins/minigames/ctf.js @@ -0,0 +1,156 @@ +'use strict' + +// Capture the Flag: walk into the enemy flag (a wool block) to take it and bring it to your own flag. +// Click an enemy (CPE PlayerClick) near you to tag them: they respawn and drop the flag. + +const { Round, distance } = require('./round') +const gameCommand = require('./game-command') +const { TEAMS, assignTeams, smallerTeam, teamOf, spawnAt, positionOf } = require('./teams') + +module.exports = function ctf (ctx) { + const { server, CommandError } = ctx + const settings = () => ({ captures: 3, timeLimitMinutes: 10, tagDistance: 4, ...ctx.config.ctf }) + + const game = gameCommand(ctx, { + name: 'ctf', + aliases: ['capturetheflag'], + title: 'Capture the Flag', + metaKey: 'ctf', + setupHelp: ['/ctf setflag <red|blue> - mark the flag block', '/ctf setspawn <red|blue> - team spawn at your position'], + setup: { + async setflag (player, args, meta) { + const team = (args[0] || '').toLowerCase() + if (!TEAMS[team]) throw new CommandError('Team must be red or blue.') + let marks + try { marks = await player.selectBlocks(1, `${TEAMS[team].name} flag`) } catch (err) { return } + const { x, y, z } = marks[0] + meta.flags = { ...(meta.flags || {}), [team]: { x, y, z } } + player.level.setBlock(x, y, z, TEAMS[team].flagBlock) + player.level.dirty = true + player.message(`&a${TEAMS[team].color}${TEAMS[team].name}&a flag set.`) + }, + setspawn (player, args, meta) { + const team = (args[0] || '').toLowerCase() + if (!TEAMS[team]) throw new CommandError('Team must be red or blue.') + meta.spawns = { ...(meta.spawns || {}), [team]: positionOf(player) } + player.level.dirty = true + player.message(`&aSpawn of the ${TEAMS[team].color}${TEAMS[team].name}&a team set.`) + } + }, + createRound (level) { + const meta = () => level.meta.ctf || {} + let score = { red: 0, blue: 0 } + let carriers = { red: null, blue: null } // team whose flag is carried -> player + let endsAt = 0 + const round = new Round(ctx, { + id: 'ctf', + title: 'Capture the Flag', + level, + minPlayers: 2, + onStart () { + const flags = meta().flags || {} + if (!flags.red || !flags.blue) { round.end(null, 'The flags are not set up (/ctf setflag)'); return } + score = { red: 0, blue: 0 } + carriers = { red: null, blue: null } + endsAt = Date.now() + settings().timeLimitMinutes * 60000 + placeFlag('red'); placeFlag('blue') + assignTeams(round.players) + for (const p of round.players) setup(p) + round.repeat(tick, 200) + round.repeat(() => { + round.status('status1', `${TEAMS.red.color}Red ${score.red} &f- ${TEAMS.blue.color}${score.blue} Blue`) + round.status('status2', `&eTime left: &f${Math.ceil(Math.max(0, endsAt - Date.now()) / 1000)}s`) + if (Date.now() > endsAt) finish() + }, 1000) + }, + onLateJoin (p) { p.data.team = smallerTeam(round.players); setup(p) }, + onLeave (p) { dropFlag(p) } + }) + + const flagPos = team => (meta().flags || {})[team] + const placeFlag = team => { const f = flagPos(team); level.setBlock(f.x, f.y, f.z, TEAMS[team].flagBlock) } + const enemy = team => team === 'red' ? 'blue' : 'red' + + function setup (p) { + const t = teamOf(p) + p.message(`&eYou are in the ${t.color}${t.name}&e team! Bring the enemy flag to yours.`) + spawnAt(p, (meta().spawns || {})[p.data.team], level) + server.tabList.update(p) + } + + function dropFlag (p) { + for (const team of ['red', 'blue']) { + if (carriers[team] === p) { + carriers[team] = null + placeFlag(team) + round.broadcast(`${TEAMS[team].color}The ${TEAMS[team].name} flag&e was returned.`) + } + } + } + + function tick () { + if (!round.running) return + for (const p of round.players) { + const mine = p.data.team + if (!mine) continue + const other = enemy(mine) + const center = f => ({ x: (f.x + 0.5) * 32, y: f.y * 32 + 51, z: (f.z + 0.5) * 32 }) + // take the enemy flag + const ef = flagPos(other) + if (!carriers[other] && distance(p.pos, center(ef)) < 1.8) { + carriers[other] = p + level.setBlock(ef.x, ef.y, ef.z, 0) + round.broadcast(`${TEAMS[mine].color}${p.name}&e took the ${TEAMS[other].color}${TEAMS[other].name} flag&e!`, 'announce') + } + // capture at our own flag (it must be at home) + const of = flagPos(mine) + if (carriers[other] === p && !carriers[mine] && distance(p.pos, center(of)) < 1.8) { + carriers[other] = null + placeFlag(other) + score[mine]++ + round.broadcast(`${TEAMS[mine].color}${p.name}&e captured the flag! (${score[mine]}/${settings().captures})`, 'announce') + if (score[mine] >= settings().captures) return finish() + } + } + } + + function finish () { + const winner = score.red === score.blue ? null : score.red > score.blue ? 'red' : 'blue' + round.end(winner ? [...round.players].filter(p => p.data.team === winner) : [], winner ? `${TEAMS[winner].name} team wins ${score.red}-${score.blue}` : `Draw ${score.red}-${score.blue}`) + } + + round.tag = (attacker, victim) => { + if (!round.running || !round.has(victim) || victim.data.team === attacker.data.team) return + if (distance(attacker.pos, victim.pos) > settings().tagDistance) return + dropFlag(victim) + spawnAt(victim, (meta().spawns || {})[victim.data.team], level) + round.broadcast(`${teamOf(attacker).color}${attacker.name}&e tagged ${teamOf(victim).color}${victim.name}`) + } + + return round + } + }) + + ctx.on('playerClick', (ev) => { + if (ev.button !== 'left' || ev.action !== 'press' || ev.targetEntity < 0) return + const r = game.roundOf(ev.player) + if (!r || r.id !== 'ctf') return + const victim = [...r.players].find(p => p.id === ev.targetEntity) + if (victim) r.tag(ev.player, victim) + }) + + // flags can't be broken by hand during a game + ctx.on('blockChange', (ev) => { + const r = game.roundOf(ev.player) + if (!r || r.id !== 'ctf' || !r.running) return + const flags = ev.level.meta.ctf && ev.level.meta.ctf.flags + if (flags && Object.values(flags).some(f => f.x === ev.x && f.y === ev.y && f.z === ev.z)) ev.cancel() + }, { priority: 'high' }) + + ctx.on('tabListEntry', (ev) => { + const t = teamOf(ev.player) + if (t && game.roundOf(ev.player)) { ev.groupName = `${t.color}Team ${t.name}`; ev.listName = t.color + ev.player.name } + }) + + return game +} diff --git a/plugins/minigames/game-command.js b/plugins/minigames/game-command.js new file mode 100644 index 0000000..663ebe6 --- /dev/null +++ b/plugins/minigames/game-command.js @@ -0,0 +1,95 @@ +'use strict' + +// Builds the /<game> command shared by the round based games: +// join [level], leave, start (force), stop, status, enable, disable + game specific setup commands + +module.exports = function gameCommand (ctx, { name, aliases = [], title, metaKey, createRound, setup = {}, setupHelp = [] }) { + const { server, CommandError } = ctx + const rounds = new Map() // level name -> Round + + const configured = () => [...server.levels.loaded.values()].filter(l => l.meta[metaKey]) + const roundFor = (level) => { + let r = rounds.get(level.name) + if (!r) { r = createRound(level); rounds.set(level.name, r) } + return r + } + const roundOf = (player) => [...rounds.values()].find(r => r.has(player)) || null + + ctx.on('playerLeave', ({ player }) => { const r = roundOf(player); if (r) r.leave(player) }) + ctx.on('playerChangeLevel', ({ player, to }) => { const r = roundOf(player); if (r && r.level !== to) r.leave(player) }, { priority: 'monitor' }) + ctx.on('levelUnload', ({ level }) => { const r = rounds.get(level.name); if (r) { r.end(null, 'Level unloaded'); rounds.delete(level.name) } }, { priority: 'monitor' }) + ctx.onUnload(() => { for (const r of rounds.values()) r.end(null, 'Game stopped') }) + + ctx.command({ + name, + aliases, + category: 'other', + usage: `/${name} <join|leave|status|start|stop|enable|disable${Object.keys(setup).length ? '|' + Object.keys(setup).join('|') : ''}>`, + description: `Play ${title}`, + help: [`/${name} join [level] - join a game; /${name} leave`, 'Operators: enable (in the arena level), start, stop', ...setupHelp], + inGame: true, + async run (player, args, { usage }) { + const sub = (args[0] || 'status').toLowerCase() + const isOp = player.permission >= server.ranks.permissionOf('Operator') + const needOp = () => { if (!isOp) throw new CommandError('Only operators can do that.') } + + switch (sub) { + case 'join': { + if (roundOf(player)) throw new CommandError(`You are already playing. Use /${name} leave`) + let level = args[1] ? server.levels.get(args[1]) : null + if (!level) level = player.level.meta[metaKey] ? player.level : configured()[0] + if (!level || !level.meta[metaKey]) throw new CommandError(`There is no ${title} arena. An operator can create one with /${name} enable`) + return roundFor(level).join(player) + } + case 'leave': { + const r = roundOf(player) + if (!r) throw new CommandError('You are not in a game.') + return r.leave(player) + } + case 'status': { + const list = configured() + if (!list.length) return player.message(`&eNo ${title} arenas yet.`) + for (const l of list) { + const r = rounds.get(l.name) + player.message(`&f${l.name}&7: ${r ? `${r.state}, ${r.players.size} player(s)` : 'waiting, 0 players'}`) + } + return + } + case 'enable': + needOp() + player.level.meta[metaKey] = player.level.meta[metaKey] || {} + player.level.dirty = true + return player.message(`&a${player.level.name} is now a ${title} arena. Players join with &f/${name} join`) + case 'disable': + needOp() + if (rounds.get(player.level.name)) rounds.get(player.level.name).end(null, 'Arena disabled') + delete player.level.meta[metaKey] + player.level.dirty = true + return player.message(`&a${player.level.name} is no longer a ${title} arena.`) + case 'start': { + needOp() + const r = rounds.get(player.level.name) + if (!r || !r.players.size) throw new CommandError('Nobody has joined a game in this level.') + if (r.running) throw new CommandError('The game is already running.') + r.minPlayers = Math.min(r.minPlayers, r.players.size) + return r.start() + } + case 'stop': { + needOp() + const r = rounds.get(player.level.name) + if (!r) throw new CommandError('No game in this level.') + return r.end(null, `Stopped by ${player.name}`) + } + default: + if (setup[sub]) { + needOp() + if (!player.level.meta[metaKey]) throw new CommandError(`Use /${name} enable first.`) + return setup[sub](player, args.slice(1), player.level.meta[metaKey]) + } + return usage() + } + } + }) + + return { rounds, roundOf } +} diff --git a/plugins/minigames/index.js b/plugins/minigames/index.js new file mode 100644 index 0000000..53ea1de --- /dev/null +++ b/plugins/minigames/index.js @@ -0,0 +1,30 @@ +'use strict' + +// Minigames: Parkour, TNT Wars, Capture the Flag and Zombie Survival. +// An operator turns a level into an arena (/tntwars enable, /ctf enable, /zombie enable or the +// /parkour set... commands) and players join with /<game> join. + +module.exports = { + name: 'minigames', + version: '1.0.0', + description: 'Parkour, TNT Wars, Capture the Flag and Zombie Survival', + author: 'MCScript', + + defaultConfig: { + reward: 50, // coins for the winners (needs the economy plugin) + parkourReward: 25, + tntwars: { scoreLimit: 10, radius: 3, timeLimitMinutes: 10 }, + ctf: { captures: 3, timeLimitMinutes: 10, tagDistance: 4 }, + zombie: { minutes: 5, touchDistance: 1.2 } + }, + + load (ctx) { + require('./parkour')(ctx) + const games = { + tntwars: require('./tntwars')(ctx), + ctf: require('./ctf')(ctx), + zombie: require('./zombie')(ctx) + } + module.exports.api = { games } + } +} diff --git a/plugins/minigames/parkour.js b/plugins/minigames/parkour.js new file mode 100644 index 0000000..b771dea --- /dev/null +++ b/plugins/minigames/parkour.js @@ -0,0 +1,155 @@ +'use strict' + +// Parkour: step on the start block to start the timer, checkpoints save your progress, +// the finish block stops the timer. Best times are saved per level. + +module.exports = function parkour (ctx) { + const { server, CommandError } = ctx + const records = ctx.loadData('parkour.json', {}) + const runs = new Map() // player -> { level, started, checkpoint, index } + + const courseOf = level => level.meta.parkour || null + const same = (a, b) => a && b && a.x === b.x && a.y === b.y && a.z === b.z + const feetBlocks = (pos) => { + const x = Math.floor(pos.x / 32); const z = Math.floor(pos.z / 32) + const y = Math.floor((pos.y - 51) / 32) + return [{ x, y, z }, { x, y: y - 1, z }] // the block at the feet and the one below + } + const fmt = ms => `${(ms / 1000).toFixed(2)}s` + const toCheckpoint = (player, run) => player.teleport(run.checkpoint.x + 0.5, run.checkpoint.y + 1, run.checkpoint.z + 0.5) + + ctx.on('playerMove', (ev) => { + const player = ev.player + const course = courseOf(player.level) + if (!course) return + const run = runs.get(player) + const feet = feetBlocks(ev.to) + + if (course.fallY !== null && course.fallY !== undefined && run && feet[0].y < course.fallY) { + toCheckpoint(player, run) + player.message('&cYou fell! Back to your checkpoint.', 'status3') + return + } + for (const b of feet) { + if (same(b, course.start) && (!run || run.index > 0 || Date.now() - run.started > 1500)) { + runs.set(player, { level: player.level.name, started: Date.now(), checkpoint: course.start, index: 0 }) + player.message('&aParkour started! Go!', 'announce') + return + } + if (!run || run.level !== player.level.name) continue + const cp = (course.checkpoints || []).findIndex(c => same(c, b)) + if (cp !== -1 && cp + 1 > run.index) { + run.index = cp + 1 + run.checkpoint = course.checkpoints[cp] + player.message(`&eCheckpoint ${cp + 1} reached (${fmt(Date.now() - run.started)})`, 'status3') + return + } + if (same(b, course.finish)) { + const time = Date.now() - run.started + runs.delete(player) + player.message('', 'status2') + const levelRecords = records[player.level.name] || (records[player.level.name] = Object.create(null)) + const best = levelRecords[player.name.toLowerCase()] + const top = Math.min(...Object.values(levelRecords), Infinity) + if (!best || time < best) { + levelRecords[player.name.toLowerCase()] = time + ctx.saveData('parkour.json', records) + } + if (time < top) server.broadcast(`&6${player.coloredName}&6 set a new parkour record in ${player.level.name}: &f${fmt(time)}&6!`) + else player.message(`&aFinished in &f${fmt(time)}&a${best && time >= best ? ` (your best: ${fmt(best)})` : ' - personal best!'}`) + const eco = ctx.getPlugin('economy') + if (eco && ctx.config.parkourReward && !best) { eco.add(player.name, ctx.config.parkourReward); player.message(`&a+${ctx.config.parkourReward} ${eco.currency} for your first finish!`) } + return + } + } + }, { priority: 'monitor' }) + + ctx.setInterval(() => { + for (const [player, run] of runs) { + if (player.conn.closed || player.level.name !== run.level) { runs.delete(player); continue } + player.message(`&eTime: &f${fmt(Date.now() - run.started)}`, 'status2') + } + }, 250) + + const mark = async (player, label) => { + try { + const [m] = await player.selectBlocks(1, label) + return { x: m.x, y: m.y, z: m.z } + } catch (err) { return null } + } + + ctx.command({ + name: 'parkour', + aliases: ['pk'], + category: 'other', + usage: '/parkour <top|cp|leave|setstart|setfinish|addcheckpoint|clearcheckpoints|fall <y|off>|remove>', + description: 'Parkour courses with timer, checkpoints and records', + help: ['Players: /parkour top, /parkour cp (back to checkpoint), /parkour leave', 'Operators: mark the blocks players step on with setstart, addcheckpoint, setfinish'], + inGame: true, + async run (player, args, { usage }) { + const level = player.level + const sub = (args[0] || 'top').toLowerCase() + const isOp = player.permission >= server.ranks.permissionOf('Operator') + const needOp = () => { if (!isOp) throw new CommandError('Only operators can set up parkour courses.') } + const course = courseOf(level) + + switch (sub) { + case 'top': { + const list = Object.entries(records[level.name] || {}).sort((a, b) => a[1] - b[1]).slice(0, 10) + if (!course) return player.message('&eThis level has no parkour course.') + if (!list.length) return player.message('&eNo one has finished this course yet.') + player.message(`&6Best times in ${level.name}:`) + list.forEach(([name, ms], i) => player.message(`&f${i + 1}. ${(server.playerDB.get(name) || { name }).name} &7- ${fmt(ms)}`)) + return + } + case 'cp': + case 'checkpoint': { + const run = runs.get(player) + if (!run) throw new CommandError('You are not running a course.') + return toCheckpoint(player, run) + } + case 'leave': + runs.delete(player) + player.message('', 'status2') + return player.message('&eYou left the course.') + case 'setstart': + case 'setfinish': { + needOp() + const b = await mark(player, sub === 'setstart' ? 'Parkour start' : 'Parkour finish') + if (!b) return + level.meta.parkour = { checkpoints: [], fallY: null, ...(course || {}), [sub === 'setstart' ? 'start' : 'finish']: b } + level.dirty = true + return player.message(`&a${sub === 'setstart' ? 'Start' : 'Finish'} set at ${b.x} ${b.y} ${b.z}.`) + } + case 'addcheckpoint': { + needOp() + if (!course) throw new CommandError('Set the start first.') + const b = await mark(player, 'Parkour checkpoint') + if (!b) return + course.checkpoints.push(b) + level.dirty = true + return player.message(`&aCheckpoint ${course.checkpoints.length} added.`) + } + case 'clearcheckpoints': + needOp() + if (course) { course.checkpoints = []; level.dirty = true } + return player.message('&aCheckpoints removed.') + case 'fall': { + needOp() + if (!course) throw new CommandError('Set the start first.') + course.fallY = args[1] === 'off' ? null : Number(args[1]) + if (course.fallY !== null && !Number.isInteger(course.fallY)) return usage() + level.dirty = true + return player.message(course.fallY === null ? '&aFall detection off.' : `&aPlayers below y=${course.fallY} go back to their checkpoint.`) + } + case 'remove': + needOp() + delete level.meta.parkour + level.dirty = true + return player.message('&aParkour course removed from this level.') + default: + return usage() + } + } + }) +} diff --git a/plugins/minigames/round.js b/plugins/minigames/round.js new file mode 100644 index 0000000..4723d5d --- /dev/null +++ b/plugins/minigames/round.js @@ -0,0 +1,156 @@ +'use strict' + +// Shared logic for round based games: joining, countdown, teams, scoreboard (CPE MessageTypes), +// hacks control and restoring the arena when the round ends. + +class Round { + constructor (ctx, { id, title, level, minPlayers = 2, countdown = 10, onStart, onEnd, onLeave, onLateJoin }) { + this.ctx = ctx + this.server = ctx.server + this.id = id + this.title = title + this.level = level + this.minPlayers = minPlayers + this.countdownSeconds = countdown + this.players = new Set() + this.state = 'waiting' // waiting -> countdown -> running + this.hooks = { onStart, onEnd, onLeave, onLateJoin } + this.timers = [] + this.snapshot = null + } + + get running () { return this.state === 'running' } + + has (player) { return this.players.has(player) } + + broadcast (msg, type = 'chat') { + for (const p of this.players) p.message(msg, type) + } + + status (line, text) { + for (const p of this.players) p.message(text, line) + } + + join (player) { + if (this.players.has(player)) return player.message(`&eYou are already in ${this.title}.`) + if (player.level !== this.level && !player.changeLevel(this.level)) return + this.players.add(player) + this.broadcast(`&a+ ${player.coloredName}&e joined ${this.title} (${this.players.size})`) + if (this.running) { + player.setHacks({ flying: false, noClip: false, speeding: false, spawnControl: false, thirdPerson: true }) + if (this.hooks.onLateJoin) this.hooks.onLateJoin(player) + } else if (this.state === 'waiting' && this.players.size >= this.minPlayers) { + this.startCountdown() + } else if (this.state === 'waiting') { + player.message(`&eWaiting for ${this.minPlayers - this.players.size} more player(s)...`, 'status1') + } + } + + leave (player, quiet = false) { + if (!this.players.delete(player)) return + this._resetPlayer(player) + if (!quiet) this.broadcast(`&c- ${player.coloredName}&e left ${this.title}`) + if (this.hooks.onLeave) this.hooks.onLeave(player) + if (this.players.size < this.minPlayers && this.state !== 'waiting') { + this.end(null, 'Not enough players') + } + } + + startCountdown () { + this.state = 'countdown' + let left = this.countdownSeconds + const tick = () => { + if (this.state !== 'countdown') return + if (this.players.size < this.minPlayers) { this.state = 'waiting'; this.status('status1', ''); return } + if (left <= 0) return this.start() + this.status('status1', `&e${this.title} starts in &f${left}`) + if (left <= 3) this.broadcast(`&e${left}...`, 'announce') + left-- + this.later(tick, 1000) + } + tick() + } + + start () { + this.state = 'running' + this.snapshot = { blocks: Buffer.from(this.level.blocks), upper: this.level.upper ? Buffer.from(this.level.upper) : null } + this.broadcast(`&a${this.title} has started!`, 'announce') + for (const p of this.players) p.setHacks({ flying: false, noClip: false, speeding: false, spawnControl: false, thirdPerson: true }) + if (this.hooks.onStart) this.hooks.onStart() + } + + // winners: a list of players (or null); reward from the economy plugin if installed + end (winners, reason) { + if (!this.snapshot) { + // never started: just send everyone home + for (const p of this.players) this._resetPlayer(p) + this.players.clear() + this.clearTimers() + this.state = 'waiting' + return + } + const eco = this.ctx.getPlugin('economy') + const reward = this.ctx.config.reward || 0 + if (reason) this.broadcast(`&e${this.title} ended: &f${reason}`) + if (winners && winners.length) { + this.broadcast(`&aWinners: &f${winners.map(p => p.name).join(', ')}`, 'announce') + if (eco && reward) for (const p of winners) { eco.add(p.name, reward); p.message(`&a+${reward} ${eco.currency}!`) } + } + if (this.hooks.onEnd) this.hooks.onEnd(winners) + this.restoreArena() + for (const p of this.players) this._resetPlayer(p) + this.players.clear() + this.clearTimers() + this.state = 'waiting' + } + + restoreArena () { + if (!this.snapshot) return + const level = this.level + const { blocks, upper } = this.snapshot + const changes = [] + for (let i = 0; i < blocks.length; i++) { + const original = upper ? blocks[i] | (upper[i] << 8) : blocks[i] + if (level.getAt(i) !== original) { + const { x, y, z } = level.unpack(i) + changes.push([x, y, z, original]) + } + } + level.setBlocks(changes) + this.snapshot = null + } + + _resetPlayer (p) { + for (const line of ['status1', 'status2', 'status3', 'bottom1']) p.message('', line) + if (p.conn.closed) return + p.setHacks({}) + if (p.record && p.currentModel && p.currentModel !== (p.record.model || 'humanoid')) p.setModel(p.record.model || 'humanoid', false) + delete p.data.team + } + + later (fn, ms) { + const t = this.ctx.setTimeout(fn, ms) + this.timers.push(t) + return t + } + + repeat (fn, ms) { + const t = this.ctx.setInterval(fn, ms) + this.timers.push(t) + return t + } + + clearTimers () { + for (const t of this.timers) this.ctx.clearTimer(t) + this.timers = [] + } +} + +// Distance in blocks between two protocol positions (1/32 units) +function distance (a, b) { + return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) / 32 +} + +function randomItem (list) { return list[Math.floor(Math.random() * list.length)] } + +module.exports = { Round, distance, randomItem } diff --git a/plugins/minigames/teams.js b/plugins/minigames/teams.js new file mode 100644 index 0000000..b568acb --- /dev/null +++ b/plugins/minigames/teams.js @@ -0,0 +1,33 @@ +'use strict' + +const TEAMS = { + red: { name: 'Red', color: '&c', flagBlock: 21 }, + blue: { name: 'Blue', color: '&9', flagBlock: 29 } +} + +// Splits players into red/blue teams, balancing sizes +function assignTeams (players) { + const shuffled = [...players].sort(() => Math.random() - 0.5) + shuffled.forEach((p, i) => { p.data.team = i % 2 === 0 ? 'red' : 'blue' }) +} + +function smallerTeam (players) { + let red = 0; let blue = 0 + for (const p of players) { if (p.data.team === 'red') red++; else if (p.data.team === 'blue') blue++ } + return red <= blue ? 'red' : 'blue' +} + +function teamOf (p) { return TEAMS[p.data.team] || null } + +function spawnAt (player, spawn, level) { + const s = spawn || level.spawn + player.teleport(s.x, s.y, s.z, s.yaw || 0, s.pitch || 0) +} + +// marks the player's position as a team spawn +function positionOf (player) { + const p = player.feetPos + return { x: p.x, y: p.y, z: p.z, yaw: player.yaw, pitch: player.pitch } +} + +module.exports = { TEAMS, assignTeams, smallerTeam, teamOf, spawnAt, positionOf } diff --git a/plugins/minigames/tntwars.js b/plugins/minigames/tntwars.js new file mode 100644 index 0000000..b169b73 --- /dev/null +++ b/plugins/minigames/tntwars.js @@ -0,0 +1,148 @@ +'use strict' + +// TNT Wars: two teams. Right click a block (or place TNT) to drop TNT; it explodes after a few +// seconds and knocks out enemies nearby. First team to the score limit wins. The arena is restored. + +const { Round, distance } = require('./round') +const gameCommand = require('./game-command') +const { TEAMS, assignTeams, smallerTeam, teamOf, spawnAt, positionOf } = require('./teams') + +const TNT = 46 +const FUSE_MS = 2500 +const COOLDOWN_MS = 1500 + +module.exports = function tntwars (ctx) { + const { server } = ctx + const settings = () => ({ scoreLimit: 10, radius: 3, timeLimitMinutes: 10, ...ctx.config.tntwars }) + + const game = gameCommand(ctx, { + name: 'tntwars', + aliases: ['tw'], + title: 'TNT Wars', + metaKey: 'tntwars', + setupHelp: ['/tntwars setspawn <red|blue> - team spawn at your position'], + setup: { + setspawn (player, args, meta) { + const team = (args[0] || '').toLowerCase() + if (!TEAMS[team]) throw new ctx.CommandError('Team must be red or blue.') + meta.spawns = { ...(meta.spawns || {}), [team]: positionOf(player) } + player.level.dirty = true + player.message(`&aSpawn of the ${TEAMS[team].color}${TEAMS[team].name}&a team set.`) + } + }, + createRound (level) { + const meta = () => level.meta.tntwars || {} + let score = { red: 0, blue: 0 } + let endsAt = 0 + const lastDrop = new Map() + const round = new Round(ctx, { + id: 'tntwars', + title: 'TNT Wars', + level, + minPlayers: 2, + onStart () { + score = { red: 0, blue: 0 } + endsAt = Date.now() + settings().timeLimitMinutes * 60000 + assignTeams(round.players) + for (const p of round.players) setup(p) + round.repeat(() => { + const left = Math.max(0, endsAt - Date.now()) + round.status('status1', `${TEAMS.red.color}Red ${score.red} &f- ${TEAMS.blue.color}${score.blue} Blue`) + round.status('status2', `&eTime left: &f${Math.ceil(left / 1000)}s`) + if (!left) finish() + }, 1000) + }, + onLateJoin (p) { p.data.team = smallerTeam(round.players); setup(p) } + }) + + function setup (p) { + const t = teamOf(p) + p.message(`&eYou are in the ${t.color}${t.name}&e team! Right click blocks to drop TNT.`) + spawnAt(p, (meta().spawns || {})[p.data.team], level) + p.holdBlock(TNT, false) + server.tabList.update(p) + } + + function finish () { + const winner = score.red === score.blue ? null : score.red > score.blue ? 'red' : 'blue' + round.end(winner ? [...round.players].filter(p => p.data.team === winner) : [], winner ? `${TEAMS[winner].name} team wins ${score.red}-${score.blue}` : `Draw ${score.red}-${score.blue}`) + } + + round.drop = (player, x, y, z) => { + if (!round.running || !round.has(player)) return false + const now = Date.now() + if ((lastDrop.get(player) || 0) > now - COOLDOWN_MS) return true + if (!level.inBounds(x, y, z) || level.getBlock(x, y, z) !== 0) return true + lastDrop.set(player, now) + level.setBlock(x, y, z, TNT) + round.later(() => explode(player, x, y, z), FUSE_MS) + return true + } + + function explode (thrower, x, y, z) { + if (!round.running) return + if (level.getBlock(x, y, z) === TNT) level.setBlock(x, y, z, 0) + const radius = settings().radius + const physics = ctx.getPlugin('physics') + if (physics && physics.explode) physics.explode(level, x, y, z, radius) + else { + const changes = [] + for (let dx = -radius; dx <= radius; dx++) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dz = -radius; dz <= radius; dz++) { + if (dx * dx + dy * dy + dz * dz > radius * radius) continue + const b = level.getBlock(x + dx, y + dy, z + dz) + if (b !== 7 && b !== 49) changes.push([x + dx, y + dy, z + dz, 0]) + } + } + } + level.setBlocks(changes) + } + const center = { x: (x + 0.5) * 32, y: (y + 0.5) * 32 + 51, z: (z + 0.5) * 32 } + for (const victim of round.players) { + if (victim.data.team === thrower.data.team && victim !== thrower) continue + if (distance(victim.pos, center) > radius + 1) continue + spawnAt(victim, (meta().spawns || {})[victim.data.team], level) + if (victim === thrower) { victim.message('&cYou blew yourself up!'); continue } + score[thrower.data.team]++ + round.broadcast(`${teamOf(thrower).color}${thrower.name}&e blew up ${teamOf(victim).color}${victim.name}`) + } + if (score.red >= settings().scoreLimit || score.blue >= settings().scoreLimit) finish() + } + + return round + } + }) + + const roundOfPlayer = p => game.roundOf(p) + + // right click with PlayerClick drops TNT on the clicked face + ctx.on('playerClick', (ev) => { + if (ev.button !== 'right' || ev.action !== 'press' || !ev.target) return + const r = roundOfPlayer(ev.player) + if (!r || r.id !== 'tntwars') return + const face = [[-1, 0, 0], [1, 0, 0], [0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1]][ev.target.face] || [0, 1, 0] + r.drop(ev.player, ev.target.x + face[0], ev.target.y + face[1], ev.target.z + face[2]) + }) + + // players in a running game may "place" TNT whatever their rank; the block itself is never placed + ctx.on('blockPermission', (ev) => { + const r = roundOfPlayer(ev.player) + if (r && r.id === 'tntwars' && r.running) { ev.allowed = true; ev.message = null } + }) + + // placing TNT (or anything while holding TNT, for clients without PlayerClick) also works + ctx.on('blockChange', (ev) => { + const r = roundOfPlayer(ev.player) + if (!r || r.id !== 'tntwars' || !r.running) return + ev.cancel() + if (ev.placing && (ev.block === TNT || !ev.player.supports('PlayerClick'))) r.drop(ev.player, ev.x, ev.y, ev.z) + }, { priority: 'high' }) + + ctx.on('tabListEntry', (ev) => { + const t = teamOf(ev.player) + if (t && roundOfPlayer(ev.player)) { ev.groupName = `${t.color}Team ${t.name}`; ev.listName = t.color + ev.player.name } + }) + + return game +} diff --git a/plugins/minigames/zombie.js b/plugins/minigames/zombie.js new file mode 100644 index 0000000..a29fae2 --- /dev/null +++ b/plugins/minigames/zombie.js @@ -0,0 +1,91 @@ +'use strict' + +// Zombie Survival: one player starts as a zombie; zombies infect humans by touching them. +// Humans win if anyone survives until the time runs out. + +const { Round, distance, randomItem } = require('./round') +const gameCommand = require('./game-command') + +module.exports = function zombie (ctx) { + const { server } = ctx + const settings = () => ({ minutes: 5, touchDistance: 1.2, ...ctx.config.zombie }) + + const game = gameCommand(ctx, { + name: 'zombie', + aliases: ['zs', 'zombiesurvival', 'infection'], + title: 'Zombie Survival', + metaKey: 'zombie', + createRound (level) { + let endsAt = 0 + let firstZombies = [] + const round = new Round(ctx, { + id: 'zombie', + title: 'Zombie Survival', + level, + minPlayers: 2, + countdown: 15, + onStart () { + endsAt = Date.now() + settings().minutes * 60000 + for (const p of round.players) p.data.team = 'human' + const count = Math.max(1, Math.floor(round.players.size / 6)) + firstZombies = [] + for (let i = 0; i < count; i++) { + const pick = randomItem([...round.players].filter(p => p.data.team === 'human')) + infect(pick, null) + firstZombies.push(pick) + } + const s = level.spawn + for (const p of round.players) p.teleport(s.x, s.y, s.z) + round.repeat(tick, 150) + round.repeat(() => { + const humans = [...round.players].filter(p => p.data.team === 'human').length + round.status('status1', `&aHumans: &f${humans} &c Zombies: &f${round.players.size - humans}`) + round.status('status2', `&eSurvive: &f${Math.ceil(Math.max(0, endsAt - Date.now()) / 1000)}s`) + if (Date.now() > endsAt) { + const alive = [...round.players].filter(p => p.data.team === 'human') + round.end(alive, `${alive.length} human(s) survived!`) + } + }, 1000) + }, + onLateJoin (p) { infect(p, null) }, + onLeave () { checkEnd() } + }) + + function infect (p, by) { + p.data.team = 'zombie' + p.setModel('zombie', false) + if (by) round.broadcast(`&c${by.name} infected ${p.name}!`) + else p.message('&cYou are a zombie! Touch the humans to infect them.', 'announce') + server.tabList.update(p) + } + + function checkEnd () { + if (!round.running) return + const humans = [...round.players].filter(p => p.data.team === 'human') + if (!humans.length) round.end([...round.players].filter(p => p.data.team === 'zombie'), 'The zombies infected everyone!') + } + + function tick () { + if (!round.running) return + const zombies = [...round.players].filter(p => p.data.team === 'zombie') + for (const human of [...round.players].filter(p => p.data.team === 'human')) { + const z = zombies.find(z => distance(z.pos, human.pos) < settings().touchDistance) + if (z) infect(human, z) + } + checkEnd() + } + + round.firstZombies = () => firstZombies + return round + } + }) + + ctx.on('tabListEntry', (ev) => { + const team = ev.player.data.team + if (!game.roundOf(ev.player)) return + if (team === 'zombie') { ev.groupName = '&cZombies'; ev.listName = '&c' + ev.player.name } + if (team === 'human') { ev.groupName = '&aHumans'; ev.listName = '&a' + ev.player.name } + }) + + return game +} diff --git a/plugins/physics/index.js b/plugins/physics/index.js new file mode 100644 index 0000000..1b12616 --- /dev/null +++ b/plugins/physics/index.js @@ -0,0 +1,272 @@ +'use strict' + +// Block physics, like classic servers: +// level 1: sand and gravel fall, water and lava flow, sponges soak up water, water + lava = stone +// level 2: also grass dies in the dark and spreads to lit dirt, TNT next to fire or lava explodes +// Each level has its own setting (/physics), saved inside the level file. + +const AIR = 0 +const STONE = 1 +const GRASS = 2 +const DIRT = 3 +const WATER = 8 +const STILL_WATER = 9 +const LAVA = 10 +const STILL_LAVA = 11 +const SAND = 12 +const GRAVEL = 13 +const SPONGE = 19 +const TNT = 46 +const FIRE = 54 + +const isWater = b => b === WATER || b === STILL_WATER +const isLava = b => b === LAVA || b === STILL_LAVA +const isLiquid = b => isWater(b) || isLava(b) + +// Delays in ticks (1 tick = 100 ms) +const DELAY = { [WATER]: 2, [LAVA]: 8, [SAND]: 1, [GRAVEL]: 1, [SPONGE]: 1, [TNT]: 10, [GRASS]: 60, [DIRT]: 60 } + +module.exports = { + name: 'physics', + version: '1.0.0', + description: 'Falling sand, flowing water and lava, sponges, grass and TNT', + author: 'MCScript', + + defaultConfig: { + defaultLevel: 1, + maxUpdatesPerTick: 20000, + tntRadius: 3 + }, + + load (ctx) { + const { server, config, CommandError } = ctx + const states = new Map() // level name -> { queue: Map(index -> due tick) } + const primed = new Set() // TNT set off by an explosion + let tick = 0 + + const modeOf = level => level.meta.physics ?? config.defaultLevel + const stateOf = level => { + let st = states.get(level.name) + if (!st) states.set(level.name, (st = { queue: new Map() })) + return st + } + + const schedule = (level, index, delay = 1) => { + if (!modeOf(level)) return + const q = stateOf(level).queue + const due = tick + delay + const cur = q.get(index) + if (cur === undefined || cur > due) q.set(index, due) + } + + const scheduleAround = (level, x, y, z) => { + const dirs = [[0, 0, 0], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]] + for (const [dx, dy, dz] of dirs) { + const nx = x + dx; const ny = y + dy; const nz = z + dz + if (!level.inBounds(nx, ny, nz)) continue + const b = level.getBlock(nx, ny, nz) + if (DELAY[b] !== undefined) schedule(level, level.index(nx, ny, nz), DELAY[b]) + } + } + + // Finds blocks that need physics when a level loads or physics is turned on + const scan = (level) => { + if (!modeOf(level)) return + const st = stateOf(level) + for (let i = 0; i < level.volume; i++) { + const b = level.blocks[i] + if (b === WATER || b === LAVA || b === SAND || b === GRAVEL) { + const { x, y, z } = level.unpack(i) + if (b === SAND || b === GRAVEL) { if (y > 0 && fallsInto(level.getBlock(x, y - 1, z))) st.queue.set(i, tick + 1) } else st.queue.set(i, tick + DELAY[b]) + } + } + } + + const fallsInto = b => b === AIR || isLiquid(b) + + const nearSponge = (level, x, y, z) => { + for (let dy = -2; dy <= 2; dy++) { + for (let dz = -2; dz <= 2; dz++) { + for (let dx = -2; dx <= 2; dx++) if (level.getBlock(x + dx, y + dy, z + dz) === SPONGE) return true + } + } + return false + } + + // Explodes at a position; returns the changes made (also used by the TNT Wars minigame) + const explode = (level, x, y, z, radius = config.tntRadius) => { + const changes = [] + for (let dy = -radius; dy <= radius; dy++) { + for (let dz = -radius; dz <= radius; dz++) { + for (let dx = -radius; dx <= radius; dx++) { + const d = Math.sqrt(dx * dx + dy * dy + dz * dz) + if (d > radius + 0.5 || (d > radius - 1 && Math.random() < 0.4)) continue + const nx = x + dx; const ny = y + dy; const nz = z + dz + if (!level.inBounds(nx, ny, nz)) continue + const b = level.getBlock(nx, ny, nz) + if (b === 7 || b === 49 || b === AIR) continue // bedrock and obsidian resist + if (b === TNT && (dx || dy || dz)) { + const i = level.index(nx, ny, nz) + primed.add(level.name + ':' + i) + schedule(level, i, 2 + Math.floor(Math.random() * 4)) + continue + } + changes.push([nx, ny, nz, AIR]) + } + } + } + level.setBlocks(changes) + for (const [cx, cy, cz] of changes) scheduleAround(level, cx, cy, cz) + server.events.fire('explosion', { level, x, y, z, radius }) + return changes + } + + const process = (level, index, changes, pending) => { + const b = pending.has(index) ? pending.get(index) : level.getAt(index) + const { x, y, z } = level.unpack(index) + const mode = modeOf(level) + const get = (nx, ny, nz) => { + if (!level.inBounds(nx, ny, nz)) return STONE + const i = level.index(nx, ny, nz) + return pending.has(i) ? pending.get(i) : level.getAt(i) + } + const set = (nx, ny, nz, block) => { + const i = level.index(nx, ny, nz) + pending.set(i, block) + changes.push([nx, ny, nz, block]) + } + + switch (b) { + case SAND: + case GRAVEL: { + let ny = y + while (ny > 0 && fallsInto(get(x, ny - 1, z))) ny-- + if (ny !== y) { + set(x, y, z, AIR) + set(x, ny, z, b) + scheduleAround(level, x, y, z) + } + break + } + case WATER: + case LAVA: { + const other = b === WATER ? isLava : isWater + for (const [dx, dy, dz] of [[0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]]) { + const nx = x + dx; const ny = y + dy; const nz = z + dz + if (!level.inBounds(nx, ny, nz)) continue + const t = get(nx, ny, nz) + if (t === AIR) { + if (b === WATER && nearSponge(level, nx, ny, nz)) continue + set(nx, ny, nz, b) + schedule(level, level.index(nx, ny, nz), DELAY[b]) + } else if (other(t)) { + set(nx, ny, nz, STONE) + } else if (b === LAVA && t === TNT && mode >= 2) { + schedule(level, level.index(nx, ny, nz), DELAY[TNT]) + } + } + break + } + case SPONGE: { + for (let dy = -2; dy <= 2; dy++) { + for (let dz = -2; dz <= 2; dz++) { + for (let dx = -2; dx <= 2; dx++) { + if (isWater(get(x + dx, y + dy, z + dz))) set(x + dx, y + dy, z + dz, AIR) + } + } + } + break + } + case TNT: { + if (mode < 2) break + const lit = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]].some(([dx, dy, dz]) => { + const n = get(x + dx, y + dy, z + dz) + return n === FIRE || isLava(n) + }) + // TNT hit by another explosion goes off too (chain reaction) + if (primed.delete(level.name + ':' + index) || lit) explode(level, x, y, z) + break + } + case GRASS: + if (mode >= 2 && !transparent(get(x, y + 1, z))) set(x, y, z, DIRT) + break + case DIRT: + if (mode >= 2 && get(x, y + 1, z) === AIR && Math.random() < 0.5) set(x, y, z, GRASS) + break + } + } + + const transparent = b => [AIR, 6, 18, 20, 37, 38, 39, 40, 44, 50, 51, 53, 54].includes(b) + + ctx.setInterval(() => { + tick++ + for (const [name, st] of states) { + const level = server.levels.get(name) + if (!level) { states.delete(name); continue } + if (!modeOf(level) || !st.queue.size) continue + const due = [] + for (const [index, at] of st.queue) { + if (at <= tick) due.push(index) + if (due.length >= config.maxUpdatesPerTick) break + } + if (!due.length) continue + for (const i of due) st.queue.delete(i) + const changes = [] + const pending = new Map() + for (const i of due) process(level, i, changes, pending) + if (changes.length) { + // keep only the last change for each position + const last = new Map() + for (const c of changes) last.set(level.index(c[0], c[1], c[2]), c) + level.setBlocks([...last.values()]) + } + } + }, 100) + + ctx.on('blockChange', (ev) => { + if (ev.cancelled) return + // run after the block is actually set + setImmediate(() => scheduleAround(ev.level, ev.x, ev.y, ev.z)) + }, { priority: 'monitor' }) + + ctx.on('drawOperation', (ev) => { + if (ev.cancelled) return + const level = ev.level + setImmediate(() => { for (const [x, y, z] of ev.changes.slice(0, 50000)) scheduleAround(level, x, y, z) }) + }, { priority: 'monitor' }) + + ctx.on('levelLoad', ({ level }) => scan(level)) + ctx.on('levelUnload', ({ level }) => states.delete(level.name), { priority: 'monitor' }) + for (const level of server.levels.loaded.values()) scan(level) + + ctx.command({ + name: 'physics', + aliases: ['phys'], + category: 'world', + rank: 'Operator', + usage: '/physics [0|1|2] [level]', + description: 'Shows or changes block physics: 0 = off, 1 = normal, 2 = advanced (grass, TNT)', + run (player, args) { + const level = args[1] ? server.levels.get(args[1]) : player.level + if (!level) throw new CommandError('That level is not loaded.') + if (args[0] === undefined) { + const st = states.get(level.name) + return player.message(`&ePhysics in ${level.name}: &f${modeOf(level)}&e (${st ? st.queue.size : 0} pending updates)`) + } + const mode = Number(args[0]) + if (![0, 1, 2].includes(mode)) throw new CommandError('Physics level must be 0, 1 or 2.') + level.meta.physics = mode + level.dirty = true + if (mode === 0) states.delete(level.name) + else scan(level) + server.broadcast(`&ePhysics in ${level.name} set to &f${mode}&e.`, p => p.level === level) + } + }) + + module.exports.api = { + explode: (level, x, y, z, radius) => explode(level, x, y, z, radius), + schedule: (level, x, y, z) => scheduleAround(level, x, y, z), + modeOf + } + } +} diff --git a/plugins/portals/index.js b/plugins/portals/index.js new file mode 100644 index 0000000..b3473c1 --- /dev/null +++ b/plugins/portals/index.js @@ -0,0 +1,164 @@ +'use strict' + +// Portals (walk into a block to be teleported, even to another level) and +// message blocks (walk into or click a block to see a message or run a command). +// Both are stored inside the level file (level.meta). + +module.exports = { + name: 'portals', + version: '1.0.0', + description: 'Portals and message blocks', + author: 'MCScript', + + load (ctx) { + const { server, CommandError, text } = ctx + + const portalsOf = level => (level.meta.portals = level.meta.portals || {}) + const mbsOf = level => (level.meta.messageBlocks = level.meta.messageBlocks || {}) + + const markOne = async (player, label) => { + try { + const [m] = await player.selectBlocks(1, label) + return m + } catch (err) { + return null + } + } + + const usePortal = (player, portal) => { + let level = server.levels.get(portal.level) + if (!level) { + if (!server.levels.exists(portal.level)) return player.message('&cThis portal leads to a level that no longer exists.') + level = server.levels.load(portal.level) + } + if (level !== player.level && !player.changeLevel(level)) return + player.teleport(portal.x, portal.y, portal.z, portal.yaw, portal.pitch) + } + + const useMessageBlock = (player, msg) => { + if (msg.startsWith('/')) server.commands.execute(player, msg) + else player.message(msg) + } + + // returns true if something was triggered + const trigger = (player, level, index) => { + const portal = portalsOf(level)[index] + if (portal) { usePortal(player, portal); return true } + const mb = mbsOf(level)[index] + if (mb) { useMessageBlock(player, mb); return true } + return false + } + + ctx.on('playerMove', (ev) => { + const { player, to } = ev + const level = player.level + const x = Math.floor(to.x / 32); const z = Math.floor(to.z / 32) + const feet = Math.floor((to.y - 51) / 32) + if (!level.inBounds(x, feet, z)) return + const feetIndex = level.index(x, feet, z) + if (player.data['portals.last'] === feetIndex) return + player.data['portals.last'] = feetIndex + if (trigger(player, level, feetIndex)) return + if (level.inBounds(x, feet + 1, z)) trigger(player, level, level.index(x, feet + 1, z)) + }) + + ctx.on('playerClick', (ev) => { + if (ev.action !== 'press' || !ev.target) return + const level = ev.player.level + const { x, y, z } = ev.target + if (!level.inBounds(x, y, z)) return + const mb = mbsOf(level)[level.index(x, y, z)] + if (mb) useMessageBlock(ev.player, mb) + }) + + ctx.command({ + name: 'portal', + aliases: ['portals'], + category: 'world', + rank: 'AdvBuilder', + usage: '/portal [exit|remove|list]', + description: 'Creates a portal: mark the entrance, then go to the exit and type /portal exit', + inGame: true, + async run (player, args) { + const sub = (args[0] || '').toLowerCase() + const level = player.level + if (sub === 'exit') { + const pending = player.data['portals.pending'] + if (!pending) throw new CommandError('Mark an entrance first with /portal') + const from = server.levels.get(pending.level) + if (!from) throw new CommandError('The level of the entrance is not loaded anymore.') + portalsOf(from)[pending.index] = { level: level.name, ...player.feetPos, yaw: player.yaw, pitch: player.pitch } + from.dirty = true + player.data['portals.pending'] = null + return player.message(`&aPortal created! It leads here (${level.name}).`) + } + if (sub === 'remove' || sub === 'delete') { + const m = await markOne(player, 'Remove portal') + if (!m) return + const i = level.index(m.x, m.y, m.z) + if (!portalsOf(level)[i]) throw new CommandError('That block is not a portal.') + delete portalsOf(level)[i] + level.dirty = true + return player.message('&aPortal removed.') + } + if (sub === 'list') { + const list = Object.entries(portalsOf(level)) + if (!list.length) return player.message('&eNo portals in this level.') + for (const [i, p] of list) { + const at = level.unpack(Number(i)) + player.message(`&f(${at.x}, ${at.y}, ${at.z}) &7-> ${p.level} (${Math.floor(p.x)}, ${Math.floor(p.y)}, ${Math.floor(p.z)})`) + } + return + } + const m = await markOne(player, 'Portal entrance') + if (!m) return + player.data['portals.pending'] = { level: level.name, index: level.index(m.x, m.y, m.z) } + player.message('&eEntrance marked. Now go to where the portal should lead (any level) and type &f/portal exit') + } + }) + + ctx.command({ + name: 'mb', + aliases: ['messageblock'], + category: 'world', + rank: 'AdvBuilder', + usage: '/mb <message or /command> | /mb remove | /mb list', + description: 'Makes a block show a message (or run a command) when touched or clicked', + inGame: true, + async run (player, args, { usage, raw }) { + const level = player.level + const sub = (args[0] || '').toLowerCase() + if (!sub) return usage() + if (sub === 'remove' || sub === 'delete') { + const m = await markOne(player, 'Remove message block') + if (!m) return + const i = level.index(m.x, m.y, m.z) + if (!mbsOf(level)[i]) throw new CommandError('That block has no message.') + delete mbsOf(level)[i] + level.dirty = true + return player.message('&aMessage block removed.') + } + if (sub === 'list') { + const list = Object.entries(mbsOf(level)) + if (!list.length) return player.message('&eNo message blocks in this level.') + for (const [i, msg] of list.slice(0, 20)) { + const at = level.unpack(Number(i)) + player.message(`&f(${at.x}, ${at.y}, ${at.z})&7: ${msg}`) + } + return + } + let msg = text.convertPercentCodes(raw) + if (msg.startsWith('/') && player.permission < server.ranks.permissionOf('Operator')) { + throw new CommandError('Only operators can create command blocks.') + } + if (msg.length > 250) throw new CommandError('Message too long (max 250 characters).') + if (!msg.startsWith('/')) msg = text.sanitize(msg) + const m = await markOne(player, 'Message block') + if (!m) return + mbsOf(level)[level.index(m.x, m.y, m.z)] = msg + level.dirty = true + player.message('&aMessage block created.') + } + }) + } +} diff --git a/plugins/relay-discord/index.js b/plugins/relay-discord/index.js new file mode 100644 index 0000000..8694117 --- /dev/null +++ b/plugins/relay-discord/index.js @@ -0,0 +1,162 @@ +'use strict' + +/* global WebSocket */ + +// Two-way chat bridge with a Discord channel. +// - Outgoing only: set "webhookUrl" (Channel settings > Integrations > Webhooks). +// - Two-way: set "botToken" and "channelId" (needs the "Message Content" intent enabled for the bot). +// Disabled by default: set "enabled": true in config/plugins/relay-discord.json and /preload relay-discord. + +const API = 'https://discord.com/api/v10' +const GATEWAY = 'wss://gateway.discord.gg/?v=10&encoding=json' +const INTENTS = (1 << 0) | (1 << 9) | (1 << 15) // GUILDS, GUILD_MESSAGES, MESSAGE_CONTENT + +module.exports = { + name: 'relay-discord', + version: '1.0.0', + description: 'Relays chat to and from Discord', + author: 'MCScript', + + defaultConfig: { + enabled: false, + webhookUrl: '', + botToken: '', + channelId: '', + discordPrefix: '&9[Discord] ', + relayJoins: true, + avatarUrl: 'https://cdn.classicube.net/face/{name}.png' + }, + + load (ctx) { + const { server, config, log, text } = ctx + if (!config.enabled) { + log.info('Disabled. Edit config/plugins/relay-discord.json to enable it.') + return + } + if (!config.webhookUrl && !(config.botToken && config.channelId)) { + log.warn('Set webhookUrl, or botToken + channelId, in config/plugins/relay-discord.json') + return + } + + const clean = msg => text.stripColors(msg).replace(/([*_~`|>\\])/g, '\\$1').slice(0, 1900) + const queue = [] + let sending = false + + // Messages are sent one at a time to respect Discord's rate limits + const flush = async () => { + if (sending) return + sending = true + while (queue.length) { + const { name, content } = queue.shift() + try { + let res + if (config.webhookUrl) { + res = await fetch(config.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: name || server.config.name.slice(0, 32), + avatar_url: name ? config.avatarUrl.replace('{name}', encodeURIComponent(name)) : undefined, + content, + allowed_mentions: { parse: [] } + }) + }) + } else { + res = await fetch(`${API}/channels/${config.channelId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bot ${config.botToken}` }, + body: JSON.stringify({ content: name ? `**${name}**: ${content}` : content, allowed_mentions: { parse: [] } }) + }) + } + if (res.status === 429) { + const retry = Number(res.headers.get('retry-after') || 1) + queue.unshift({ name, content }) + await new Promise(resolve => setTimeout(resolve, retry * 1000)) + } else if (!res.ok) { + log.warn(`Discord responded ${res.status}: ${(await res.text()).slice(0, 200)}`) + } + } catch (err) { + log.warn(`Could not send to Discord: ${err.message}`) + } + } + sending = false + } + + const send = (name, content) => { + if (queue.length > 50) queue.shift() + queue.push({ name, content }) + flush() + } + + ctx.on('playerChat', (ev) => { if (!ev.cancelled) send(ev.player.name, clean(ev.message)) }, { priority: 'monitor' }) + if (config.relayJoins) { + ctx.on('playerJoin', ({ player }) => send(null, `**${clean(player.name)}** joined the game`), { priority: 'monitor' }) + ctx.on('playerLeave', ({ player }) => { if (player.loggedIn) send(null, `**${clean(player.name)}** left the game`) }, { priority: 'monitor' }) + } + ctx.on('serverStart', () => send(null, 'Server started'), { priority: 'monitor' }) + + // incoming (gateway) + + if (!config.botToken || !config.channelId) return + if (typeof WebSocket === 'undefined') { + log.warn('Receiving messages from Discord needs Node 22 or newer (global WebSocket). Only sending is enabled.') + return + } + + let ws = null + let heartbeat = null + let seq = null + let stopping = false + + const connect = () => { + ws = new WebSocket(GATEWAY) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.s !== null && msg.s !== undefined) seq = msg.s + switch (msg.op) { + case 10: // hello + clearInterval(heartbeat) + heartbeat = setInterval(() => ws.send(JSON.stringify({ op: 1, d: seq })), msg.d.heartbeat_interval) + ws.send(JSON.stringify({ + op: 2, + d: { token: config.botToken, intents: INTENTS, properties: { os: process.platform, browser: 'mcscript', device: 'mcscript' } } + })) + break + case 7: // reconnect + case 9: // invalid session + ws.close() + break + case 0: + if (msg.t === 'READY') log.info(`Connected to Discord as ${msg.d.user.username}`) + if (msg.t === 'MESSAGE_CREATE') onMessage(msg.d) + break + } + } + ws.onclose = () => { + clearInterval(heartbeat) + if (!stopping) setTimeout(() => { if (!stopping) connect() }, 10000) + } + ws.onerror = (err) => log.warn(`Discord gateway error: ${err.message || err.type}`) + } + + const onMessage = (d) => { + if (d.channel_id !== config.channelId || d.author.bot || d.webhook_id) return + const name = (d.member && d.member.nick) || d.author.global_name || d.author.username + let content = d.content || '' + if (d.attachments && d.attachments.length) content += ' [attachment]' + content = text.sanitize(content.replace(/\n/g, ' ').replace(/<a?(:\w+:)\d+>/g, '$1')) + if (content.trim() === '!players') { + const names = server.online.filter(p => !p.hidden).map(p => p.name) + return send(null, `Online (${names.length}/${server.config.maxPlayers}): ${names.join(', ') || 'nobody'}`) + } + if (content.trim()) server.broadcast(`${config.discordPrefix}&f${text.stripColors(name)}: ${content}`) + } + + connect() + ctx.onUnload(() => { + stopping = true + clearInterval(heartbeat) + if (ws) ws.close() + }) + } +} diff --git a/plugins/relay-irc/index.js b/plugins/relay-irc/index.js new file mode 100644 index 0000000..ab2d46d --- /dev/null +++ b/plugins/relay-irc/index.js @@ -0,0 +1,128 @@ +'use strict' + +// Two-way chat bridge between the server and an IRC channel. +// Disabled by default: set "enabled": true in config/plugins/relay-irc.json and /preload relay-irc. + +const net = require('net') +const tls = require('tls') + +module.exports = { + name: 'relay-irc', + version: '1.0.0', + description: 'Relays chat to and from an IRC channel', + author: 'MCScript', + + defaultConfig: { + enabled: false, + host: 'irc.libera.chat', + port: 6697, + tls: true, + nick: 'MCScriptBot', + password: '', + channel: '#my-classicube-server', + ircPrefix: '&5[IRC] ', + relayJoins: true + }, + + load (ctx) { + const { server, config, log, text } = ctx + if (!config.enabled) { + log.info('Disabled. Edit config/plugins/relay-irc.json to enable it.') + return + } + + let socket = null + let buffer = '' + let connected = false + let stopping = false + let reconnectTimer = null + + const send = (line) => { if (socket && !socket.destroyed) socket.write(line + '\r\n') } + const say = (msg) => { if (connected) send(`PRIVMSG ${config.channel} :${text.stripColors(msg).slice(0, 400)}`) } + + const connect = () => { + buffer = '' + const opts = { host: config.host, port: config.port } + socket = config.tls ? tls.connect({ ...opts, servername: config.host }) : net.connect(opts) + socket.setEncoding('utf8') + socket.on('connect', () => { + if (config.tls) return + register() + }) + socket.on('secureConnect', register) + socket.on('data', onData) + socket.on('error', err => log.warn(`IRC error: ${err.message}`)) + socket.on('close', () => { + connected = false + if (!stopping) { + log.warn('IRC connection closed, reconnecting in 30s') + reconnectTimer = ctx.setTimeout(connect, 30000) + } + }) + } + + const register = () => { + if (config.password) send(`PASS ${config.password}`) + send(`NICK ${config.nick}`) + send(`USER ${config.nick} 0 * :MCScript relay`) + } + + const onData = (chunk) => { + buffer += chunk + const lines = buffer.split('\r\n') + buffer = lines.pop() + for (const line of lines) handle(line) + } + + const handle = (line) => { + const m = line.match(/^(?::(\S+) )?(\S+)(?: (.*))?$/) + if (!m) return + const [, prefix, command, rest = ''] = m + const nick = prefix ? prefix.split('!')[0] : '' + const trailing = rest.includes(' :') ? rest.slice(rest.indexOf(' :') + 2) : rest.startsWith(':') ? rest.slice(1) : '' + switch (command) { + case 'PING': send(`PONG ${rest}`); break + case '001': + connected = true + send(`JOIN ${config.channel}`) + log.info(`Connected to ${config.host}, joining ${config.channel}`) + break + case '433': // nick in use + config.nick += '_' + send(`NICK ${config.nick}`) + break + case 'PRIVMSG': { + const target = rest.split(' ')[0] + if (target.toLowerCase() !== config.channel.toLowerCase()) return + // eslint-disable-next-line no-control-regex + const action = trailing.match(/^\x01ACTION (.*)\x01$/) + // eslint-disable-next-line no-control-regex + const msg = text.sanitize((action ? action[1] : trailing).replace(/[\x00-\x1f]/g, '')) + if (msg.trim() === '.who' || msg.trim() === '!players') { + const names = server.online.filter(p => !p.hidden).map(p => p.name) + say(`Online (${names.length}): ${names.join(', ') || 'nobody'}`) + return + } + server.broadcast(action ? `${config.ircPrefix}&f* ${nick} ${msg}` : `${config.ircPrefix}&f${nick}: ${msg}`) + break + } + } + } + + ctx.on('playerChat', (ev) => { if (!ev.cancelled) say(`<${ev.player.name}> ${ev.message}`) }, { priority: 'monitor' }) + if (config.relayJoins) { + ctx.on('playerJoin', ({ player }) => say(`${player.name} joined the game`), { priority: 'monitor' }) + ctx.on('playerLeave', ({ player }) => { if (player.loggedIn) say(`${player.name} left the game`) }, { priority: 'monitor' }) + } + + connect() + ctx.onUnload(() => { + stopping = true + if (reconnectTimer) ctx.clearTimer(reconnectTimer) + send('QUIT :Server stopping') + if (socket) socket.end() + }) + + module.exports.api = { say } + } +} diff --git a/plugins/test.js b/plugins/test.js deleted file mode 100644 index 9b4c830..0000000 --- a/plugins/test.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports.server = (server, settings) => { - -} diff --git a/plugins/warps/index.js b/plugins/warps/index.js new file mode 100644 index 0000000..45a4ca5 --- /dev/null +++ b/plugins/warps/index.js @@ -0,0 +1,114 @@ +'use strict' + +// Named teleport points shared by everyone (/warp) and personal homes (/home). + +module.exports = { + name: 'warps', + version: '1.0.0', + description: 'Warps and homes', + author: 'MCScript', + + defaultConfig: { + maxHomes: { Guest: 1, Builder: 3, AdvBuilder: 5, Operator: 20 } + }, + + load (ctx) { + const { server, config, CommandError } = ctx + const data = ctx.loadData('warps.json', { warps: {}, homes: {} }) + const save = () => ctx.saveData('warps.json', data) + + const here = (player) => ({ level: player.level.name, ...player.feetPos, yaw: player.yaw, pitch: player.pitch }) + + const go = (player, point, label) => { + let level = server.levels.get(point.level) + if (!level) { + if (!server.levels.exists(point.level)) throw new CommandError(`The level of ${label} no longer exists.`) + level = server.levels.load(point.level) + } + if (player.level !== level && !player.changeLevel(level)) return + player.teleport(point.x, point.y, point.z, point.yaw, point.pitch) + player.message(`&eTeleported to &f${label}&e.`) + } + + const maxHomes = (player) => { + let max = 0 + for (const [rank, n] of Object.entries(config.maxHomes)) { + if (player.permission >= server.ranks.permissionOf(rank)) max = Math.max(max, n) + } + return max + } + + ctx.command({ + name: 'warp', + aliases: ['warps'], + category: 'essentials', + usage: '/warp <name> | /warp list | /warp create <name> | /warp delete <name>', + description: 'Teleports to a warp point', + run (player, args, { usage }) { + const sub = (args[0] || 'list').toLowerCase() + const name = (args[1] || '').toLowerCase() + const isOp = player.permission >= server.ranks.permissionOf('Operator') + + if (sub === 'list') { + const names = Object.keys(data.warps).sort() + return player.message(names.length ? `&eWarps: &f${names.join(', ')}` : '&eThere are no warps yet.') + } + if (sub === 'create' || sub === 'add' || sub === 'set') { + if (!isOp) throw new CommandError('Only operators can create warps.') + if (player.isConsole) throw new CommandError('Use this in-game.') + if (!/^[a-z0-9_-]{1,24}$/.test(name)) return usage() + data.warps[name] = { ...here(player), by: player.name } + save() + return player.message(`&aWarp &f${name}&a created.`) + } + if (sub === 'delete' || sub === 'remove' || sub === 'del') { + if (!isOp) throw new CommandError('Only operators can delete warps.') + if (!data.warps[name]) throw new CommandError('That warp does not exist.') + delete data.warps[name] + save() + return player.message(`&aWarp ${name} deleted.`) + } + if (player.isConsole) throw new CommandError('Use this in-game.') + const warp = data.warps[sub] + if (!warp) throw new CommandError(`Unknown warp "${sub}". See /warp list`) + go(player, warp, `warp ${sub}`) + } + }) + + ctx.command({ + name: 'home', + aliases: ['homes'], + category: 'essentials', + usage: '/home [name] | /home set [name] | /home delete [name] | /home list', + description: 'Your personal teleport points', + inGame: true, + run (player, args) { + const key = player.name.toLowerCase() + const homes = data.homes[key] || (data.homes[key] = Object.create(null)) + const sub = (args[0] || '').toLowerCase() + const name = (args[1] || 'home').toLowerCase() + + if (sub === 'list') { + const names = Object.keys(homes) + return player.message(names.length ? `&eYour homes (${names.length}/${maxHomes(player)}): &f${names.join(', ')}` : '&eYou have no homes. Use /home set') + } + if (sub === 'set') { + if (!homes[name] && Object.keys(homes).length >= maxHomes(player)) throw new CommandError(`You can have at most ${maxHomes(player)} homes.`) + if (!/^[a-z0-9_-]{1,24}$/.test(name)) throw new CommandError('Invalid home name.') + homes[name] = here(player) + save() + return player.message(`&aHome &f${name}&a set.`) + } + if (sub === 'delete' || sub === 'del' || sub === 'remove') { + if (!homes[name]) throw new CommandError('You have no home with that name.') + delete homes[name] + save() + return player.message(`&aHome ${name} deleted.`) + } + const target = homes[sub || 'home'] || (!sub && Object.values(homes)[0]) + if (!target) throw new CommandError(sub ? `You have no home named ${sub}.` : 'You have no home. Use /home set') + go(player, target, 'home') + } + }) + } +} diff --git a/plugins/web-panel/index.js b/plugins/web-panel/index.js new file mode 100644 index 0000000..12d2081 --- /dev/null +++ b/plugins/web-panel/index.js @@ -0,0 +1,151 @@ +'use strict' + +// Web administration panel: players, levels, live log and a console, in the browser. +// Disabled by default. Set "enabled": true in config/plugins/web-panel.json and /preload web-panel. +// It listens on 127.0.0.1 unless you change "host"; put it behind HTTPS if you expose it. + +const http = require('http') +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') + +const LOG_LINES = 500 + +module.exports = { + name: 'web-panel', + version: '1.0.0', + description: 'Browser admin panel (players, levels, logs, console)', + author: 'MCScript', + + defaultConfig: { + enabled: false, + host: '127.0.0.1', + port: 8080, + token: '' + }, + + load (ctx) { + const { server, config, log } = ctx + if (!config.enabled) { + log.info('Disabled. Edit config/plugins/web-panel.json to enable it.') + return + } + if (!config.token) { + config.token = crypto.randomBytes(18).toString('base64url') + ctx.saveConfig() + } + + const lines = [] + let seq = 0 + ctx.onUnload(server.log.subscribe(entry => { + lines.push({ id: ++seq, ...entry }) + if (lines.length > LOG_LINES) lines.shift() + })) + + const page = fs.readFileSync(path.join(__dirname, 'panel.html'), 'utf8') + const tokenBuf = Buffer.from(config.token) + const authorized = (req) => { + const header = req.headers.authorization || '' + const given = Buffer.from(header.replace(/^Bearer\s+/i, '')) + return given.length === tokenBuf.length && crypto.timingSafeEqual(given, tokenBuf) + } + + const json = (res, status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify(body)) + } + + const readBody = (req) => new Promise((resolve, reject) => { + let body = '' + req.on('data', c => { body += c; if (body.length > 10000) req.destroy() }) + req.on('end', () => { try { resolve(JSON.parse(body || '{}')) } catch (err) { reject(err) } }) + }) + + // Runs a command as the panel and returns what it printed + const runCommand = async (line) => { + const output = [] + const actor = server.createConsoleActor('WebPanel', m => { if (m) output.push(String(m)) }) + server.log.info(`[web panel] ${line}`) + if (line.startsWith('/')) await server.commands.execute(actor, line) + else server.broadcast(`&d[Panel]&f: ${server.text.sanitize(line)}`) + return output + } + + const status = () => ({ + name: server.config.name, + motd: server.config.motd, + version: server.version, + uptime: Date.now() - server.startedAt, + players: server.online.length, + maxPlayers: server.config.maxPlayers, + memoryMB: Math.round(process.memoryUsage().rss / 1048576), + levels: server.levels.loaded.size, + plugins: [...server.plugins.discover().keys()].sort().map(key => { + const p = server.plugins.get(key) + return p + ? { name: p.name, version: p.module.version || null, description: p.module.description || '', loaded: true, disabled: false } + : { name: key, version: null, description: '', loaded: false, disabled: server.plugins.isDisabled(key) } + }), + url: server.heartbeat.url + }) + + const players = () => server.online.map(p => ({ + name: p.name, + rank: p.rank.name, + color: p.color, + level: p.level ? p.level.name : null, + ip: p.ip, + client: p.appName, + ping: p.pingMs, + afk: !!p.data['essentials.afk'], + web: p.conn.isWebSocket, + online: Date.now() - p.joinedAt + })) + + const levels = () => server.levels.listFiles().map(name => { + const l = server.levels.get(name) + return l + ? { name, loaded: true, size: [l.width, l.height, l.length], players: l.players.length, main: l === server.levels.main } + : { name, loaded: false } + }) + + const httpServer = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://panel') + try { + if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer' }) + return res.end(page) + } + if (!url.pathname.startsWith('/api/')) return json(res, 404, { error: 'not found' }) + if (!authorized(req)) return json(res, 401, { error: 'invalid token' }) + + if (req.method === 'GET' && url.pathname === '/api/status') return json(res, 200, status()) + if (req.method === 'GET' && url.pathname === '/api/players') return json(res, 200, players()) + if (req.method === 'GET' && url.pathname === '/api/levels') return json(res, 200, levels()) + if (req.method === 'GET' && url.pathname === '/api/logs') { + const since = Number(url.searchParams.get('since') || 0) + return json(res, 200, lines.filter(l => l.id > since)) + } + if (req.method === 'POST' && url.pathname === '/api/command') { + const body = await readBody(req) + const line = String(body.command || '').trim() + if (!line) return json(res, 400, { error: 'empty command' }) + return json(res, 200, { output: await runCommand(line) }) + } + return json(res, 404, { error: 'not found' }) + } catch (err) { + log.error('Panel error:', err) + json(res, 500, { error: err.message }) + } + }) + + httpServer.on('error', err => log.error(`Could not start the web panel on ${config.host}:${config.port}: ${err.message}`)) + httpServer.listen(config.port, config.host, () => { + const addr = httpServer.address() + log.info(`Web panel on http://${config.host}:${addr.port}/ (token in config/plugins/web-panel.json)`) + module.exports.api.port = addr.port + }) + ctx.onUnload(() => httpServer.close()) + module.exports.api = { port: null } + } +} diff --git a/plugins/web-panel/panel.html b/plugins/web-panel/panel.html new file mode 100644 index 0000000..3663c20 --- /dev/null +++ b/plugins/web-panel/panel.html @@ -0,0 +1,309 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MCScript · Server panel + + + + + + + + + + diff --git a/plugins/zones/index.js b/plugins/zones/index.js new file mode 100644 index 0000000..71b2529 --- /dev/null +++ b/plugins/zones/index.js @@ -0,0 +1,166 @@ +'use strict' + +// Protected areas inside a level. Only players with enough rank (or on the zone's whitelist) can build there. +// Zones are stored inside the level file (level.meta.zones), so they travel with the map. + +const COLORS = [[255, 80, 80], [80, 255, 80], [80, 160, 255], [255, 220, 60], [220, 90, 255], [60, 230, 230]] +const SELECTION_BASE = 100 + +module.exports = { + name: 'zones', + version: '1.0.0', + description: 'Protected building zones', + author: 'MCScript', + + load (ctx) { + const { server, CommandError } = ctx + + const zonesOf = level => (level.meta.zones = level.meta.zones || []) + + const zonesAt = (level, x, y, z) => zonesOf(level).filter(zn => + x >= zn.x1 && x <= zn.x2 && y >= zn.y1 && y <= zn.y2 && z >= zn.z1 && z <= zn.z2) + + const canBuildIn = (player, zone) => { + if (player.isConsole) return true + if (zone.owners && zone.owners.includes(player.name.toLowerCase())) return true + return player.permission >= server.ranks.permissionOf(zone.rank) + } + + const canBuild = (player, level, x, y, z) => zonesAt(level, x, y, z).every(zn => canBuildIn(player, zn)) + + const show = (player, level) => { + zonesOf(level).forEach((zn, i) => { + const c = COLORS[i % COLORS.length] + player.showSelection(SELECTION_BASE + (i % 100), zn.name, { x: zn.x1, y: zn.y1, z: zn.z1 }, { x: zn.x2, y: zn.y2, z: zn.z2 }, [...c, 60]) + }) + } + const hide = (player, level) => zonesOf(level).forEach((zn, i) => player.hideSelection(SELECTION_BASE + (i % 100))) + + ctx.on('blockChange', (ev) => { + const blocked = zonesAt(ev.level, ev.x, ev.y, ev.z).find(zn => !canBuildIn(ev.player, zn)) + if (blocked) ev.cancel(`&cThis area is protected by zone &f${blocked.name}&c.`) + }, { priority: 'high' }) + + ctx.on('drawOperation', (ev) => { + if (!zonesOf(ev.level).length) return + const before = ev.changes.length + ev.changes = ev.changes.filter(([x, y, z]) => canBuild(ev.player, ev.level, x, y, z)) + if (ev.changes.length < before) ev.player.message(`&7${before - ev.changes.length} blocks were inside protected zones.`) + }, { priority: 'high' }) + + ctx.on('playerSpawn', ({ player }) => { if (player.data['zones.show']) show(player, player.level) }) + + ctx.command({ + name: 'zone', + aliases: ['zones', 'oz'], + category: 'building', + rank: 'Guest', + usage: '/zone ...', + description: 'Protects areas of the level', + help: [ + '/zone add [rank] - mark two corners; only that rank+ can build inside (default Operator)', + '/zone allow / disallow - lets a player build even without the rank', + '/zone delete , /zone list, /zone show, /zone hide, /zone check' + ], + inGame: true, + async run (player, args, { usage }) { + const level = player.level + const zones = zonesOf(level) + const sub = (args[0] || '').toLowerCase() + const name = (args[1] || '').toLowerCase() + const isOp = player.permission >= server.ranks.permissionOf('Operator') + const findZone = () => { + const zn = zones.find(z => z.name === name) + if (!zn) throw new CommandError(`No zone named "${name}" in this level.`) + return zn + } + + switch (sub) { + case 'add': + case 'create': { + if (!isOp) throw new CommandError('Only operators can create zones.') + if (!/^[a-z0-9_-]{1,32}$/.test(name)) return usage() + if (zones.some(z => z.name === name)) throw new CommandError('A zone with that name already exists.') + const rank = server.ranks.get(args[2] || 'Operator') + if (!rank) throw new CommandError('Unknown rank.') + let marks + try { marks = await player.selectBlocks(2, `Zone ${name}`) } catch (err) { return } + const [a, b] = marks + zones.push({ + name, + rank: rank.name, + owners: [], + createdBy: player.name, + x1: Math.min(a.x, b.x), + y1: Math.min(a.y, b.y), + z1: Math.min(a.z, b.z), + x2: Math.max(a.x, b.x), + y2: Math.max(a.y, b.y), + z2: Math.max(a.z, b.z) + }) + level.dirty = true + player.message(`&aZone &f${name}&a created. ${rank.color}${rank.name}&a+ can build inside.`) + if (player.data['zones.show']) show(player, level) + break + } + case 'delete': + case 'remove': + case 'del': { + if (!isOp) throw new CommandError('Only operators can delete zones.') + const zn = findZone() + hide(player, level) + zones.splice(zones.indexOf(zn), 1) + level.dirty = true + if (player.data['zones.show']) show(player, level) + player.message(`&aZone ${zn.name} deleted.`) + break + } + case 'allow': + case 'disallow': { + const zn = findZone() + if (!isOp && !(zn.owners || []).includes(player.name.toLowerCase())) throw new CommandError('Only operators or zone members can change members.') + const who = (args[2] || '').toLowerCase() + if (!who) return usage() + zn.owners = zn.owners || [] + if (sub === 'allow' && !zn.owners.includes(who)) zn.owners.push(who) + if (sub === 'disallow') zn.owners = zn.owners.filter(o => o !== who) + level.dirty = true + player.message(`&a${who} ${sub === 'allow' ? 'can now' : 'can no longer'} build in ${zn.name}.`) + break + } + case 'list': { + if (!zones.length) return player.message('&eThis level has no zones.') + for (const zn of zones) { + const rank = server.ranks.get(zn.rank) + player.message(`&f${zn.name}&7: (${zn.x1},${zn.y1},${zn.z1}) to (${zn.x2},${zn.y2},${zn.z2}), ${rank ? rank.color + rank.name : zn.rank}&7+${zn.owners && zn.owners.length ? ', members: ' + zn.owners.join(', ') : ''}`) + } + break + } + case 'show': + if (!player.supports('SelectionCuboid')) throw new CommandError('Your client can\'t display zones.') + player.data['zones.show'] = true + show(player, level) + player.message('&eShowing zones. &7(/zone hide)') + break + case 'hide': + player.data['zones.show'] = false + hide(player, level) + player.message('&eZones hidden.') + break + case 'check': { + let marks + try { marks = await player.selectBlocks(1, 'Zone check') } catch (err) { return } + const { x, y, z } = marks[0] + const found = zonesAt(level, x, y, z) + player.message(found.length ? `&eBlock is in: &f${found.map(zn => zn.name).join(', ')}&e. You ${found.every(zn => canBuildIn(player, zn)) ? '&acan' : '&ccannot'}&e build here.` : '&eThat block is not in any zone.') + break + } + default: + return usage() + } + } + }) + + module.exports.api = { zonesAt, canBuild } + } +} diff --git a/scripts/create-plugin.js b/scripts/create-plugin.js new file mode 100644 index 0000000..5d99ef0 --- /dev/null +++ b/scripts/create-plugin.js @@ -0,0 +1,21 @@ +'use strict' + +// Usage: npm run plugin:create [author] +const fs = require('fs') +const path = require('path') +const template = require('../lib/plugins/template') + +const [name, author] = process.argv.slice(2) +if (!name || !/^[A-Za-z][A-Za-z0-9_-]{1,31}$/.test(name)) { + console.error('Usage: npm run plugin:create [author]') + process.exit(1) +} +const dir = path.join(__dirname, '..', 'plugins', name) +if (fs.existsSync(dir)) { + console.error(`plugins/${name} already exists`) + process.exit(1) +} +fs.mkdirSync(dir, { recursive: true }) +fs.writeFileSync(path.join(dir, 'index.js'), template(name, author)) +console.log(`Created plugins/${name}/index.js`) +console.log('Start the server (or use /pload ' + name + ' in-game) to load it.') diff --git a/scripts/release-notes.js b/scripts/release-notes.js new file mode 100644 index 0000000..8806dc3 --- /dev/null +++ b/scripts/release-notes.js @@ -0,0 +1,21 @@ +'use strict' + +// Prints the CHANGELOG.md section for one version (used by the release workflow). +// Usage: node scripts/release-notes.js 2.0.0 +const fs = require('fs') +const path = require('path') + +const version = process.argv[2] +if (!version) { + console.error('Usage: node scripts/release-notes.js ') + process.exit(1) +} +const lines = fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf8').split(/\r?\n/) +const start = lines.findIndex(l => l.startsWith(`## ${version}`)) +if (start === -1) { + console.error(`CHANGELOG.md has no section for ${version}`) + process.exit(1) +} +let end = lines.findIndex((l, i) => i > start && l.startsWith('## ')) +if (end === -1) end = lines.length +console.log(lines.slice(start + 1, end).join('\n').trim()) diff --git a/src/command.js b/src/command.js deleted file mode 100644 index 602281e..0000000 --- a/src/command.js +++ /dev/null @@ -1,78 +0,0 @@ -module.exports = class Command { - constructor (params, parent) { - this.params = params - this.parent = parent - this.hash = parent ? parent.hash : {} - this.uniqueHash = parent ? parent.uniqueHash : {} - this.parentBase = (this.parent && this.parent.base && this.parent.base + ' ') || '' - this.base = this.parentBase + (this.params.base || '') - - if (this.params.base) this.updateHistory() - } - - find (command) { - command = command.split('').splice(1).join('') - - const parts = command.split(' ') - const c = parts.shift() - const pars = parts.join(' ') - - if (this.hash[c]) return [this.hash[c], pars] - return undefined - } - - use (command) { - const op = arguments.length <= 1 || arguments[1] === undefined ? true : arguments[1] - let res = this.find(command) - - if (res) { - let command = res[0] - let pars = res[1] - - if (command.params.op && !op) return '&cYou do not have permission to use this command' - const parse = command.params.parse - - if (parse) { - if (typeof parse === 'function') { - pars = parse(pars) - if (pars === false) return `&cUsage: ${command.params.usage}` - } else { - pars = pars.match(parse) - } - } - - res = command.params.action(pars) - if (res) return `${res}` - } else { - return '&cUnknown command. Try /help for a list of commands' - } - } - - updateHistory () { - const all = '(.+?)' - const list = [ this.base ] - if (this.params.aliases && this.params.aliases.length) this.params.aliases.forEach(al => list.unshift(this.parentBase + al)) - - list.forEach((command) => { - const parentBase = this.parent ? (this.parent.path || '') : '' - this.path = parentBase + this.space() + (command || all) - if (this.path === all && !this.parent) this.path = '' - if (this.path) this.hash[this.path] = this - }) - - this.uniqueHash[this.base] = this - } - - space (end) { - const first = !(this.parent && this.parent.parent) - return this.params.merged || (!end && first) ? '' : ' ' - } - - add (params) { - return new Command(params, this) - } - - setOp (op) { - this.params.op = op - } -} diff --git a/src/modules/blocks.js b/src/modules/blocks.js deleted file mode 100644 index 9e44d54..0000000 --- a/src/modules/blocks.js +++ /dev/null @@ -1,35 +0,0 @@ -const Vec3 = require('vec3') - -module.exports.player = (player, server) => { - player._client.on('set_block', (packet) => { - if (packet.mode === 0x01) { - server.setBlock(new Vec3(packet.x, packet.y, packet.z), packet.block_type) - } else if (packet.mode === 0x00) { - server.destroyBlock(new Vec3(packet.x, packet.y, packet.z)) - } - }) -} - -module.exports.server = (server) => { - server.setBlock = (coords, blockType) => { - server.world.setBlock(new Vec3(coords.x, coords.y, coords.z), blockType) - - server._writeAll('set_block', { - x: coords.x, - y: coords.y, - z: coords.z, - block_type: blockType - }) - } - - server.destroyBlock = (coords) => { - server.world.setBlock(new Vec3(coords.x, coords.y, coords.z), 0) - - server._writeAll('set_block', { - x: coords.x, - y: coords.y, - z: coords.z, - block_type: 0 - }) - } -} diff --git a/src/modules/chat.js b/src/modules/chat.js deleted file mode 100644 index 75e7bda..0000000 --- a/src/modules/chat.js +++ /dev/null @@ -1,62 +0,0 @@ -module.exports.player = (player, server) => { - player._client.on('message', (packet) => { - if (packet.message.split('')[0] === '/') { - player.handleCommand(packet.message) - } else { - player.emit('chat', { message: packet.message }) - server.log.info(`<${player.username}> ${packet.message}`) - - if (packet.message.length >= 57) { - server.broadcast(`<${player.username}> ${packet.message.split('%').join('&')}`) - server.broadcast(`> ${packet.message.split('').splice(56, packet.message.split('').length).join('')}`) - } else { - server.broadcast(`<${player.username}> ${packet.message.split('%').join('&')}`) - } - } - }) - - player.chat = (message) => { - player._client.write('message', { - player_id: player.id, - message: message - }) - } -} - -module.exports.server = (server) => { - server.broadcast = (message) => { - server._writeAll('message', { - player_id: 0, - message: message - }) - } - - server.color = { - 'black': '&0', - 'dark_blue': '&1', - 'navy': '&1', - 'dark_green': '&2', - 'green': '&2', - 'teal': '&3', - 'dark_red': '&4', - 'maroon': '&4', - 'purple': '&5', - 'dark_yellow': '&6', - 'gold': '&6', - 'gray': '&7', - 'grey': '&7', - 'silver': '&7', - 'dark_gray': '&8', - 'dark_grey': '&8', - 'indigo': '&9', - 'blue': '&9', - 'bright_green': '&a', - 'lime': '&a', - 'cyan': '&b', - 'aqua': '&b', - 'red': '&c', - 'pink': '&d', - 'yellow': '&e', - 'white': '&f' - } -} diff --git a/src/modules/commands.js b/src/modules/commands.js deleted file mode 100644 index f6c20c6..0000000 --- a/src/modules/commands.js +++ /dev/null @@ -1,185 +0,0 @@ -module.exports.player = (player, server) => { - player.commands.add({ - base: 'help', - info: 'to show all commands', - usage: '/help [page:command name]', - parse: function parse (str) { - const params = str.split(' ') - const page = parseInt(params[params.length - 1]) - if (page) params.pop() - const search = params.join(' ') - return { - search: search, - page: (page && page - 1) || 0 - } - }, - action: function action (_ref) { - let search = _ref.search - let page = _ref.page - - if (page < 0) return 'Page # must be >= 1' - let hash = player.commands.uniqueHash - - let PAGE_LENGTH = 7 - - let found = Object.keys(hash).filter((h) => { - return (`${h} `).indexOf((search && `${search} `) || '') === 0 - }) - - if (found.length === 0) { - return `${server.color.red}Could not find any matches` - } else if (found.length === 1) { - let cmd = hash[found[0]] - let usage = (cmd.params && cmd.params.usage) || cmd.base - let info = (cmd.params && cmd.params.info) || 'No info' - player.chat(`${server.color.white}${usage}${server.color.gray}: ${info}`) - } else { - let totalPages = Math.ceil((found.length - 1) / PAGE_LENGTH) - if (page >= totalPages) return `${server.color.red}The number you have entered is too big, it must be at most ${totalPages}` - found = found.sort() - if (found.indexOf('search') !== -1) { - let baseCmd = hash[search] - player.chat(`${baseCmd.base} -` + ((baseCmd.params && baseCmd.params.info && ` ${baseCmd.params.info}`) || '')) - } else { - player.chat(`${server.color.green}--- Showing help page ${(page + 1)} of ${totalPages} (/help ) ---`) - } - for (let i = PAGE_LENGTH * page; i < Math.min(PAGE_LENGTH * (page + 1), found.length); i++) { - if (found[i] === search) continue - let cmd = hash[found[i]] - let usage = (cmd.params && cmd.params.usage) || cmd.base - let info = (cmd.params && cmd.params.info) || 'No info' - player.chat(`${server.color.white}${usage}${server.color.gray}: ${info}`) - } - } - } - }) - - player.commands.add({ - base: 'version', - info: 'get the version of the server', - usage: '/version', - action () { - return `This server is running MCScript (${require('../../package').version}) made by Waxtz` - } - }) - - player.commands.add({ - base: 'op', - info: 'give a player operator permissions', - usage: '/op ', - op: true, - action (username) { - const user = server.getPlayer(username.toString().split(' ')[0].trim()) - - if (!user) return `${server.color.red}Player not found` - if (user.op !== true) user.op = true - - user._client.write('update_user_type', { - user_type: 0x64 - }) - - if (user !== player) user.chat(`${server.color.gray}You have been granted operator permissions`) - player.chat(`Opped ${username}`) - } - }) - - player.commands.add({ - base: 'deop', - info: 'remove a player\'s operator permissions', - usage: '/deop ', - op: true, - action (username) { - const user = server.getPlayer(username.toString().split(' ')[0].trim()) - - if (!user) return `${server.color.red}Player not found` - if (user.op !== false) user.op = false - - user._client.write('update_user_type', { - user_type: 0x00 - }) - - if (user !== player) user.chat(`${server.color.gray}You have been granted operator permissions`) - player.chat(`De-opped ${username}`) - } - }) - - player.commands.add({ - base: 'save', - info: 'save the world', - usage: '/save', - op: true, - action () { - player.chat('Saving...') - server.world.save() - .then(() => player.chat('Saved the world')) - .catch(() => player.chat('Failed to save the world')) - } - }) - - player.commands.add({ - base: 'tp', - info: 'teleport a player to another player', - usage: '/tp ', - op: true, - action (username) { - const user = server.getPlayer(username.toString().split(' ')[0].trim()) - - if (!user) { - return `${server.color.red}Player not found` - } else { - player.setPosition(user.pos.x, user.pos.y, user.pos.z, user.yaw, user.pitch) - return `${server.color.green}Teleported to ${username}` - } - } - }) - - player.commands.add({ - base: 'model', - info: 'change your model', - usage: '/model ', - action (model) { - player._client.write('change_model', { - entity_id: -1, - model_name: model.replace(':', '|') - }) - return `${server.color.green}Model changed to ${model}` - } - }) - - player.commands.add({ - base: 'kick', - info: 'kick a player from the server', - usage: '/kick [reason]', - op: true, - action (params) { - const user = server.getPlayer(params.toString().split(' ')[0].trim()) - let reason - - if (params.toString().split(' ')[1] !== null) reason = params.toString().split(' ')[1].trim() - if (!user) { - return `${server.color.red}Player not found` - } else { - if (reason === undefined) { - user.disconnect('You have been kicked from the server') - } else { - user.disconnect(reason) - } - } - } - }) - - player.handleCommand = (str) => { - try { - const res = player.commands.use(str, player.op) - if (res) player.chat(res) - } catch (err) { - if (err) { - player.chat(`${server.color.red}Error: ${err.message}`) - } else { - setTimeout(() => { - throw err - }, 0) - } - } - } -} diff --git a/src/modules/communication.js b/src/modules/communication.js deleted file mode 100644 index f1945f5..0000000 --- a/src/modules/communication.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports.server = (server) => { - server._writeAll = (packetName, packetFields) => { - server.players.forEach((player) => player._client.write(packetName, packetFields)) - } -} diff --git a/src/modules/cpe.js b/src/modules/cpe.js deleted file mode 100644 index 2c7e24a..0000000 --- a/src/modules/cpe.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports.player = (player) => { - player._client.on('ext_info', (packet) => { - player.extension_count = packet.extension_count - player.app_name = packet.app_name - }) - - player._client.on('ext_entry', (packet) => { - player.supported_extensions.push(packet) - }) -} diff --git a/src/modules/disconnect.js b/src/modules/disconnect.js deleted file mode 100644 index 3a64ca6..0000000 --- a/src/modules/disconnect.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports.player = (player) => { - player.disconnect = (reason) => { - player._client.write('disconnect_player', { - disconnect_reason: reason - }) - } -} diff --git a/src/modules/external.js b/src/modules/external.js deleted file mode 100644 index 27bbdb2..0000000 --- a/src/modules/external.js +++ /dev/null @@ -1,53 +0,0 @@ -module.exports.server = (server, settings) => { - server.plugins = {} - server.pluginCount = 0 - server.externalPluginsLoaded = false - - server.addPlugin = (name, plugin, set) => { - if (!name || !plugin) throw new Error('You need a name and object for your plugin!') - server.plugins[name] = { - id: server.pluginCount, - name: name, - player: plugin.player, - server: plugin.server, - settings: set, - enabled: true - } - server.pluginCount++ - if (server.externalPluginsLoaded && plugin.server) server.plugins[name].server(server, settings) - } - - Object.keys(settings.plugins).forEach((p) => { - if (settings.plugins[p].disabled) return - try { - require.resolve(p) - } catch (err) { - try { - require.resolve(`../../plugins/${p}`) - } catch (err) { - throw new Error(`Cannot find plugin "${p}"`) - } - return server.addPlugin(p, require(`../../plugins/${p}`), settings.plugins[p]) - } - server.addPlugin(p, require(p), settings.plugins[p]) - }) - - Object.keys(server.plugins).forEach((p) => { - if (server.plugins[p].server) server.plugins[p].server(server.plugins[p], server, settings) - }) - - server.on('asap', () => { - Object.keys(server.plugins).map((p) => { - server.log.info(`Plugin "${server.plugins[p].name}" loaded!`) - }) - }) - - server.externalPluginsLoaded = true -} - -module.exports.player = (player, server) => { - Object.keys(server.plugins).forEach((p) => { - const plugin = server.plugins[p] - if (plugin.player) plugin.player(plugin, player, server) - }) -} diff --git a/src/modules/heartbeat.js b/src/modules/heartbeat.js deleted file mode 100644 index a1908e2..0000000 --- a/src/modules/heartbeat.js +++ /dev/null @@ -1,14 +0,0 @@ -const fetch = require('node-fetch') - -module.exports.server = (server, settings) => { - if (settings.public === true) setInterval(() => server.heartbeat(), 45000) - - server.heartbeat = async () => { - if (settings.public === true) { - const body = await fetch(`https://www.classicube.net/heartbeat.jsp?port=${settings.port}&max=${settings['max-players']}&name=${settings['name']}&public=true&version=7&salt=${server.salt}&software=MCScript%20${require('../../package').version}&users=${server['online_players']}`) - .then(res => res.json()) - .catch(() => {}) - if ((body && body.errors[0][0]) && body.errors[0][0].startsWith('Port')) console.log(`Port ${settings.port} not open, you may need to port forward it.`) - } - } -} diff --git a/src/modules/irc.js b/src/modules/irc.js deleted file mode 100644 index 90878aa..0000000 --- a/src/modules/irc.js +++ /dev/null @@ -1,22 +0,0 @@ -const irc = require('irc') - -module.exports.server = (server) => { - this.irc = new irc.Client('irc.snoonet.org', 'MCScript', { - channels: [], - secure: false - }) - - this.irc.nick = 'MCScript' - this.irc._updateMaxLineLength() - this.irc.addListener('message', (from, to, message) => server.broadcast(`[irc] <${from}> ${message}`)) - this.irc.addListener('error', message => console.log('error: ', message)) -} - -module.exports.player = (player, server) => { - this.irc.join('##taigacult sonic') - - const ircSay = (message) => this.irc.say('##taigacult', `[cc] ${message}`) - player.on('chat', ({ message }) => ircSay(`${player.username}: ${message}`)) - player.on('connected', () => ircSay(`${player.username} connected`)) - player.on('disconnected', () => ircSay(`${player.username} disconnected`)) -} diff --git a/src/modules/login.js b/src/modules/login.js deleted file mode 100644 index e0137aa..0000000 --- a/src/modules/login.js +++ /dev/null @@ -1,132 +0,0 @@ -const { join } = require('path') - -const EventEmitter = require('events').EventEmitter -const requireIndex = require('requireindex') -const plugins = requireIndex(join(__dirname)) -const Logger = require('js-logger') -const crypto = require('crypto') -const MD5 = require('md5.js') -const zlib = require('zlib') -const Command = require('../command') - -module.exports.server = (server, options) => { - server.pid = process.pid - server.log = Logger - server.log.useDefaults() - - server.log.info(`Starting MCScript server version 0.30c (${require('../../package.json').version}) made by Waxtz`) - - server.salt = crypto.randomBytes(16).toString('hex') - server['online_players'] = 0 - - process.on('SIGINT', () => { - setTimeout(() => { - process.exit(0) - }, 500) - }) - - server - .on('error', (error) => { - server.log.error(`Oops! Something went wrong, ${error}`) - }) - .on('listening', () => { - server.log.info(`Started MCScript server on *:${server._server.socketServer.address().port}`) - }) - ._server.on('connection', (client) => { - client.on('error', error => server.emit('clientError', client, error)) - }) - - server._server.on('login', (client) => { - if (client.socket.listeners('end').length === 0) return - const player = new EventEmitter() - player._client = client - player.commands = new Command({}) - - Object.keys(plugins) - .filter(pluginName => plugins[pluginName].player !== undefined) - .forEach(pluginName => plugins[pluginName].player(player, server, options)) - - server.emit('newPlayer', player) - player.emit('asap') - player.login() - }) - - server.heartbeat() -} - -module.exports.player = (player, server, settings) => { - player.login = () => { - player._client.on('error', (err) => { - server.log.info(err.stack) - }) - - if (server.getPlayer(player._client.username)) { - return player._client.write('disconnect_player', { - disconnect_reason: 'You already joined the server.' - }) - } - - player.verification_key = player._client.verification_key - player.id = server.entityID - player.username = player._client.username - - server.log.info(`${player.username} [/${player._client.socket.remoteAddress}:${player._client.socket.remotePort}] logged in with entity id ${player.id}`) - - player.emit('connected') - - player._client.on('end', () => { - server.players.forEach((_player) => { - if (_player.id !== player.id) { - _player._client.write('despawn_player', { - player_id: player.id - }) - } - }) - const index = server.players.indexOf(player) - if (index > -1) server.players.splice(index, 1) - server._writeAll('message', { - player_id: player.id, - message: `&e${player.username} left the game` - }) - server.log.info(`${player.username} left the game`) - server['online_players']-- - player.emit('disconnected') - }) - - player._client.write('level_initialize', {}) - - const compressedMap = zlib.gzipSync(server.world.dump()) - - for (let i = 0; i < compressedMap.length; i += 1024) { - player._client.write('level_data_chunk', { - chunk_data: compressedMap.slice(i, Math.min(i + 1024, compressedMap.length)), - percent_complete: i === 0 ? 0 : Math.ceil(i / compressedMap.length * 100) - }) - } - - player._client.write('level_finalize', { - x_size: 256, - y_size: 64, - z_size: 256 - }) - - if (settings['max-players'] < server['online_players'] + 1) { - player._client.write('disconnect_player', { - disconnect_reason: 'The player limit has been reached, please try again later.' - }) - } else { - if (settings['online-mode'] === true) { - if ((new MD5().update(server.salt + player._client.username).digest('hex')) === player.verification_key) { - player.spawn() - } else { - player._client.write('disconnect_player', { - disconnect_reason: 'Your username could not be verified!' - }) - server.log.info(`${player.username} couldn't be verified!`) - } - } else { - player.spawn() - } - } - } -} diff --git a/src/modules/players.js b/src/modules/players.js deleted file mode 100644 index d0e9381..0000000 --- a/src/modules/players.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports.server = (server) => { - server.players = [] - server.entityID = 0 - - server.getPlayer = (name) => { - const found = server.players.filter((pl) => pl.username === name) - - if (found.length > 0) return found[0] - else return null - } -} diff --git a/src/modules/position.js b/src/modules/position.js deleted file mode 100644 index 9a82371..0000000 --- a/src/modules/position.js +++ /dev/null @@ -1,71 +0,0 @@ -module.exports.player = (player, server) => { - player._client.on('position', (packet) => { - player.positionUpdate(packet.x, packet.y, packet.z) - player.orientationUpdate(packet.yaw, packet.pitch) - }) - - player.positionUpdate = (x, y, z) => { - const dx = x - player.pos.x - const dy = y - player.pos.y - const dz = z - player.pos.z - const fitsInRelative = (dx >= -128 && dx <= 127) && (dy >= -128 && dy <= 127) && (dz >= -128 && dz <= 127) - - server.players.forEach((_player) => { - if (_player.id !== player.id) { - if (fitsInRelative) { - _player._client.write('position_update', { - player_id: player.id, - change_in_x: x - player.pos.x, - change_in_y: y - player.pos.y, - change_in_z: z - player.pos.z - }) - } else { - _player._client.write('player_teleport', { - player_id: player.id, - x: x, - y: y, - z: z, - yaw: player.yaw, - pitch: player.pitch - }) - } - } - }) - - player.pos.x = x - player.pos.y = y - player.pos.z = z - } - - player.orientationUpdate = (yaw, pitch) => { - server.players.forEach((_player) => { - if (_player.id !== player.id) { - _player._client.write('orientation_update', { - player_id: player.id, - yaw: yaw, - pitch: pitch - }) - } - }) - - player.yaw = yaw - player.pitch = pitch - } - - player.setPosition = (x, y, z, yaw, pitch) => { - player.pos.x = x - player.pos.y = y - player.pos.z = z - player.yaw = yaw - player.pitch = pitch - - player._client.write('player_teleport', { - player_id: -1, - x: player.pos.x, - y: player.pos.y, - z: player.pos.z, - yaw: player.yaw, - pitch: player.pitch - }) - } -} diff --git a/src/modules/spawn.js b/src/modules/spawn.js deleted file mode 100644 index eb42dbd..0000000 --- a/src/modules/spawn.js +++ /dev/null @@ -1,66 +0,0 @@ -const Vec3 = require('vec3') - -module.exports.player = (player, server) => { - player.spawn = () => { - player.pos = new Vec3(1 * 32, 34 * 32, 1 * 32) - player.yaw = 0 - player.pitch = 0 - player.op = true - player.cpe = false - player.supported_extensions = [] - - if (player._client.identification_byte === 0x42) player.cpe = true - if (player.cpe) { - player._client.write('ext_info', { - app_name: 'MCScript', - extension_count: 0 - }) - } - - player._client.write('spawn_player', { - player_id: -1, - player_name: player._client.username, - x: player.pos.x, - y: player.pos.y, - z: player.pos.z, - yaw: player.yaw, - pitch: player.pitch - }) - - server.players.forEach((_player) => { - _player._client.write('spawn_player', { - player_id: server.entityID, - player_name: player._client.username, - x: player.pos.x, - y: player.pos.y, - z: player.pos.z, - yaw: player.yaw, - pitch: player.pitch - }) - }) - - server.players.push(player) - - server.players.forEach((_player) => { - if (_player.id !== player.id) { - player._client.write('spawn_player', { - player_id: _player.id, - player_name: _player.username, - x: _player.pos.x, - y: _player.pos.y, - z: _player.pos.z, - yaw: _player.yaw, - pitch: _player.pitch - }) - } - }) - - server.broadcast(`${server.color.yellow}${player._client.username} joined the game`) - server.log.info(`${player.username} joined the game`) - player.chat(`${server.color.gray}Welcome ${server.color.green}${player._client.username}${server.color.gray}!`) - player.chat(`${server.color.gray}Server running using ${server.color.pink}Waxtz${server.color.gray}'s server software made in JS`) - - server['online_players']++ - server.entityID++ - } -} diff --git a/src/modules/world.js b/src/modules/world.js deleted file mode 100644 index 54ea625..0000000 --- a/src/modules/world.js +++ /dev/null @@ -1,28 +0,0 @@ -const World = require('../world') -const Vec3 = require('vec3') - -module.exports.server = async (server) => { - server.world = new World({ x: 256, y: 64, z: 256 }) - - server.world.load() - .then(() => {}) - .catch(async () => { - for (let x = 0; x < server.world.size.x; x++) { - for (let y = 0; y <= (server.world.size.y / 2); y++) { - for (let z = 0; z < server.world.size.z; z++) { - if (y === 0) { - server.world.setBlock(new Vec3(x, y, z), 0x07) - } else if (y <= (server.world.size.y / 2) - 4) { - server.world.setBlock(new Vec3(x, y, z), 0x01) - } else if (y <= (server.world.size.y / 2) - 1) { - server.world.setBlock(new Vec3(x, y, z), 0x03) - } else if (y === (server.world.size.y / 2)) { - server.world.setBlock(new Vec3(x, y, z), 0x02) - } - } - } - } - - await server.world.save() - }) -} diff --git a/src/world.js b/src/world.js deleted file mode 100644 index 495827e..0000000 --- a/src/world.js +++ /dev/null @@ -1,44 +0,0 @@ -const { readFile, writeFile } = require('fs') -const { gunzip, gzip } = require('zlib') -const { promisify } = require('util') - -const [ - readFileAsync, - writeFileAsync, - gunzipAsync, - gzipAsync -] = [ - promisify(readFile), - promisify(writeFile), - promisify(gunzip), - promisify(gzip) -] - -module.exports = class World { - constructor (size) { - this.size = size - this.data = Buffer.alloc(4 + size.x * size.y * size.z) - this.data.fill(0) - this.data.writeInt32BE(this.size.x * this.size.y * this.size.z, 0) - } - - setBlock (pos, block) { - this.data.writeUInt8(block, 4 + pos.x + this.size.z * (pos.z + this.size.x * pos.y)) - } - - getBlock (pos) { - return this.data.readUInt8(4 + pos.x + this.size.z * (pos.z + this.size.x * pos.y)) - } - - dump () { - return this.data - } - - async load () { - this.data = await gunzipAsync(await readFileAsync('./levels/level.dat')) - } - - async save () { - await writeFileAsync('./levels/level.dat', await gzipAsync(this.data)) - } -} diff --git a/test/helpers/client.js b/test/helpers/client.js new file mode 100644 index 0000000..7936564 --- /dev/null +++ b/test/helpers/client.js @@ -0,0 +1,203 @@ +'use strict' + +// A tiny scripted Classic/CPE client used by the integration tests. + +const net = require('net') +const zlib = require('zlib') +const { EventEmitter } = require('events') +const packets = require('../../lib/protocol/packets') + +const CLIENT_OUT = { + identification: packets.CLIENT[0x00], + setBlock: packets.CLIENT[0x05], + position: packets.CLIENT[0x08], + message: packets.CLIENT[0x0d], + extInfo: packets.CLIENT[0x10], + extEntry: packets.CLIENT[0x11], + customBlockSupportLevel: packets.CLIENT[0x13], + playerClicked: packets.CLIENT[0x22], + twoWayPing: packets.CLIENT[0x2b], + pluginMessage: packets.CLIENT[0x35], + notifyAction: packets.CLIENT[0x39] +} + +const resolve = (type, opts) => type === 'blk' ? (opts.extBlocks ? 'u16' : 'u8') : type === 'pos' ? (opts.extPos ? 'i32' : 'i16') : type + +function encodeClient (name, data, opts = {}) { + const def = CLIENT_OUT[name] + const buf = Buffer.alloc(packets.sizeOf(def, opts)) + buf[0] = def.id + let o = 1 + for (const [field, rawType] of def.fields) { + const type = resolve(rawType, opts) + const v = data[field] ?? 0 + if (type === 'u8') { buf.writeUInt8(v & 255, o); o += 1 } else if (type === 'i8') { buf.writeInt8(v, o); o += 1 } else if (type === 'u16') { buf.writeUInt16BE(v, o); o += 2 } else if (type === 'i16') { buf.writeInt16BE(v, o); o += 2 } else if (type === 'i32') { buf.writeInt32BE(v, o); o += 4 } else if (type === 'str') { packets.writeString(buf, o, v, true); o += 64 } else if (type.startsWith('bytes:')) { const n = Number(type.slice(6)); if (v) Buffer.from(v).copy(buf, o); o += n } + } + return buf +} + +// server packet id -> list of defs (levelInitialize has two variants) +function serverDefs (fastMap) { + const byId = {} + for (const [name, def] of Object.entries(packets.SERVER)) { + if (name === 'levelInitializeFast' && !fastMap) continue + if (name === 'levelInitialize' && fastMap) continue + byId[def.id] = { name, ...def } + } + return byId +} + +function decodeServer (def, buf, opts = {}) { + const out = { name: def.name } + let o = 1 + for (const [field, rawType] of def.fields) { + const type = resolve(rawType, opts) + if (type === 'u8') { out[field] = buf.readUInt8(o); o += 1 } else if (type === 'i8') { out[field] = buf.readInt8(o); o += 1 } else if (type === 'u16') { out[field] = buf.readUInt16BE(o); o += 2 } else if (type === 'i16') { out[field] = buf.readInt16BE(o); o += 2 } else if (type === 'i32') { out[field] = buf.readInt32BE(o); o += 4 } else if (type === 'f32') { out[field] = buf.readFloatBE(o); o += 4 } else if (type === 'str') { out[field] = packets.readString(buf, o); o += 64 } else if (type.startsWith('xbytes:')) { + const n = opts.extBlocks ? Number(type.slice(7)) : 0; out[field] = Buffer.from(buf.subarray(o, o + n)); o += n + } else { + const n = Number(type.slice(6)); out[field] = Buffer.from(buf.subarray(o, o + n)); o += n + } + } + return out +} + +class TestClient extends EventEmitter { + constructor ({ port, name = 'Tester', cpe = true, extensions = null, key = '' }) { + super() + this.port = port + this.name = name + this.cpe = cpe + this.key = key + // by default, pretend to support everything the server supports + this.extensions = extensions || Object.entries(packets.EXTENSIONS) + this.buffer = Buffer.alloc(0) + this.received = [] + this.cursor = 0 + this.messages = [] + this.levelChunks = [] + this.level = null + this.entities = new Map() + this.blockChanges = [] + this.defs = serverDefs(false) + this.opts = { extBlocks: false, extPos: false } + } + + connect () { + return new Promise((resolve, reject) => { + this.socket = net.connect(this.port, '127.0.0.1', () => { + this.send('identification', { protocolVersion: 7, username: this.name, key: this.key, padding: this.cpe ? 0x42 : 0 }) + resolve() + }) + this.socket.on('error', reject) + this.socket.on('data', d => this._onData(d)) + this.socket.on('close', () => this.emit('close')) + }) + } + + send (name, data = {}) { this.socket.write(encodeClient(name, data, this.opts)) } + + chat (msg) { this.send('message', { partial: 0, message: msg }) } + + _onData (data) { + this.buffer = Buffer.concat([this.buffer, data]) + while (this.buffer.length) { + const def = this.defs[this.buffer[0]] + if (!def) throw new Error(`Test client: unknown server packet ${this.buffer[0]}`) + const size = packets.sizeOf(def, this.opts) + if (this.buffer.length < size) return + const p = decodeServer(def, this.buffer.subarray(0, size), this.opts) + this.buffer = this.buffer.subarray(size) + this._handle(p) + } + } + + _handle (p) { + this.received.push(p) + switch (p.name) { + case 'extInfo': + this.serverExtCount = p.count + break + case 'extEntry': + if (--this.serverExtCount === 0) { + this.send('extInfo', { appName: 'TestClient', count: this.extensions.length }) + for (const [extName, version] of this.extensions) this.send('extEntry', { extName, version }) + const fast = this.extensions.some(([n]) => n === 'FastMap') + this.defs = serverDefs(fast) + this.fastMap = fast + this.opts = { + extBlocks: this.extensions.some(([n]) => n === 'ExtendedBlocks'), + extPos: this.extensions.some(([n]) => n === 'ExtEntityPositions') + } + } + break + case 'customBlockSupportLevel': + this.send('customBlockSupportLevel', { level: 1 }) + break + case 'levelDataChunk': + if (this.opts.extBlocks && p.percent) (this.upperChunks = this.upperChunks || []).push(p.data.subarray(0, p.length)) + else this.levelChunks.push(p.data.subarray(0, p.length)) + break + case 'levelFinalize': { + const inflate = data => this.fastMap ? zlib.inflateRawSync(data) : zlib.gunzipSync(data).subarray(4) + const raw = inflate(Buffer.concat(this.levelChunks)) + const upper = this.upperChunks ? inflate(Buffer.concat(this.upperChunks)) : null + this.levelChunks = [] + this.upperChunks = null + this.level = { width: p.x, height: p.y, length: p.z, blocks: raw, upper } + this.emit('level', this.level) + break + } + case 'message': + this.messages.push(p.message) + this.emit('message', p.message) + break + case 'spawnPlayer': + case 'extAddEntity2': + this.entities.set(p.id, p) + this.emit('spawn', p) + break + case 'despawnPlayer': + this.entities.delete(p.id) + this.emit('despawn', p) + break + case 'setBlock': + this.blockChanges.push(p) + if (this.level) this.level.blocks[(p.y * this.level.length + p.z) * this.level.width + p.x] = p.block + break + case 'bulkBlockUpdate': + for (let i = 0; i <= p.count; i++) { + const index = p.indices.readInt32BE(i * 4) + if (this.level) this.level.blocks[index] = p.blocks[i] + } + this.blockChanges.push(p) + break + case 'disconnect': + this.kickReason = p.reason + this.emit('kick', p.reason) + break + } + this.emit('packet', p) + } + + waitFor (predicate, timeout = 3000) { + if (typeof predicate === 'string') { const name = predicate; predicate = p => p.name === name } + const existing = this.received.slice(this.cursor).find(predicate) + if (existing) return Promise.resolve(existing) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.off('packet', handler); reject(new Error('Timed out waiting for packet')) }, timeout) + const handler = p => { if (predicate(p)) { clearTimeout(timer); this.off('packet', handler); resolve(p) } } + this.on('packet', handler) + }) + } + + waitForMessage (re, timeout = 3000) { + return this.waitFor(p => p.name === 'message' && re.test(p.message), timeout) + } + + // Only packets received after mark() are considered by waitFor() + mark () { this.cursor = this.received.length } + + close () { this.socket.destroy() } +} + +module.exports = { TestClient } diff --git a/test/helpers/server.js b/test/helpers/server.js new file mode 100644 index 0000000..e475797 --- /dev/null +++ b/test/helpers/server.js @@ -0,0 +1,55 @@ +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { MCScriptServer } = require('../../lib/server') +const { loadConfig } = require('../../lib/config') + +const REPO = path.join(__dirname, '..', '..') + +// Starts a server in a temporary folder with a copy of the bundled plugins. +async function startServer (overrides = {}, root = null, pluginConfigs = {}) { + if (!root) { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'mcscript-test-')) + fs.cpSync(path.join(REPO, 'plugins'), path.join(root, 'plugins'), { recursive: true }) + } + for (const [name, cfg] of Object.entries(pluginConfigs)) { + fs.mkdirSync(path.join(root, 'config', 'plugins'), { recursive: true }) + fs.writeFileSync(path.join(root, 'config', 'plugins', `${name}.json`), JSON.stringify(cfg)) + } + const config = loadConfig({ + root, + env: {}, + overrides: { + port: 0, + host: '127.0.0.1', + public: false, + verifyNames: false, + checkForUpdates: false, + silent: true, + logToFile: false, + autosaveMinutes: 0, + backupMinutes: 0, + exitOnStop: false, + mainLevel: 'main', + ...overrides + } + }) + const server = new MCScriptServer(config) + // small main level so tests run fast + if (!fs.existsSync(path.join(root, 'levels', 'main.cw'))) { + fs.mkdirSync(path.join(root, 'levels'), { recursive: true }) + const { Level } = require('../../lib/level/level') + const { generators } = require('../../lib/level/generators') + const level = new Level({ name: 'main', width: 64, height: 32, length: 64 }) + generators.flat.generate(level) + fs.writeFileSync(path.join(root, 'levels', 'main.cw'), level.toCW()) + } + await server.start() + return { server, root } +} + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +module.exports = { startServer, sleep } diff --git a/test/level.test.js b/test/level.test.js new file mode 100644 index 0000000..b96b206 --- /dev/null +++ b/test/level.test.js @@ -0,0 +1,133 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const zlib = require('zlib') + +const { Level } = require('../lib/level/level') +const { generators } = require('../lib/level/generators') + +test('block indexing follows the classic x + width * (z + length * y) order', () => { + const level = new Level({ name: 't', width: 4, height: 3, length: 5 }) + level.setBlockRaw(1, 2, 3, 7) + assert.equal(level.blocks[1 + 4 * (3 + 5 * 2)], 7) + assert.deepEqual(level.unpack(level.index(1, 2, 3)), { x: 1, y: 2, z: 3 }) + assert.equal(level.getBlock(10, 0, 0), 0) +}) + +test('ClassicWorld save/load keeps blocks, env, custom blocks and plugin metadata', () => { + const level = new Level({ name: 'roundtrip', width: 16, height: 16, length: 32 }) + generators.flat.generate(level) + level.setBlockRaw(3, 9, 20, 45) + level.env.skyColor = 'ff0000' + level.env.texture = 'https://example.com/pack.zip' + level.env.weather = 2 + level.blockDefs[70] = { id: 70, name: 'Lamp', fullBright: true } + level.meta.zones = [{ name: 'z' }] + level.buildRank = 'Builder' + level.spawn = { x: 5.5, y: 9, z: 6.5, yaw: 64, pitch: 0 } + + const copy = Level.fromCW(level.toCW(), 'roundtrip') + assert.equal(copy.width, 16); assert.equal(copy.length, 32) + assert.deepEqual(copy.blocks, level.blocks) + assert.equal(copy.env.skyColor, 'ff0000') + assert.equal(copy.env.texture, 'https://example.com/pack.zip') + assert.equal(copy.env.weather, 2) + assert.equal(copy.blockDefs[70].name, 'Lamp') + assert.deepEqual(copy.meta.zones, [{ name: 'z' }]) + assert.equal(copy.buildRank, 'Builder') + assert.equal(copy.spawn.x, 5.5); assert.equal(copy.spawn.yaw, 64) +}) + +test('.cw from other software: env and block definitions are read from CPE metadata', () => { + const level = new Level({ name: 'x', width: 16, height: 16, length: 16 }) + level.env.fogColor = '112233' + level.env.sideBlock = 12 + level.blockDefs[100] = { id: 100, name: 'Foreign', collide: 0, textures: { top: 5, bottom: 6, left: 7, right: 7, front: 7, back: 7 } } + // Drop our own MCScript metadata to simulate a map from ClassiCube/MCGalaxy + const nbt = require('../lib/util/nbt') + const tree = nbt.read(zlib.gunzipSync(level.toCW())) + delete tree.value.Metadata.value.MCScript + const foreign = Level.fromCW(zlib.gzipSync(nbt.write('ClassicWorld', tree.value)), 'x') + assert.equal(foreign.env.fogColor, '112233') + assert.equal(foreign.env.sideBlock, 12) + assert.equal(foreign.blockDefs[100].name, 'Foreign') + assert.equal(foreign.blockDefs[100].collide, 0) + assert.equal(foreign.blockDefs[100].textures.top, 5) +}) + +test('MCGalaxy .lvl import', () => { + const w = 8; const h = 4; const l = 6 + const header = Buffer.alloc(18) + header.writeUInt16LE(1874, 0) + header.writeUInt16LE(w, 2); header.writeUInt16LE(l, 4); header.writeUInt16LE(h, 6) + header.writeUInt16LE(2, 8); header.writeUInt16LE(3, 10); header.writeUInt16LE(1, 12) + const blocks = Buffer.alloc(w * h * l, 1) + blocks[0] = 49 + blocks[1] = 110 // MCGalaxy physics block -> stone + const level = Level.fromLvl(zlib.gzipSync(Buffer.concat([header, blocks])), 'imported') + assert.equal(level.width, w); assert.equal(level.height, h); assert.equal(level.length, l) + assert.equal(level.blocks[0], 49) + assert.equal(level.blocks[1], 1) + assert.equal(level.spawn.x, 2.5); assert.equal(level.spawn.z, 3.5) + assert.equal(level.importWarnings.length, 1) +}) + +test('legacy MCScript level.dat import', () => { + // 1.x format: gzip of a big-endian volume followed by the raw 256x64x256 blocks, bedrock at the bottom + const volume = 256 * 64 * 256 + const raw = Buffer.alloc(4 + volume) + raw.writeInt32BE(volume, 0) + raw.fill(7, 4, 4 + 256 * 256) + raw.fill(2, 4 + 256 * 256, 4 + 2 * 256 * 256) + const level = Level.fromLegacyDat(zlib.gzipSync(raw), 'main') + assert.equal(level.volume, volume) + assert.equal(level.getBlock(10, 0, 10), 7) + assert.equal(level.getBlock(10, 1, 10), 2) + assert.equal(level.spawn.y, 2) + assert.throws(() => Level.fromLegacyDat(zlib.gzipSync(Buffer.alloc(8)), 'bad'), /Unexpected legacy level size/) +}) + +test('generators produce valid levels', () => { + for (const name of ['empty', 'flat', 'pixel', 'space', 'ocean', 'island', 'terrain']) { + 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) + assert.ok(level.blocks.some(b => b !== 0), name) + } + // same seed, same terrain + const a = new Level({ name: 'a', width: 32, height: 32, length: 32 }) + const b = new Level({ name: 'b', width: 32, height: 32, length: 32 }) + generators.terrain.generate(a, { seed: 99 }) + generators.terrain.generate(b, { seed: 99 }) + assert.deepEqual(a.blocks, b.blocks) +}) + +test('block ids above 255 are stored in the upper array and saved as BlockArray2', () => { + const level = new Level({ name: 'big', width: 8, height: 8, length: 8 }) + assert.equal(level.upper, null) + level.setBlockRaw(1, 1, 1, 700) + level.setBlockRaw(2, 2, 2, 45) + assert.equal(level.getBlock(1, 1, 1), 700) + assert.equal(level.getBlock(2, 2, 2), 45) + const copy = Level.fromCW(level.toCW(), 'big') + assert.equal(copy.getBlock(1, 1, 1), 700) + const table = new Uint16Array(768).map((_, i) => i) + const { lower, upper } = copy.serializeForClient(table) + const i = copy.index(1, 1, 1) + assert.equal(lower[4 + i] | (upper[4 + i] << 8), 700) + // no upper array needed when nothing is above 255 + table[700] = 1 + assert.equal(copy.serializeForClient(table).upper, null) +}) + +test('/write font layout', () => { + const font = require('../plugins/core-building/font') + const hi = font.layout('Hi!') + assert.equal(hi.height, 7) + assert.equal(hi.width, 5 + 1 + 5 + 1 + 5) + // "H" has a full left column + for (let y = 0; y < 7; y++) assert.ok(hi.pixels.some(([x, py]) => x === 0 && py === y)) + assert.deepEqual(font.glyph('ñ'), font.glyph('N')) + assert.equal(font.layout(' ').pixels.length, 0) +}) diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..7c6144f --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,867 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const net = require('net') +const crypto = require('crypto') + +const { startServer, sleep } = require('./helpers/server') +const { TestClient } = require('./helpers/client') +const packets = require('../lib/protocol/packets') + +async function join (server, name, opts = {}) { + const c = new TestClient({ port: server.port, name, ...opts }) + await c.connect() + await c.waitFor('levelFinalize') + await c.waitFor(p => p.name === (c.cpe ? 'extAddEntity2' : 'spawnPlayer') && p.id === -1) + return c +} + +async function command (client, cmd, wait = 150) { + client.mark() + client.chat(cmd) + await sleep(wait) + return client.received.slice(client.cursor).filter(p => p.name === 'message').map(p => p.message).join('\n') +} + +test('login, CPE negotiation and map transfer', async (t) => { + const { server } = await startServer({ owners: ['Owner1'] }) + t.after(() => server.stop()) + + const cpe = await join(server, 'Alice') + assert.ok(cpe.received.some(p => p.name === 'extInfo')) + assert.equal(cpe.level.width, 64) + assert.equal(cpe.level.blocks.length, 64 * 32 * 64) + assert.ok(cpe.received.some(p => p.name === 'levelInitializeFast'), 'FastMap used for CPE clients') + assert.ok(cpe.received.some(p => p.name === 'envSetColor')) + const player = server.findPlayerExact('Alice') + assert.equal(player.customBlocksLevel, 1) + assert.ok(player.supports('BlockDefinitionsExt', 2)) + + const classic = await join(server, 'Bob', { cpe: false }) + assert.ok(!classic.received.some(p => p.name === 'extInfo')) + assert.ok(classic.received.some(p => p.name === 'levelInitialize')) + assert.equal(classic.level.blocks.length, 64 * 32 * 64) + + // they see each other + await cpe.waitFor(p => p.name === 'extAddEntity2' && p.entityName.includes('Bob')) + assert.ok([...classic.entities.values()].some(e => e.entityName.includes('Alice'))) + // tab list + await cpe.waitFor(p => p.name === 'extAddPlayerName' && p.playerName === 'Bob') + + // duplicate login kicks the old session + const again = new TestClient({ port: server.port, name: 'Bob', cpe: false }) + await again.connect() + await classic.waitFor('disconnect') + assert.match(classic.kickReason, /logged in as you/) + again.close() + cpe.close() +}) + +test('chat, colors and commands', async (t) => { + const { server } = await startServer() + t.after(() => server.stop()) + const a = await join(server, 'Alice') + const b = await join(server, 'Bob') + + a.chat('hello %cthere') + const msg = await b.waitForMessage(/hello/) + assert.match(msg.message, /Alice&f: hello &cthere/) + + // long messages (LongerMessages) arrive in parts + a.send('message', { partial: 1, message: 'x'.repeat(64) }) + a.send('message', { partial: 0, message: 'END' }) + await b.waitForMessage(/END/) + + assert.match(await command(a, '/nonexistent'), /Unknown command/) + assert.match(await command(a, '/kick Bob'), /Only .*Operator/) + assert.match(await command(a, '/help tp'), /\/tp /) + assert.match(await command(a, '/msg Bob secret'), /-> .*Bob/) + await b.waitForMessage(/secret/) + a.close(); b.close() +}) + +test('building: permissions, broadcast and CPE block fallback', async (t) => { + const { server } = await startServer() + t.after(() => server.stop()) + const a = await join(server, 'Alice') + const b = await join(server, 'Bob', { cpe: false }) + const p = server.findPlayerExact('Alice').blockPos + + // place stone next to the player + a.send('setBlock', { x: p.x + 1, y: p.y, z: p.z, mode: 1, block: 1 }) + await b.waitFor(pk => pk.name === 'setBlock' && pk.x === p.x + 1 && pk.block === 1) + assert.equal(server.levels.main.getBlock(p.x + 1, p.y, p.z), 1) + + // CPE block: classic client gets the fallback (sandstone 52 -> sand 12) + a.send('setBlock', { x: p.x + 2, y: p.y, z: p.z, mode: 1, block: 52 }) + await b.waitFor(pk => pk.name === 'setBlock' && pk.x === p.x + 2) + assert.equal(b.blockChanges.find(pk => pk.x === p.x + 2).block, 12) + await a.waitFor(pk => pk.name === 'setBlock' && pk.x === p.x + 2 && pk.block === 52) + + // guests can't place bedrock: the server reverts it + a.mark() + a.send('setBlock', { x: p.x + 3, y: p.y, z: p.z, mode: 1, block: 7 }) + const revert = await a.waitFor(pk => pk.name === 'setBlock' && pk.x === p.x + 3) + assert.equal(revert.block, 0) + assert.equal(server.levels.main.getBlock(p.x + 3, p.y, p.z), 0) + + // far away blocks are rejected + a.send('setBlock', { x: 60, y: 1, z: 60, mode: 0, block: 0 }) + await sleep(100) + assert.notEqual(server.levels.main.getBlock(60, 1, 60), 0) + + // slabs stack into a double slab + a.send('setBlock', { x: p.x, y: p.y, z: p.z + 1, mode: 1, block: 44 }) + a.send('setBlock', { x: p.x, y: p.y + 1, z: p.z + 1, mode: 1, block: 44 }) + await sleep(150) + assert.equal(server.levels.main.getBlock(p.x, p.y, p.z + 1), 43) + a.close(); b.close() +}) + +test('drawing with /cuboid uses BulkBlockUpdate and /undo reverts it', async (t) => { + const { server } = await startServer({ owners: ['Builder1'] }) + t.after(() => server.stop()) + const a = await join(server, 'Builder1') + const b = await join(server, 'Watcher', { cpe: false }) + const p = server.findPlayerExact('Builder1').blockPos + + a.chat('/cuboid glass') + await a.waitForMessage(/mark the corners/) + a.send('setBlock', { x: p.x, y: p.y, z: p.z, mode: 1, block: 1 }) + a.send('setBlock', { x: p.x + 3, y: p.y + 3, z: p.z + 3, mode: 1, block: 1 }) + await a.waitForMessage(/changed &f64/) + await a.waitFor('bulkBlockUpdate') + assert.equal(server.levels.main.getBlock(p.x + 2, p.y + 2, p.z + 2), 20) + // the classic client has no BulkBlockUpdate and gets individual block changes + await b.waitFor(pk => pk.name === 'setBlock' && pk.x === p.x + 2 && pk.y === p.y + 2 && pk.z === p.z + 2) + assert.equal(b.level.blocks[server.levels.main.index(p.x + 2, p.y + 2, p.z + 2)], 20) + assert.ok(!b.received.some(pk => pk.name === 'bulkBlockUpdate')) + + await command(a, '/undo') + assert.equal(server.levels.main.getBlock(p.x + 2, p.y + 2, p.z + 2), 0) + a.close(); b.close() +}) + +test('multiple levels, environment and custom blocks', async (t) => { + const { server } = await startServer({ owners: ['Alice'] }) + t.after(() => server.stop()) + const a = await join(server, 'Alice') + const b = await join(server, 'Bob', { cpe: false }) + + assert.match(await command(a, '/newlvl second 32 32 48 island 7', 400), /Created level second/) + a.mark() + a.chat('/goto second') + const fin = await a.waitFor('levelFinalize') + assert.deepEqual([fin.x, fin.y, fin.z], [32, 32, 48]) + await b.waitFor(pk => pk.name === 'despawnPlayer') + assert.equal(server.findPlayerExact('Alice').level.name, 'second') + + a.mark() + a.chat('/env sky ff0000') + const color = await a.waitFor(pk => pk.name === 'envSetColor' && pk.variable === 0) + assert.deepEqual([color.r, color.g, color.b], [255, 0, 0]) + + a.mark() + a.chat('/texture https://example.com/pack.zip') + const url = await a.waitFor('setMapEnvUrl') + assert.equal(url.url, 'https://example.com/pack.zip') + + a.mark() + a.chat('/lb preset 80 lamp') + const def = await a.waitFor('defineBlockExt') + assert.equal(def.block, 80) + assert.equal(def.blockName, 'Lamp') + assert.equal(def.fullBright, 1) + assert.equal(server.levels.get('second').blockDefs[80].name, 'Lamp') + + // leaving the level removes its custom blocks from the client + a.mark() + a.chat('/main') + await a.waitFor(pk => pk.name === 'removeBlockDefinition' && pk.block === 80) + a.close(); b.close() +}) + +test('zones protect areas', async (t) => { + const { server } = await startServer({ owners: ['Admin1'] }) + t.after(() => server.stop()) + const admin = await join(server, 'Admin1') + const guest = await join(server, 'Guest1') + const p = server.findPlayerExact('Guest1').blockPos + + admin.chat('/zone add safe Operator') + await admin.waitForMessage(/mark the corners/) + admin.send('setBlock', { x: p.x - 3, y: p.y - 3, z: p.z - 3, mode: 1, block: 1 }) + admin.send('setBlock', { x: p.x + 3, y: p.y + 3, z: p.z + 3, mode: 1, block: 1 }) + await admin.waitForMessage(/Zone &fsafe&a created/) + + guest.mark() + guest.send('setBlock', { x: p.x + 1, y: p.y, z: p.z, mode: 1, block: 1 }) + await guest.waitForMessage(/protected by zone/) + assert.equal(server.levels.main.getBlock(p.x + 1, p.y, p.z), 0) + admin.close(); guest.close() +}) + +test('bans and ranks', async (t) => { + const { server } = await startServer({ owners: ['Boss'] }) + t.after(() => server.stop()) + const boss = await join(server, 'Boss') + const bob = await join(server, 'Bob') + + await command(boss, '/rank Bob Builder') + assert.equal(server.playerDB.get('bob').rank, 'Builder') + + boss.chat('/ban Bob 1h griefing') + await bob.waitFor('disconnect') + assert.match(bob.kickReason, /Banned/) + + const again = new TestClient({ port: server.port, name: 'Bob' }) + await again.connect() + await again.waitFor('disconnect') + assert.match(again.kickReason, /Banned \(.*left\): griefing/) + + await command(boss, '/unban Bob') + const ok = await join(server, 'Bob') + ok.close(); boss.close() +}) + +test('web client over WebSocket on the same port', async (t) => { + const { server } = await startServer() + t.after(() => server.stop()) + const socket = net.connect(server.port, '127.0.0.1') + await new Promise(resolve => socket.once('connect', resolve)) + const key = crypto.randomBytes(16).toString('base64') + socket.write(`GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: ClassiCube\r\n\r\n`) + + let data = Buffer.alloc(0) + socket.on('data', d => { data = Buffer.concat([data, d]) }) + await sleep(100) + const head = data.toString('latin1') + assert.match(head, /101 Switching Protocols/) + assert.match(head, new RegExp('Sec-WebSocket-Accept: ' + crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64').replace(/[+/]/g, '\\$&'))) + data = data.subarray(data.indexOf('\r\n\r\n') + 4) + + // masked binary frame with the identification packet (no CPE) + const ident = Buffer.alloc(131) + ident[0] = 0; ident[1] = 7 + packets.writeString(ident, 2, 'WebUser'); packets.writeString(ident, 66, '') + const mask = crypto.randomBytes(4) + const masked = Buffer.from(ident.map((b, i) => b ^ mask[i & 3])) + socket.write(Buffer.concat([Buffer.from([0x82, 0x80 | 126, 0, 131]), mask, masked])) + await sleep(300) + + // first frame from the server carries the server identification + assert.equal(data[0], 0x82) + const len = data[1] & 0x7f + const payload = len === 126 ? data.subarray(4, 4 + data.readUInt16BE(2)) : data.subarray(2, 2 + len) + assert.equal(payload[0], 0x00) + assert.ok(server.findPlayerExact('WebUser')) + assert.equal(server.findPlayerExact('WebUser').conn.isWebSocket, true) + socket.destroy() +}) + +test('plugins can be unloaded and reloaded cleanly', async (t) => { + const { server } = await startServer({ owners: ['Alice'] }) + t.after(() => server.stop()) + const a = await join(server, 'Alice') + assert.ok(server.commands.find('warp')) + assert.match(await command(a, '/punload warps'), /Unloaded/) + assert.equal(server.commands.find('warp'), null) + assert.match(await command(a, '/pload warps'), /Loaded plugin warps/) + assert.ok(server.commands.find('warp')) + assert.match(await command(a, '/pcreate myplugin'), /Created plugins\/myplugin/) + assert.match(await command(a, '/pload myplugin'), /Loaded plugin myplugin/) + assert.match(await command(a, '/myplugin'), /Hello from myplugin/) + // unloading removes its event handlers too + const before = [...server.events.handlers.values()].flat().length + await command(a, '/punload myplugin') + assert.ok([...server.events.handlers.values()].flat().length < before) + a.close() +}) + +test('state survives a restart', async () => { + const first = await startServer({ owners: ['Alice'] }) + const bob = await join(first.server, 'Bob') + bob.close() + const a = await join(first.server, 'Alice') + const p = first.server.findPlayerExact('Alice').blockPos + a.send('setBlock', { x: p.x + 1, y: p.y, z: p.z, mode: 1, block: 45 }) + await sleep(100) + await command(a, '/rank Bob Builder') + await command(a, '/warp create spot') + a.close() + await first.server.stop() + + const second = await startServer({ owners: ['Alice'] }, first.root) + try { + assert.equal(second.server.levels.main.getBlock(p.x + 1, p.y, p.z), 45) + assert.equal(second.server.playerDB.get('bob').rank, 'Builder') + const b = await join(second.server, 'Alice') + assert.match(await command(b, '/warp list'), /spot/) + b.close() + } finally { + await second.server.stop() + } +}) + +test('ExtendedBlocks: ids above 255 reach capable clients, others get the fallback', async (t) => { + const { server } = await startServer({ owners: ['Alice'] }) + t.after(() => server.stop()) + const level = server.levels.main + server.setGlobalBlock({ id: 300, name: 'Big', fallback: 45 }) + level.setBlockRaw(5, 20, 5, 300) + + const ext = await join(server, 'Alice') + const noExt = await join(server, 'Bob', { + extensions: Object.entries(packets.EXTENSIONS).filter(([n]) => n !== 'ExtendedBlocks' && n !== 'ExtEntityPositions') + }) + const i = level.index(5, 20, 5) + assert.equal(ext.level.blocks[i] | (ext.level.upper[i] << 8), 300) + assert.equal(noExt.level.blocks[i], 45) + assert.ok(ext.received.some(p => p.name === 'defineBlockExt' && p.block === 300)) + assert.ok(!noExt.received.some(p => (p.name === 'defineBlockExt' || p.name === 'defineBlock') && p.block === 300)) + + // placing block 300 from the capable client (2-byte block id in SetBlock) + const pos = server.findPlayerExact('Alice').blockPos + ext.send('setBlock', { x: pos.x + 1, y: pos.y, z: pos.z, mode: 1, block: 300 }) + const seen = await noExt.waitFor(p => p.name === 'setBlock' && p.x === pos.x + 1) + assert.equal(seen.block, 45) + assert.equal(level.getBlock(pos.x + 1, pos.y, pos.z), 300) + + // bulk updates carry the high bits + ext.mark() + level.setBlocks([[1, 20, 1, 300], [2, 20, 1, 300]]) + const bulk = await ext.waitFor('bulkBlockUpdate') + assert.equal(bulk.blocks[0] | (((bulk.high[0] >> 0) & 3) << 8), 300) + + // levels bigger than 1023 need ExtEntityPositions + assert.match(await command(ext, '/newlvl huge 1100 16 16 flat', 600), /Created level huge/) + assert.match(await command(noExt, '/goto huge', 400), /too big for your client/) + ext.mark() + ext.chat('/goto huge') + const fin = await ext.waitFor('levelFinalize', 5000) + assert.equal(fin.x, 1100) + ext.close(); noExt.close() +}) + +test('SQLite storage keeps players across restarts', { skip: (() => { try { require('node:sqlite'); return false } catch (e) { return true } })() }, async () => { + const first = await startServer({ owners: ['Alice'], database: 'sqlite' }) + const bob = await join(first.server, 'Bob') + bob.close() + const a = await join(first.server, 'Alice') + await command(a, '/rank Bob AdvBuilder') + a.close() + await first.server.stop() + const second = await startServer({ database: 'sqlite' }, first.root) + try { + assert.equal(second.server.playerDB.constructor.name, 'SqlitePlayerDB') + assert.equal(second.server.playerDB.get('bob').rank, 'AdvBuilder') + assert.equal(second.server.playerDB.get('alice').logins, 1) + } finally { + await second.server.stop() + } +}) + +test('plugins can be installed from a URL and from an npm package', async (t) => { + const http = require('http') + const fs = require('fs') + const os = require('os') + const path = require('path') + const code = "module.exports = { name: 'fromweb', version: '0.1.0', load (ctx) { ctx.command({ name: 'webhello', run (p) { p.message('hello from the web') } }) } }" + const web = http.createServer((req, res) => { res.end(code) }) + await new Promise(resolve => web.listen(0, '127.0.0.1', resolve)) + const { server, root } = await startServer({ owners: ['Alice'] }) + t.after(() => { web.close(); return server.stop() }) + const a = await join(server, 'Alice') + + assert.match(await command(a, `/pinstall http://127.0.0.1:${web.address().port}/whatever.js`, 400), /Installed and loaded fromweb/) + assert.match(await command(a, '/webhello'), /hello from the web/) + assert.ok(fs.existsSync(path.join(root, 'plugins', 'fromweb.js'))) + assert.match(await command(a, '/puninstall fromweb'), /Removed fromweb/) + assert.equal(server.commands.find('webhello'), null) + assert.ok(!fs.existsSync(path.join(root, 'plugins', 'fromweb.js'))) + + // a local npm package (file: spec works offline) + const pkg = fs.mkdtempSync(path.join(os.tmpdir(), 'mcscript-pkg-')) + fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: 'mcscript-plugin-demo', version: '1.2.3', main: 'main.js' })) + fs.writeFileSync(path.join(pkg, 'main.js'), "module.exports = { name: 'demo', version: '1.2.3', load (ctx) { ctx.command({ name: 'demohi', run (p) { p.message('npm plugin works') } }) } }") + a.mark() + a.chat(`/pinstall npm:file:${pkg}`) + await a.waitForMessage(/Installed and loaded demo v1.2.3/, 60000) + assert.match(await command(a, '/demohi'), /npm plugin works/) + assert.ok(fs.existsSync(path.join(root, 'plugins', 'demo', 'node_modules', 'mcscript-plugin-demo'))) + a.close() +}) + +test('physics: sand falls, water flows, sponges and water + lava', async (t) => { + const { server } = await startServer({ owners: ['Alice'] }) + t.after(() => server.stop()) + const level = server.levels.main // flat: ground at y 0..15, air above + const set = (x, y, z, b) => { level.setBlock(x, y, z, b); server.plugins.get('physics').module.api.schedule(level, x, y, z) } + + set(10, 25, 10, 12) // sand in the air + await sleep(400) + assert.equal(level.getBlock(10, 25, 10), 0) + assert.equal(level.getBlock(10, 16, 10), 12) + + set(20, 16, 20, 8) // active water on the ground + await sleep(700) + assert.equal(level.getBlock(21, 16, 20), 8) + assert.equal(level.getBlock(19, 16, 20), 8) + + set(40, 16, 40, 19) // a sponge stops water nearby + set(43, 16, 40, 8) + await sleep(900) + assert.notEqual(level.getBlock(42, 16, 40), 8) + + set(30, 16, 30, 10) // lava next to flowing water turns into stone + set(32, 16, 30, 8) + await sleep(1500) + assert.ok(level.getBlock(30, 16, 30) === 1 || level.getBlock(31, 16, 30) === 1) + + // /physics 0 stops everything + const a = await join(server, 'Alice') + assert.match(await command(a, '/physics 0'), /Physics in main set to &f0/) + set(50, 25, 50, 12) + await sleep(300) + assert.equal(level.getBlock(50, 25, 50), 12) + a.close() +}) + +test('block history is kept on disk: /about and /undoplayer work after a restart', async () => { + const first = await startServer({ owners: ['Admin'] }) + let p, before + try { + const g = await join(first.server, 'Griefer') + p = first.server.findPlayerExact('Griefer').blockPos + const level = first.server.levels.main + before = level.getBlock(p.x + 1, p.y - 1, p.z) + g.send('setBlock', { x: p.x + 1, y: p.y - 1, z: p.z, mode: 0, block: 0 }) + g.send('setBlock', { x: p.x + 1, y: p.y, z: p.z, mode: 1, block: 45 }) + await sleep(200) + assert.equal(level.getBlock(p.x + 1, p.y, p.z), 45) + g.close() + } finally { + await first.server.stop() + } + + const second = await startServer({ owners: ['Admin'] }, first.root) + try { + const lvl = second.server.levels.main + const admin = await join(second.server, 'Admin') + admin.chat('/about') + await admin.waitForMessage(/mark the position/) + admin.send('setBlock', { x: p.x + 1, y: p.y, z: p.z, mode: 0, block: 0 }) + await admin.waitForMessage(/Last changed by &fGriefer/) + assert.match(await command(admin, '/undoplayer Griefer 1h', 300), /Undid &f2&e block changes by Griefer/) + assert.equal(lvl.getBlock(p.x + 1, p.y, p.z), 0) + assert.equal(lvl.getBlock(p.x + 1, p.y - 1, p.z), before) + admin.close() + } finally { + await second.server.stop() + } +}) + +test('economy: pay, shop ranks and personal levels', async (t) => { + const { server } = await startServer({ owners: ['Boss'] }) + t.after(() => server.stop()) + const boss = await join(server, 'Boss') + const ann = await join(server, 'Ann') + const eco = server.plugins.get('economy').module.api + + assert.match(await command(ann, '/money'), /Ann has &a50 coins/) + assert.match(await command(ann, '/pay Boss 20'), /You paid &a20 coins/) + assert.equal(eco.balance('boss'), 70) + assert.match(await command(ann, '/pay Boss 1000'), /You only have/) + assert.match(await command(ann, '/buy rank Builder'), /That costs 200 coins/) + + await command(boss, '/eco give Ann 2000') + assert.match(await command(ann, '/buy rank Builder', 300), /bought the .*Builder/) + assert.equal(server.playerDB.get('ann').rank, 'Builder') + + ann.mark() + ann.chat('/buy level') + await ann.waitForMessage(/You bought the level/, 5000) + const level = server.levels.get('Ann') + assert.deepEqual(level.owners, ['ann']) + // only the owner builds there + assert.equal(level.canBuild(server.findPlayerExact('Ann')), true) + const other = await join(server, 'Visitor') + assert.equal(level.canBuild(server.findPlayerExact('Visitor')), false) + other.close(); ann.close(); boss.close() +}) + +test('web panel: token auth, status, players and commands', async (t) => { + const { server } = await startServer({ owners: ['Alice'] }, null, { 'web-panel': { enabled: true, host: '127.0.0.1', port: 0, token: 'secret-token' } }) + t.after(() => server.stop()) + const api = server.plugins.get('web-panel').module.api + for (let i = 0; i < 20 && !api.port; i++) await sleep(50) + const base = `http://127.0.0.1:${api.port}` + const a = await join(server, 'Alice') + + const page = await fetch(base + '/') + assert.equal(page.status, 200) + assert.match(await page.text(), /MCScript · Server panel/) + assert.equal((await fetch(base + '/api/status')).status, 401) + assert.equal((await fetch(base + '/api/status', { headers: { Authorization: 'Bearer wrong-token!' } })).status, 401) + + const auth = { Authorization: 'Bearer secret-token', 'Content-Type': 'application/json' } + const status = await (await fetch(base + '/api/status', { headers: auth })).json() + assert.equal(status.players, 1) + const players = await (await fetch(base + '/api/players', { headers: auth })).json() + assert.equal(players[0].name, 'Alice') + const out = await (await fetch(base + '/api/command', { method: 'POST', headers: auth, body: JSON.stringify({ command: '/levels' }) })).json() + assert.match(out.output.join('\n'), /main/) + await fetch(base + '/api/command', { method: 'POST', headers: auth, body: JSON.stringify({ command: 'hello from the panel' }) }) + await a.waitForMessage(/hello from the panel/) + const logs = await (await fetch(base + '/api/logs', { headers: auth })).json() + assert.ok(logs.some(l => /Alice/.test(l.message))) + a.close() +}) + +const moveTo = (client, x, y, z) => client.send('position', { heldBlock: 1, x: Math.round(x * 32), y: Math.round(y * 32) + 51, z: Math.round(z * 32), yaw: 0, pitch: 0 }) + +test('minigames: zombie survival infection and parkour timing', async (t) => { + const { server } = await startServer({ owners: ['Op'] }) + t.after(() => server.stop()) + const op = await join(server, 'Op') + const guest = await join(server, 'Guest') + + // Zombie Survival + assert.match(await command(op, '/zombie enable'), /is now a Zombie Survival arena/) + await command(op, '/zombie join') + await command(guest, '/zombie join') + op.mark(); guest.mark() + op.chat('/zombie start') + await op.waitForMessage(/has started/) + const round = server.plugins.get('minigames').module.api.games.zombie.rounds.get('main') + const [zombie] = round.firstZombies() + const human = zombie.name === 'Op' ? server.findPlayerExact('Guest') : server.findPlayerExact('Op') + const zClient = zombie.name === 'Op' ? op : guest + const hClient = zombie.name === 'Op' ? guest : op + assert.equal(human.data.team, 'human') + moveTo(zClient, 10, 17, 10) + moveTo(hClient, 10.5, 17, 10.3) + await hClient.waitForMessage(/infected everyone/, 3000) + assert.equal(round.state, 'waiting') + assert.equal(human.currentModel, 'humanoid') + assert.equal(human.record.model, null) + + // Parkour: start at (20,15,20), finish at (30,15,20): the blocks you stand on + const setBlockMark = async (client, cmd, x, y, z) => { + client.mark(); client.chat(cmd) + await client.waitForMessage(/Place or break/) + client.send('setBlock', { x, y, z, mode: 1, block: 1 }) + await client.waitForMessage(/set at|added/) + } + moveTo(op, 22, 17, 20) + await sleep(100) + await setBlockMark(op, '/parkour setstart', 20, 15, 20) + moveTo(op, 28, 17, 20) + await sleep(100) + await setBlockMark(op, '/parkour setfinish', 30, 15, 20) + moveTo(guest, 20.5, 16, 20.5) + await guest.waitForMessage(/Parkour started/) + await sleep(300) + moveTo(guest, 30.5, 16, 20.5) + await guest.waitForMessage(/new parkour record|Finished in/) + assert.match(await command(guest, '/parkour top'), /1\. Guest/) + op.close(); guest.close() +}) + +test('minigames: TNT Wars explosions score points', async (t) => { + const { server } = await startServer({ owners: ['Op'] }) + t.after(() => server.stop()) + const op = await join(server, 'Op') + const guest = await join(server, 'Guest') + await command(op, '/tntwars enable') + moveTo(op, 10.5, 16, 10.5); await sleep(100) + await command(op, '/tntwars setspawn red') + moveTo(op, 12.5, 16, 10.5); await sleep(100) + await command(op, '/tntwars setspawn blue') + await command(op, '/tntwars join') + await command(guest, '/tntwars join') + op.mark() + op.chat('/tntwars start') + await op.waitForMessage(/has started/) + const p1 = server.findPlayerExact('Op'); const p2 = server.findPlayerExact('Guest') + assert.notEqual(p1.data.team, p2.data.team) + // stand next to each other and Op drops TNT between them (right click on the ground) + moveTo(op, 20.5, 16, 20.5) + moveTo(guest, 22.5, 16, 20.5) + await sleep(150) + op.send('playerClicked', { button: 1, action: 0, yaw: 0, pitch: 0, targetEntity: -1, x: 21, y: 15, z: 20, face: 3 }) + await op.waitFor(p => p.name === 'setBlock' && p.x === 21 && p.y === 16 && p.block === 46) + await guest.waitForMessage(/Op&e blew up/, 4000) + assert.equal(server.levels.main.getBlock(21, 16, 20), 0) + // the arena is restored when the round ends + await command(op, '/tntwars stop', 400) + assert.equal(server.levels.main.getBlock(21, 15, 20), 2) + op.close(); guest.close() +}) + +test('minigames: capture the flag', async (t) => { + const { server } = await startServer({ owners: ['Op'] }, null, { minigames: { ctf: { captures: 1, timeLimitMinutes: 5, tagDistance: 4 } } }) + t.after(() => server.stop()) + const op = await join(server, 'Op') + const guest = await join(server, 'Guest') + await command(op, '/ctf enable') + const flag = async (team, x, z) => { + moveTo(op, x + 2.5, 16, z + 0.5); await sleep(100) + op.mark(); op.chat(`/ctf setflag ${team}`) + await op.waitForMessage(/Place or break/) + op.send('setBlock', { x, y: 16, z, mode: 1, block: 1 }) + await op.waitForMessage(/flag set/) + } + await flag('red', 10, 10) + await flag('blue', 40, 40) + moveTo(op, 12.5, 16, 12.5); await sleep(100); await command(op, '/ctf setspawn red') + moveTo(op, 38.5, 16, 38.5); await sleep(100); await command(op, '/ctf setspawn blue') + await command(op, '/ctf join') + await command(guest, '/ctf join') + op.mark(); op.chat('/ctf start') + await op.waitForMessage(/has started/) + const me = server.findPlayerExact('Op') + const [enemyFlag, ownFlag] = me.data.team === 'red' ? [[40, 40], [10, 10]] : [[10, 10], [40, 40]] + moveTo(op, enemyFlag[0] + 0.5, 16, enemyFlag[1] + 0.5) + await op.waitForMessage(/took the/) + assert.equal(server.levels.main.getBlock(enemyFlag[0], 16, enemyFlag[1]), 0) + moveTo(op, ownFlag[0] + 0.5, 16, ownFlag[1] + 0.5) + await op.waitForMessage(/team wins 1-0|team wins 0-1/, 3000) + op.close(); guest.close() +}) + +test('CPE extras: custom models, particles, cinematic GUI, plugin messages and notify actions', async (t) => { + const { server } = await startServer({ owners: ['Alice'] }) + t.after(() => server.stop()) + const a = await join(server, 'Alice') + // models are defined before the level is sent + const def = a.received.find(p => p.name === 'defineModel' && p.modelName === 'bighead') + assert.ok(def) + assert.equal(a.received.filter(p => p.name === 'defineModelPart' && p.modelId === def.modelId).length, 6) + assert.ok(a.received.indexOf(def) < a.received.findIndex(p => p.name === 'levelFinalize')) + + a.mark() + a.chat('/model spinner') + const cm = await a.waitFor('changeModel') + assert.equal(cm.model, 'spinner') + + a.mark() + a.chat('/effect hearts') + const fx = await a.waitFor('defineEffect') + const spawn = await a.waitFor('spawnEffect') + assert.equal(spawn.effectId, fx.effectId) + assert.deepEqual([fx.r, fx.g, fx.b], [255, 80, 140]) + + a.mark() + a.chat('/cinematic on') + const cg = await a.waitFor('cinematicGui') + assert.equal(cg.hideHotbar, 1) + assert.ok(cg.barSize > 0) + + const gotMsg = new Promise(resolve => server.events.once('pluginMessage', resolve)) + const payload = Buffer.alloc(64); payload.write('hello plugin') + a.send('pluginMessage', { channel: 7, data: payload }) + const pm = await gotMsg + assert.equal(pm.channel, 7) + assert.match(pm.data.toString(), /hello plugin/) + + const gotAction = new Promise(resolve => server.events.once('notifyAction', resolve)) + a.send('notifyAction', { action: 7, value: 1 }) + assert.equal((await gotAction).action, 'thirdPersonChanged') + a.close() +}) + +test('/write draws text with blocks', async (t) => { + const { server } = await startServer({ owners: ['Alice'] }) + t.after(() => server.stop()) + const a = await join(server, 'Alice') + const p = server.findPlayerExact('Alice').blockPos + a.chat('/write stone 2 OK') + await a.waitForMessage(/mark the corners/) + a.send('setBlock', { x: p.x + 1, y: p.y, z: p.z - 2, mode: 1, block: 1 }) + a.send('setBlock', { x: p.x + 3, y: p.y, z: p.z - 2, mode: 1, block: 1 }) + await a.waitForMessage(/Write \(22 blocks long\)/) + const level = server.levels.main + // bottom-left pixel of "O" is empty, the one next to it is filled (scale 2) + assert.equal(level.getBlock(p.x + 1, p.y, p.z - 2), 0) + assert.equal(level.getBlock(p.x + 3, p.y, p.z - 2), 1) + assert.equal(level.getBlock(p.x + 1, p.y + 2, p.z - 2), 1) + a.close() +}) + +test('MCGalaxy style commands: chat, info and teleports', async (t) => { + const { server } = await startServer({ owners: ['Op'] }) + t.after(() => server.stop()) + const op = await join(server, 'Op') + const bob = await join(server, 'Bob') + + // emotes and staff chat + bob.chat('I (heart) this') + await op.waitForMessage(/I ♥ this/) + op.mark(); bob.mark() + op.chat('#only staff') + await op.waitForMessage(/\[Op\].*only staff/) + await sleep(150) + assert.ok(!bob.received.slice(bob.cursor).some(p => p.name === 'message' && /only staff/.test(p.message))) + + // mail + await command(op, '/send Bob check the spawn') + assert.match(await command(bob, '/inbox'), /Op.*check the spawn/) + + // /back after a teleport + moveTo(op, 10.5, 16, 10.5); await sleep(100) + await command(op, '/tp 30 16 30') + await command(op, '/back', 200) + assert.equal(server.findPlayerExact('Op').blockPos.x, 10) + + // tpa + await command(bob, '/tpa Op') + await command(op, '/tpa accept', 200) + assert.equal(server.findPlayerExact('Bob').blockPos.x, server.findPlayerExact('Op').blockPos.x) + + assert.match(await command(op, '/roll 5 5'), /rolled &f5/) + assert.match(await command(op, '/top logins'), /1\. /) + assert.match(await command(op, '/search levels mai'), /main/) + assert.match(await command(op, '/calculate 2^8'), /= &f256/) + assert.match(await command(op, '/plugin list'), /Plugins/) + assert.match(await command(op, '/give Bob 100'), /Bob now has &a150/) + + // moderation + await command(op, '/warn Bob spam') + await command(op, '/warn Bob spam') + op.chat('/warn Bob spam') + await bob.waitFor('disconnect') + assert.match(bob.kickReason, /Warned 3 times/) + assert.match(await command(op, '/report Bob griefing'), /Thanks/) + assert.match(await command(op, '/report list'), /Bob.*griefing/) + await command(op, '/temprank Bob Builder 1h') + assert.equal(server.playerDB.get('bob').rank, 'Builder') + assert.equal(server.playerDB.get('bob').tempRank.previous, 'Guest') + assert.match(await command(op, '/rankinfo Bob'), /Guest &7-> &fBuilder/) + await command(op, '/whitelist on') + const stranger = new TestClient({ port: server.port, name: 'Stranger' }) + await stranger.connect() + await stranger.waitFor('disconnect') + assert.match(stranger.kickReason, /whitelist/) + await command(op, '/whitelist off') + op.close() +}) + +test('MCGalaxy style commands: levels and drawing', async (t) => { + const { server } = await startServer({ owners: ['Op'] }) + t.after(() => server.stop()) + const op = await join(server, 'Op') + const level = server.levels.main + const mark = async (cmd, ...points) => { + op.mark(); op.chat(cmd) + await op.waitForMessage(/Place or break/) + for (const [x, y, z] of points) op.send('setBlock', { x, y, z, mode: 1, block: 1 }) + } + moveTo(op, 20.5, 16, 20.5); await sleep(100) + + await mark('/pyramid stone', [16, 16, 16], [20, 16, 20]) + await op.waitForMessage(/Pyramid: changed/) + assert.equal(level.getBlock(18, 18, 18), 1) + assert.equal(level.getBlock(16, 17, 16), 0) + + await mark('/hollow', [10, 5, 10], [14, 9, 14]) + await op.waitForMessage(/Hollow: changed/) + assert.equal(level.getBlock(12, 7, 12), 0) + + await mark('/spheroid glass', [22, 16, 22], [26, 20, 26]) + await op.waitForMessage(/Spheroid: changed/) + assert.equal(level.getBlock(24, 18, 24), 20) + + await mark('/maze stone 2', [30, 16, 10], [40, 16, 20]) + await op.waitForMessage(/Maze: changed/) + + // /mark from a command + op.mark(); op.chat('/center') + await op.waitForMessage(/Place or break/) + await command(op, '/mark 0 20 0') + await command(op, '/mark 4 20 4', 300) + assert.equal(level.getBlock(2, 20, 2), 41) + + // mode: everything placed becomes brick + await command(op, '/mode brick') + op.send('setBlock', { x: 21, y: 16, z: 21, mode: 1, block: 1 }) + await sleep(150) + assert.equal(level.getBlock(21, 16, 21), 45) + await command(op, '/mode') + + // level maintenance + level.setBlock(5, 16, 5, 8) + assert.match(await command(op, '/unflood'), /Removed 1 flowing/) + assert.match(await command(op, '/copylvl main main2', 300), /Copied/) + assert.ok(server.levels.exists('main2')) + await command(op, '/renamelvl main2 other', 300) + assert.ok(server.levels.exists('other') && !server.levels.exists('main2')) + await command(op, '/lockdown other') + await command(op, '/newlvl small 32 32 32 flat', 400) + op.mark(); op.chat('/goto small') + await op.waitFor('levelFinalize') + await command(op, '/resizelvl 48 32 40', 600) + assert.equal(server.levels.get('small').width, 48) + const fin = op.received.filter(p => p.name === 'levelFinalize').pop() + assert.equal(fin.x, 48) + op.close() +}) + +test('connection limits and malformed input', async (t) => { + const { server } = await startServer({ maxConnectionsPerIp: 2 }) + t.after(() => server.stop()) + + // reserved names are refused + const bad = new TestClient({ port: server.port, name: '__proto__' }) + await bad.connect() + const kicked = await bad.waitFor('disconnect') + assert.match(kicked.reason, /Invalid username/) + bad.close() + await sleep(100) + + // a third connection from the same address is dropped straight away + const sockets = [] + for (let i = 0; i < 2; i++) { + const s = net.connect(server.port, '127.0.0.1') + await new Promise(resolve => s.once('connect', resolve)) + sockets.push(s) + } + const third = net.connect(server.port, '127.0.0.1') + await new Promise(resolve => third.once('close', resolve)) + sockets.forEach(s => s.destroy()) + await sleep(100) + + // unmasked WebSocket frames are rejected (RFC 6455) + const ws = net.connect(server.port, '127.0.0.1') + ws.resume() // keep reading, otherwise the close from the server is never noticed + await new Promise(resolve => ws.once('connect', resolve)) + ws.write('GET / HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n') + await sleep(100) + ws.write(Buffer.from([0x82, 0x02, 0x00, 0x07])) + await new Promise(resolve => ws.once('close', resolve)) + assert.equal(server.players.length, 0) +}) + +test('plugins can be turned off and back on, and it survives a restart', async (t) => { + const { server, root } = await startServer({ owners: ['Boss'] }) + let current = server + t.after(() => current.stop()) + const boss = await join(server, 'Boss') + + assert.match(await command(boss, '/pdisable warps'), /turned off/) + assert.equal(server.plugins.get('warps'), null) + assert.match(await command(boss, '/warp list'), /Unknown command/) + assert.deepEqual(server.config.disabledPlugins, ['warps']) + assert.match(await command(boss, '/plugins'), /&8warps/) + assert.match(await command(boss, '/pdisable nosuchplugin'), /not found/) + boss.close() + await server.stop() + + // the setting was written to config/server.json + current = (await startServer({}, root)).server + assert.equal(current.plugins.get('warps'), null) + current.plugins.enable('warps') + assert.ok(current.plugins.get('warps')) + assert.deepEqual(current.config.disabledPlugins, []) +}) diff --git a/test/unit.test.js b/test/unit.test.js new file mode 100644 index 0000000..00bca98 --- /dev/null +++ b/test/unit.test.js @@ -0,0 +1,267 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const cp437 = require('../lib/protocol/cp437') +const packets = require('../lib/protocol/packets') +const text = require('../lib/util/text') +const nbt = require('../lib/util/nbt') +const Blocks = require('../lib/blocks') +const { EventBus } = require('../lib/events') +const { Ranks } = require('../lib/ranks') + +test('cp437 table has 256 entries and round-trips', () => { + assert.equal(cp437.LOW.length, 32) + assert.equal(cp437.HIGH.length, 128) + const s = 'naïve café ☺ ░▒▓ ²' + assert.equal(cp437.decode(cp437.encode(s)), s) + // without FullCP437 only ASCII is sent + assert.equal(cp437.decode(cp437.encode('café', false)), 'caf?') +}) + +// Packet sizes as read by the ClassiCube client (src/Protocol.c) for the versions we advertise +test('server packet sizes match the ClassiCube client', () => { + const expected = { + serverIdentification: 131, + ping: 1, + levelInitialize: 1, + levelInitializeFast: 5, + levelDataChunk: 1028, + levelFinalize: 7, + setBlock: 8, + spawnPlayer: 74, + teleport: 10, + posAndOrientUpdate: 7, + posUpdate: 5, + orientUpdate: 4, + despawnPlayer: 2, + message: 66, + disconnect: 65, + updateUserType: 2, + extInfo: 67, + extEntry: 69, + setClickDistance: 3, + customBlockSupportLevel: 2, + holdThis: 3, + setTextHotKey: 134, + extAddPlayerName: 196, + extRemovePlayerName: 3, + envSetColor: 8, + makeSelection: 86, + removeSelection: 2, + setBlockPermission: 4, + changeModel: 66, + envSetWeatherType: 2, + hackControl: 8, + extAddEntity2: 138, + defineBlock: 80, + removeBlockDefinition: 2, + defineBlockExt: 88, + bulkBlockUpdate: 1282, + setTextColor: 6, + setMapEnvUrl: 65, + setMapEnvProperty: 6, + setEntityProperty: 7, + twoWayPing: 4, + setInventoryOrder: 3, + setHotbar: 3, + setSpawnpoint: 9, + velocityControl: 16, + lightingMode: 3, + defineEffect: 36, + spawnEffect: 26, + defineModel: 116, + defineModelPart: 167, + undefineModel: 2, + pluginMessage: 66, + cinematicGui: 10, + toggleBlockList: 2 + } + for (const [name, size] of Object.entries(expected)) { + assert.equal(packets.SERVER[name].size, size, name) + assert.equal(packets.encode(name, {}).length, size, name) + } + assert.deepEqual(Object.keys(expected).sort(), Object.keys(packets.SERVER).sort()) +}) + +// With ExtendedBlocks / ExtEntityPositions the ClassiCube client grows these packets (Protocol.c, CPE_ExtEntry) +test('packet sizes change with ExtendedBlocks and ExtEntityPositions', () => { + const eb = { extBlocks: true } + const grow = { setBlock: 1, holdThis: 1, setBlockPermission: 1, defineBlock: 1, removeBlockDefinition: 1, defineBlockExt: 1, setInventoryOrder: 2, bulkBlockUpdate: 64, setHotbar: 1 } + for (const [name, extra] of Object.entries(grow)) { + assert.equal(packets.sizeOf(packets.SERVER[name], eb), packets.SERVER[name].size + extra, name) + } + const ep = { extPos: true } + for (const name of ['teleport', 'spawnPlayer', 'extAddEntity2', 'setSpawnpoint']) { + assert.equal(packets.sizeOf(packets.SERVER[name], ep), packets.SERVER[name].size + 6, name) + } + // client -> server + assert.equal(packets.sizeOf(packets.CLIENT[0x05], eb), 10) + assert.equal(packets.sizeOf(packets.CLIENT[0x08], { extBlocks: true, extPos: true }), 17) + const buf = packets.encode('setBlock', { x: 1, y: 2, z: 3, block: 700 }, eb) + assert.equal(buf.readUInt16BE(7), 700) +}) + +test('client packet sizes', () => { + const expected = { 0x00: 131, 0x05: 9, 0x08: 10, 0x0d: 66, 0x10: 67, 0x11: 69, 0x13: 2, 0x22: 15, 0x2b: 4, 0x35: 66, 0x39: 5, 0x3a: 9 } + for (const [id, size] of Object.entries(expected)) assert.equal(packets.CLIENT[id].size, size) +}) + +test('packet encode/decode', () => { + const buf = packets.encode('message', { type: -1, message: 'hi' }) + assert.equal(buf[0], 0x0d) + assert.equal(buf.readInt8(1), -1) + assert.equal(packets.readString(buf, 2), 'hi') + const id = Buffer.alloc(131) + id[0] = 0; id[1] = 7 + packets.writeString(id, 2, 'Steve'); packets.writeString(id, 66, 'abc'); id[130] = 0x42 + const p = packets.decode(0, id) + assert.deepEqual(p, { name: 'identification', protocolVersion: 7, username: 'Steve', key: 'abc', padding: 0x42 }) +}) + +test('text wrapping keeps colors and never ends with a color code', () => { + const lines = text.wrap('&aThis is a very long message that should be split into more than one line of chat text &cred', 64) + assert.ok(lines.length >= 2) + for (const l of lines) { + assert.ok(l.length <= 64) + assert.ok(!/&.?$/.test(l)) + } + assert.ok(lines[1].startsWith('> &a')) + assert.equal(text.sanitize('bad & trailing &'), 'bad trailing ') + assert.equal(text.convertPercentCodes('%chello %zworld'), '&chello %zworld') +}) + +test('durations', () => { + assert.equal(text.parseDuration('10m'), 600000) + assert.equal(text.parseDuration('1d12h'), 129600000) + assert.equal(text.parseDuration('90'), 90000) + assert.equal(text.parseDuration('spam'), null) + assert.equal(text.formatDuration(3723000), '1h 2m 3s') +}) + +test('nbt round trip', () => { + const { t } = nbt + const data = { A: t.byte(-3), B: t.short(300), C: t.int(-70000), D: t.long(123456789012), E: t.float(1.5), F: t.string('héllo'), G: t.bytes(Buffer.from([1, 2, 3])), H: t.compound({ X: t.short(1) }) } + const out = nbt.read(nbt.write('Root', data)) + assert.equal(out.name, 'Root') + const s = nbt.simplify(out.value) + assert.equal(s.A, -3); assert.equal(s.B, 300); assert.equal(s.C, -70000); assert.equal(s.D, 123456789012n) + assert.equal(s.E, 1.5); assert.equal(s.F, 'héllo'); assert.deepEqual([...s.G], [1, 2, 3]); assert.equal(s.H.X, 1) +}) + +test('block parsing and definitions', () => { + assert.equal(Blocks.parse('stone'), 1) + assert.equal(Blocks.parse('Still Water'), 9) + assert.equal(Blocks.parse('65'), 65) + assert.equal(Blocks.parse('70'), null) + assert.equal(Blocks.parse('70', id => id === 70 ? { name: 'Lamp' } : null), 70) + assert.equal(Blocks.parse('lamp', id => id === 70 ? { name: 'Lamp' } : null), 70) + const [name, data] = Blocks.definitionPacket({ id: 70, name: 'Lamp', speed: 1 }, true) + assert.equal(name, 'defineBlockExt') + assert.equal(data.speed, 128) + assert.equal(packets.encode(name, data).length, 88) + const [spriteName] = Blocks.definitionPacket({ ...Blocks.coreDefinition(37), id: 90 }, true) + assert.equal(spriteName, 'defineBlock') +}) + +test('event bus priorities and cancelling', () => { + const bus = new EventBus(null) + const order = [] + bus.on('x', () => order.push('low'), { priority: 'low' }) + bus.on('x', (ev) => { order.push('high'); ev.cancel('no') }, { priority: 'high' }) + bus.on('x', () => order.push('normal')) + bus.on('x', (ev) => order.push('monitor:' + ev.cancelled), { priority: 'monitor', owner: 'p' }) + const ev = bus.fire('x', {}) + assert.deepEqual(order, ['high', 'monitor:true']) + assert.equal(ev.cancelReason, 'no') + bus.removeOwner('p') + order.length = 0 + bus.fire('x', {}) + assert.deepEqual(order, ['high']) +}) + +test('ranks', () => { + const ranks = new Ranks(null, 'Guest') + assert.equal(ranks.default.name, 'Guest') + assert.equal(ranks.get('operator').permission, 80) + assert.equal(ranks.get(85).name, 'Operator') + assert.equal(ranks.permissionOf(null), 0) + assert.equal(ranks.next('Guest').name, 'Builder') + assert.equal(ranks.highest.name, 'Owner') +}) + +test('SQLite player database', { skip: (() => { try { require('node:sqlite'); return false } catch (e) { return true } })() }, () => { + const fs = require('fs'); const os = require('os'); const path = require('path') + const { SqlitePlayerDB } = require('../lib/storage/player-db') + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcscript-sqlite-')) + fs.writeFileSync(path.join(dir, 'players.json'), JSON.stringify({ old: { name: 'Old', rank: 'Builder', ips: [] } })) + let db = new SqlitePlayerDB(path.join(dir, 'players.db'), { importFrom: path.join(dir, 'players.json') }) + assert.equal(db.get('OLD').rank, 'Builder') // imported from players.json + const r = db.getOrCreate('Steve_1') + r.blocksPlaced = 42 + r.ips.push('1.2.3.4') + assert.equal(db.find('stev').name, 'Steve_1') + assert.equal(db.find('st%'), null) + db.close() + db = new SqlitePlayerDB(path.join(dir, 'players.db')) + assert.equal(db.get('steve_1').blocksPlaced, 42) + assert.deepEqual(db.get('steve_1').ips, ['1.2.3.4']) + assert.equal(db.all().length, 2) + db.close() + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('/calculate expression evaluator', () => { + const { evaluate } = require('../plugins/core-building/more') + assert.equal(evaluate('12*(3+4)/2'), 42) + assert.equal(evaluate('2^10'), 1024) + assert.equal(evaluate('-3 + 5 % 3'), -1) + assert.equal(Math.round(evaluate('sqrt(2)*sqrt(2)')), 2) + assert.throws(() => evaluate('process.exit()')) + assert.throws(() => evaluate('1/0')) +}) + +test('async event handler errors are logged instead of crashing', async () => { + const errors = [] + const bus = new EventBus({ error: (...args) => errors.push(args.join(' ')) }) + bus.on('x', async () => { throw new Error('boom') }, { owner: 'p' }) + bus.fire('x', {}) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(errors.length, 1) + assert.match(errors[0], /plugin p/) +}) + +test('player database treats prototype names as plain keys', () => { + const fs = require('fs') + const os = require('os') + const path = require('path') + const { PlayerDB } = require('../lib/storage/player-db') + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcscript-db-')) + const db = new PlayerDB(path.join(dir, 'players.json')) + assert.equal(db.get('constructor'), null) + const rec = db.getOrCreate('__proto__') + rec.logins++ + assert.equal({}.logins, undefined) + assert.equal(db.get('__proto__'), rec) + db.flush() + assert.ok(Object.keys(JSON.parse(fs.readFileSync(path.join(dir, 'players.json'), 'utf8'))).includes('__proto__')) + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('plugin installer rejects unsafe input', async () => { + const installer = require('../lib/plugins/installer') + await assert.rejects(installer.installFromUrl('/nonexistent', 'http://example.com/p.js'), /https/) + await assert.rejects(installer.installFromNpm('/nonexistent', 'pkg && calc'), /Invalid package/) + await assert.rejects(installer.installFromNpm('/nonexistent', '--registry=https://evil'), /Invalid package/) + assert.throws(() => installer.uninstall('/nonexistent', '../../etc'), /Invalid plugin name/) +}) + +test('version comparison for the update check', () => { + const { isNewer } = require('../lib/update-check') + assert.equal(isNewer('2.1.0', '2.0.9'), true) + assert.equal(isNewer('v2.0.0', '2.0.0'), false) + assert.equal(isNewer('2.0.0', '2.0.0-beta.2'), true) + assert.equal(isNewer('2.0.0-beta.2', '2.0.0'), false) + assert.equal(isNewer('1.9.9', '2.0.0'), false) +})