diff --git a/.gitignore b/.gitignore index c6a9f9e..6b10c63 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ config/ data/ logs/ levels/ +texpacks/ plugins/.removed/ # editors and OS files diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d91ec..9eafd07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +### Added +- MCGalaxy's command shortcuts and aliases (`lib/commands/shortcuts.js`), including ones that carry arguments, + like `/zadd` for `/zone add` or `/cw` for `/cuboid wire`. `/v` is now `/paste` and `/pl` is `/place`, as in + MCGalaxy (`/vanish` keeps `/hide`, `/plugins` keeps `/plist`). +- `/main ` sets the main level; `/autoload` and `autoloadLevels` load more levels at startup. +- New commands from MCGalaxy: `/vote` (`/yes`, `/no`), `/timer`, `/pronouns` (shown in `/whois`), `/quit`, + `/ragequit`, `/alts`, `/like` and `/dislike` (shown in `/mapinfo`), `/replacenot`, `/triangle`, `/delete` + and `/static`. +- `biomes` generator: plains, forests, deserts and snowy mountains with rivers, caves and ores. +- Texture packs in `texpacks/` are served over HTTP on the game port; `/texture mypack.zip` uses them. + `publicAddress` sets the address players' clients download from. +- Pterodactyl: the port is read from `SERVER_PORT`, the allocation address is used for texture packs, and + `/restart` lets the panel start the server again. Setup steps in the README. + ## 2.1.0 (2026-10-05) ### Added diff --git a/README.md b/README.md index 6d84f9a..15366e1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +

MCScript, a ClassiCube server written in JavaScript

+ # MCScript [![CI](https://github.com/perronosaurio/MCScript/actions/workflows/main.yml/badge.svg)](https://github.com/perronosaurio/MCScript/actions/workflows/main.yml) @@ -17,7 +19,7 @@ classicube.net and BetaCraft. - [Installing](#installing) - [Your first server](#your-first-server) - [Letting other people join](#letting-other-people-join) -- [Keeping it running](#keeping-it-running) +- [Keeping it running](#keeping-it-running) (including [Pterodactyl](#pterodactyl)) - [Updating](#updating) - [Features](#features) - [Bundled plugins](#bundled-plugins) @@ -123,6 +125,25 @@ pm2 save && pm2 startup The console isn't available as a service. Use the web panel plugin or join the game to run commands. +### Pterodactyl + +MCScript runs on Pterodactyl panels (and others like it) with a Node.js egg, such as the generic Node.js egg from +[parkervcp/eggs](https://github.com/parkervcp/eggs). + +1. **Create the server** with the Node.js egg. In **Startup**, pick a Docker image with Node.js 22 or newer + (for example `nodejs_22` or `nodejs_24`) and set the main file to `index.js`. +2. **Upload the server.** In **Files**, upload the release zip, right click it and choose **Unarchive**, then move + the contents of the `MCScript-...` folder to the top folder, so `index.js` and `package.json` sit next to + each other at `/home/container`. +3. **Start it.** The port comes from the panel's allocation (`SERVER_PORT`); you don't need to set it. The panel + console works like the normal console: type `/rank Name Owner` or any other command there. +4. Edit `config/server.json` from **Files** to set the name, MOTD and `owners`, and restart from the panel. + +`/restart` exits and lets the panel start the server again. The panel restarts crashed servers by default; if +yours doesn't, use the panel's Restart button. Texture packs in `texpacks/` use the allocation's address +automatically; if the panel shows the allocation as `0.0.0.0`, set `publicAddress` in `config/server.json` to +the address players connect to (for example `play.example.com:25565`). + ### Backups Levels are backed up automatically to `levels/backups/`. `/restore` brings one back in-game. For a complete @@ -156,13 +177,21 @@ Protocol - Clients without an extension get sensible fallbacks, for example a replacement block for custom blocks. Levels -- Several loaded at once. Generators: `flat`, `empty`, `pixel`, `space`, `ocean`, `island`, `terrain` (seeded). +- Several loaded at once. Generators: `flat`, `empty`, `pixel`, `space`, `ocean`, `island`, `terrain` and `biomes` + (plains, forests, deserts and snowy mountains with rivers, caves and ores). All seeded. +- `/main ` picks the level players join in; `/autoload` keeps more levels loaded from startup. - ClassicWorld `.cw` files. MCGalaxy `.lvl` and the old `.dat` can be imported with `/import`. - Autosave, periodic backups with `/restore`. Deleted levels go to `levels/deleted`. - Per level: sky, fog, cloud and light colors, texture pack, weather, edge blocks, water level, MOTD hack flags (`-hax +fly`), build and visit ranks, owners, physics and lockdown. - Block history is written to disk, so `/about` and `/undoplayer` still work after a restart. +Texture packs: put a `.zip` in `texpacks/` and `/texture mypack.zip` serves it to players from the server itself, +on the game port. No web host needed (set `publicAddress` to your domain or IP). + +MCGalaxy habits work: the shortcuts and aliases from MCGalaxy are there (`/z`, `/ld`, `/gen`, `/wcopy`, `/zadd`, +`/cw`, `/v`, `/fg`, `/h`...). + Custom blocks: `/gb` for every level and `/lb` for one level. Name, textures per face, shape, collision, speed, sound, light, transparency, fog and fallback block. Presets included: invisible barrier, lamp, glass pane, ladder, carpet, slabs, speed pad and more. @@ -175,10 +204,10 @@ anti-grief. | Plugin | Commands | | --- | --- | -| core-essentials | `/spawn /main /tp /tphere /back /ascend /descend /tpa /kill /msg /reply /ignore /me /say /announce /rules /faq /news /view /players /whois /top /search /blocks /pclients /whonick /serverinfo /ping /where /time /model /modelscale /entityrot /skin /nick /color /title /tcolor /hold /reach /fly /afk /clear /roll /8ball /hug /high5 /send /inbox /loginmessage /logoutmessage /emotes /ccols /lastcmd` | -| core-moderation | `/rank /promote /demote /ranks /rankinfo /temprank /kick /warn /ban /unban /baninfo /banedit /banip /unbanip /xban /bans /mute /unmute /freeze /vanish /follow /p2p /patrol /moveall /moderate /voice /opchat /adminchat /rankmsg /report /whitelist /playeredit /limit /sudo /oprules` | -| core-worlds | `/newlvl /goto /levels /load /unload /save /deletelvl /copylvl /renamelvl /resizelvl /import /mapinfo /map /setspawn /backup /restore /lockdown /reload /fixgrass /unflood /env /weather /texture` | -| core-building | `/cuboid /replace /replaceall /line /sphere /spheroid /torus /pyramid /hollow /outline /fill /tree /maze /rainbow /drill /center /place /copy /paste /mirror /spin /write /mark /bind /mode /undo /undoplayer /redo /paint /about /measure /calculate` | +| core-essentials | `/spawn /main /tp /tphere /back /ascend /descend /tpa /kill /msg /reply /ignore /me /say /announce /rules /faq /news /view /players /whois /top /search /blocks /pclients /whonick /serverinfo /ping /where /time /model /modelscale /entityrot /skin /nick /color /title /tcolor /hold /reach /fly /afk /clear /roll /8ball /hug /high5 /send /inbox /loginmessage /logoutmessage /emotes /ccols /lastcmd /vote /yes /no /timer /pronouns /quit /ragequit` | +| core-moderation | `/rank /promote /demote /ranks /rankinfo /temprank /kick /warn /ban /unban /baninfo /banedit /banip /unbanip /xban /bans /mute /unmute /freeze /vanish /follow /p2p /patrol /moveall /moderate /voice /opchat /adminchat /rankmsg /report /whitelist /playeredit /limit /sudo /oprules /alts` | +| core-worlds | `/newlvl /goto /levels /load /unload /save /deletelvl /copylvl /renamelvl /resizelvl /import /mapinfo /map /setspawn /backup /restore /lockdown /reload /fixgrass /unflood /env /weather /texture /like /dislike /autoload` | +| core-building | `/cuboid /replace /replaceall /line /sphere /spheroid /torus /pyramid /hollow /outline /fill /tree /maze /rainbow /drill /center /place /copy /paste /mirror /spin /write /mark /bind /mode /undo /undoplayer /redo /paint /about /measure /calculate /replacenot /triangle /delete /static` | | core-blocks | `/gb /lb` | | warps | `/warp /home` | | zones | `/zone` (protected areas, shown in the client) | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0330bcc..7fea0c3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -21,12 +21,14 @@ write its own copy over your changes on shutdown. | `extraHeartbeats` | `[]` | More server lists to announce the server on, such as BetaCraft. See below | | `allowWebClient` | `true` | Accept the browser client (WebSocket on the same port) | | `trustProxy` | `false` | Use the `X-Forwarded-For` header as the player IP. Only turn this on behind your own reverse proxy | -| `mainLevel` | `main` | Level players spawn in | +| `mainLevel` | `main` | Level players spawn in. `/main ` changes it | +| `autoloadLevels` | `[]` | More levels to load at startup. `/autoload ` adds one | | `defaultRank` | `Guest` | Rank given to new players | | `owners` | `[]` | Names that always get the highest rank | | `welcomeMessage` | | Sent to players when they join. `{player}` is replaced with their name | | `rules` | three lines | Shown by `/rules` | | `defaultTexture` | `""` | Texture pack URL for levels that don't set their own | +| `publicAddress` | `""` | Your server's domain or IP (and `:port` if needed), used to serve packs from `texpacks/`. On Pterodactyl it is detected | | `autosaveMinutes` | `5` | `0` turns autosave off | | `backupMinutes` | `30` | How often changed levels are backed up. `0` turns backups off | | `backupsToKeep` | `10` | Backups kept per level | @@ -46,7 +48,7 @@ containers and hosting panels. | Variable | Key | | --- | --- | -| `PORT` | `port` | +| `PORT` or `SERVER_PORT` | `port` (`SERVER_PORT` is what Pterodactyl sets) | | `HOST` | `host` | | `SERVER_NAME` | `name` | | `MOTD` | `motd` | diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index f5e975b..fe2a8a1 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -63,6 +63,8 @@ ctx.command({ }) ``` +An alias can carry arguments: with `aliases: ['zadd add']`, typing `/zadd x` runs the command as `/zone add x`. + `run` can be `async` (for example to wait for the player to mark blocks). Owners can change the rank of any command with `/cmdset `. diff --git a/docs/images/banner.png b/docs/images/banner.png new file mode 100644 index 0000000..f019f19 Binary files /dev/null and b/docs/images/banner.png differ diff --git a/lib/commands/core.js b/lib/commands/core.js index bb5071e..453add8 100644 --- a/lib/commands/core.js +++ b/lib/commands/core.js @@ -68,7 +68,7 @@ module.exports = function registerCoreCommands (server) { commands.register({ name: 'plugins', - aliases: ['pl', 'plist'], + aliases: ['plist'], category: 'server', usage: '/plugins', description: 'Lists loaded plugins', @@ -276,8 +276,8 @@ module.exports = function registerCoreCommands (server) { server.broadcast(`&e${reason}...`) await server.stop(reason) if (server.config.exitOnStop === false) return - // under systemd, pm2 or a start script loop the supervisor starts us again - if (process.env.MCSCRIPT_SUPERVISED) process.exit(0) + // under systemd, pm2, a start script loop or Pterodactyl the supervisor starts us again + if (process.env.MCSCRIPT_SUPERVISED || process.env.P_SERVER_UUID) process.exit(0) const { spawn } = require('child_process') spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit', cwd: process.cwd(), detached: true }).unref() process.exit(0) diff --git a/lib/commands/manager.js b/lib/commands/manager.js index 65abddf..2d9efa9 100644 --- a/lib/commands/manager.js +++ b/lib/commands/manager.js @@ -1,6 +1,7 @@ 'use strict' const fs = require('fs') +const SHORTCUTS = require('./shortcuts') class CommandError extends Error {} @@ -10,7 +11,7 @@ class CommandManager { constructor (server, overridesFile) { this.server = server this.commands = new Map() // name -> def - this.aliases = new Map() // alias -> name + this.aliases = new Map() // alias -> { name, args } (args are put in front of what the player typed) this.overridesFile = overridesFile this.overrides = {} if (overridesFile && fs.existsSync(overridesFile)) { @@ -33,9 +34,12 @@ class CommandManager { name } this.commands.set(name, cmd) - for (const alias of cmd.aliases) { - const a = alias.toLowerCase() - if (!this.commands.has(a) && !this.aliases.has(a)) this.aliases.set(a, name) + const extra = SHORTCUTS[name] || [] + for (const entry of [...cmd.aliases, ...extra]) { + const [a, ...args] = entry.toLowerCase().split(' ') + if (this.commands.has(a) || this.aliases.has(a)) continue + this.aliases.set(a, { name, args }) + if (!cmd.aliases.includes(a) && !args.length) cmd.aliases = [...cmd.aliases, a] } return () => this.unregister(name) } @@ -45,7 +49,7 @@ class CommandManager { const cmd = this.commands.get(name) if (!cmd) return false this.commands.delete(name) - for (const [alias, target] of this.aliases) if (target === name) this.aliases.delete(alias) + for (const [alias, target] of this.aliases) if (target.name === name) this.aliases.delete(alias) return true } @@ -56,7 +60,16 @@ class CommandManager { find (name) { if (!name) return null name = name.toLowerCase() - return this.commands.get(name) || this.commands.get(this.aliases.get(name)) || null + const alias = this.aliases.get(name) + return this.commands.get(name) || (alias && this.commands.get(alias.name)) || null + } + + // Arguments an alias adds in front of the player's own ('zadd' -> ['add']) + aliasArgs (label) { + label = String(label).toLowerCase() + if (this.commands.has(label)) return [] + const alias = this.aliases.get(label) + return alias ? alias.args : [] } // minimum permission number required to use a command @@ -84,7 +97,9 @@ class CommandManager { if (!line) return const space = line.indexOf(' ') const label = (space === -1 ? line : line.slice(0, space)).toLowerCase() - const raw = space === -1 ? '' : line.slice(space + 1).trim() + const typed = space === -1 ? '' : line.slice(space + 1).trim() + const prefix = this.aliasArgs(label) + const raw = [...prefix, typed].filter(Boolean).join(' ') const args = raw.length ? raw.split(/\s+/) : [] const cmd = this.find(label) diff --git a/lib/commands/shortcuts.js b/lib/commands/shortcuts.js new file mode 100644 index 0000000..79225ad --- /dev/null +++ b/lib/commands/shortcuts.js @@ -0,0 +1,93 @@ +'use strict' + +// MCGalaxy's shortcuts and aliases, so people coming from MCGalaxy can keep typing what they are used to. +// An entry with a space carries arguments: 'zadd add' makes /zadd x the same as /zone add x. +// They are added when the command registers; a real command name always wins over an alias. +module.exports = { + // chat and info + adminchat: ['admin'], + opchat: ['op'], + clear: ['playercls', 'globalcls all', 'gcls all'], + about: ['whodid'], + blocks: ['materials'], + help: ['cmdhelp'], + mapinfo: ['mi', 'winfo', 'worldinfo'], + serverinfo: ['sinfo', 'host', 'zall'], + time: ['ti'], + top: ['most'], + whois: ['whowas', 'info', 'i'], + players: ['who'], + pclients: ['clients'], + levels: ['worlds', 'maps', 'loaded'], + lastcmd: ['last'], + rankinfo: ['ri'], + whonick: ['realname'], + bans: ['banned'], + vote: ['vo'], + alts: ['clones', 'whoip'], + // essentials + hold: ['holdthis'], + reach: ['reachdistance'], + entityrot: ['entrot'], + tp: ['move', 'teleport'], + tphere: ['summon', 's', 'fetch', 'bring'], + tpa: ['tpaccept accept', 'tpdeny deny'], + bot: ['botlist list'], + // economy and games + buy: ['purchase'], + give: ['gib'], + shop: ['store', 'item'], + explode: ['ex'], + // moderation + ban: ['tempban', 'tb', 'kickban', 'kb'], + banedit: ['be'], + banip: ['bi', 'ipban'], + unbanip: ['unipban'], + xban: ['banx'], + kick: ['k'], + freeze: ['fz'], + rank: ['setrank'], + moveall: ['ma'], + playeredit: ['pe', 'setinfo'], + report: ['reports list'], + undoplayer: ['xundo'], + whitelist: ['wl'], + // levels + copylvl: ['wcopy', 'worldcopy'], + deletelvl: ['wdelete', 'worlddelete', 'wremove'], + fixgrass: ['fg'], + load: ['mapload', 'wload'], + lockdown: ['ld', 'wlock', 'wunlock'], + main: ['h', 'wmain', 'worldmain'], + newlvl: ['gen'], + reload: ['reveal', 'rejoin', 'rd', 'wflush', 'worldflush'], + renamelvl: ['wrename', 'worldrename'], + resizelvl: ['wresize', 'worldresize'], + save: ['mapsave', 'wsave', 'worldsave'], + map: ['perbuild buildrank', 'wbuild buildrank', 'worldbuild buildrank', 'pervisit visitrank', 'waccess visitrank', 'worldaccess visitrank'], + zone: ['zadd add', 'zremove delete', 'zdelete delete', 'zonelist list', 'zonetest check', 'ztest check'], + // building + cuboid: ['cw wire', 'ch hollow', 'walls walls', 'hbox hollow'], + sphere: ['sph hollow', 'sphereh hollow'], + spheroid: ['eh hollow'], + fill: ['f3d 3d', 'fill3d 3d', 'f2d layer', 'fill2d layer'], + line: ['ln'], + mark: ['click', 'x'], + measure: ['ms'], + calculate: ['calc'], + center: ['centre'], + paint: ['p'], + paste: ['v'], + place: ['pl'], + mirror: ['flip'], + spin: ['rotate'], + portal: ['o'], + torus: ['tor', 'bagel'], + write: ['wrt', 'writetext'], + abort: ['a'], + replacenot: ['rn'], + triangle: ['tri'], + static: ['t'], + delete: ['d'], + bind: ['cmdbind', 'cb'] +} diff --git a/lib/config.js b/lib/config.js index c63152a..c477c89 100644 --- a/lib/config.js +++ b/lib/config.js @@ -19,6 +19,7 @@ const DEFAULTS = { allowWebClient: true, trustProxy: false, mainLevel: 'main', + autoloadLevels: [], defaultRank: 'Guest', owners: [], welcomeMessage: '&eWelcome to the server, &f{player}&e! Type &f/help&e for commands.', @@ -28,6 +29,7 @@ const DEFAULTS = { '&f3. &7No spamming or advertising.' ], defaultTexture: '', + publicAddress: '', // your server's address as players reach it (domain or IP, optional :port), for texture packs in texpacks/ autosaveMinutes: 5, backupMinutes: 30, backupsToKeep: 10, @@ -44,6 +46,7 @@ const DEFAULTS = { // Environment variables that map onto config keys const ENV_MAP = { PORT: ['port', Number], + SERVER_PORT: ['port', Number], // Pterodactyl and similar panels HOST: ['host', String], SERVER_NAME: ['name', String], MOTD: ['motd', String], diff --git a/lib/level/biomes.js b/lib/level/biomes.js new file mode 100644 index 0000000..0048af2 --- /dev/null +++ b/lib/level/biomes.js @@ -0,0 +1,198 @@ +'use strict' + +// "biomes" generator: rolling plains, forests, deserts and snowy mountains, with rivers, beaches, +// caves and ores. Seeded, so the same seed gives the same world. + +// block ids +const B = { air: 0, stone: 1, grass: 2, dirt: 3, cobble: 4, sapling: 6, bedrock: 7, water: 9, lava: 11, sand: 12, gravel: 13, goldOre: 14, ironOre: 15, coalOre: 16, log: 17, leaves: 18, green: 25, dandelion: 37, rose: 38, brownMush: 39, redMush: 40, mossy: 48, sandstone: 52, snow: 53, ice: 60 } + +function generate (level, { seed, rand, makeNoise }) { + const { width, height, length } = level + const sea = Math.floor(height * 0.42) + const continent = makeNoise(rand) + const detail = makeNoise(rand) + const mountains = makeNoise(rand) + const ridges = makeNoise(rand) + const rivers = makeNoise(rand) + const heat = makeNoise(rand) + const wet = makeNoise(rand) + + const heights = new Int16Array(width * length) + const biome = new Uint8Array(width * length) // 0 plains, 1 forest, 2 desert, 3 snow, 4 water + const set = (x, y, z, b) => level.setBlockRaw(x, y, z, b) + const get = (x, y, z) => level.getBlock(x, y, z) + + for (let x = 0; x < width; x++) { + for (let z = 0; z < length; z++) { + const base = continent(x / 180, z / 180, 4) * 0.8 + detail(x / 40, z / 40, 3) * 0.2 + const mask = Math.max(0, Math.min(1, (mountains(x / 140, z / 140, 3) + 0.05) * 2.2)) ** 2 + const ridge = 1 - Math.abs(ridges(x / 55, z / 55, 4)) + let h = sea + base * height * 0.2 + mask * ridge * height * 0.42 + // rivers cut through everything but the high mountains + const r = Math.abs(rivers(x / 160, z / 160, 3)) + if (r < 0.04 && h < sea + height * 0.25) h = Math.min(h, sea - 1 - (0.04 - r) * 60 + r * 40) + h = Math.max(4, Math.min(height - 12, Math.floor(h))) + heights[x + z * width] = h + + const temp = heat(x / 220, z / 220, 2) - Math.max(0, h - sea) / height * 1.6 + const moist = wet(x / 160, z / 160, 2) + let kind = 0 + if (h < sea) kind = 4 + else if (temp < -0.32 || h > sea + height * 0.3) kind = 3 + else if (temp > 0.22 && moist < 0) kind = 2 + else if (moist > 0.08) kind = 1 + biome[x + z * width] = kind + + // column: bedrock, stone with ores, soil, surface + set(x, 0, z, B.bedrock) + if (rand() < 0.5) set(x, 1, z, B.bedrock) + for (let y = 1; y <= h; y++) { + if (get(x, y, z) === B.bedrock) continue + let b = B.stone + const depth = h - y + const beach = h <= sea + 1 && h >= sea - 2 && kind !== 3 + if (kind === 2 || beach) b = depth < 3 ? B.sand : depth < 6 ? B.sandstone : B.stone + else if (kind === 3 && h > sea + height * 0.3) b = depth === 0 ? B.snow : B.stone + else if (kind === 4) b = depth < 2 ? (rand() < 0.3 ? B.gravel : B.sand) : depth < 4 ? B.dirt : B.stone + else if (depth === 0) b = kind === 3 ? B.snow : B.grass + else if (depth < 4) b = B.dirt + if (b === B.stone) { + const roll = rand() + if (roll < 0.012 && y < sea + 20) b = B.coalOre + else if (roll < 0.018 && y < sea - 4) b = B.ironOre + else if (roll < 0.0205 && y < sea / 2) b = B.goldOre + else if (roll < 0.03) b = B.gravel + } + set(x, y, z, b) + } + // water, frozen on top in cold places + for (let y = h + 1; y <= sea - 1; y++) set(x, y, z, B.water) + if (h < sea - 1 && temp < -0.32) set(x, sea - 1, z, B.ice) + } + } + + // caves: worms that wander underground and carve tunnels + const worms = Math.floor(width * length / 1800) + for (let w = 0; w < worms; w++) { + let x = rand() * width; let z = rand() * length + let y = 6 + rand() * (heights[Math.floor(x) + Math.floor(z) * width] - 14) + let yaw = rand() * Math.PI * 2; let pitch = (rand() - 0.5) * 0.4 + const steps = 60 + Math.floor(rand() * 120) + for (let s = 0; s < steps; s++) { + yaw += (rand() - 0.5) * 0.5 + pitch = Math.max(-0.5, Math.min(0.5, pitch + (rand() - 0.5) * 0.2)) + x += Math.cos(yaw) * Math.cos(pitch); z += Math.sin(yaw) * Math.cos(pitch); y += Math.sin(pitch) + if (x < 2 || z < 2 || x >= width - 2 || z >= length - 2 || y < 3) break + const r = 1.3 + Math.sin(s / 9) * 0.6 + rand() * 0.5 + for (let dx = -3; dx <= 3; dx++) { + for (let dy = -3; dy <= 3; dy++) { + for (let dz = -3; dz <= 3; dz++) { + if (dx * dx + dy * dy + dz * dz > r * r) continue + const cx = Math.floor(x + dx); const cy = Math.floor(y + dy); const cz = Math.floor(z + dz) + if (cx < 0 || cz < 0 || cx >= width || cz >= length || cy < 2) continue + const top = heights[cx + cz * width] + if (cy > top - 3 || top < sea) continue // keep the surface and the sea floor whole + set(cx, cy, cz, cy <= 5 ? B.lava : B.air) + } + } + } + } + } + + // plants + const surface = (x, z) => heights[x + z * width] + for (let x = 3; x < width - 3; x++) { + for (let z = 3; z < length - 3; z++) { + const h = surface(x, z) + const kind = biome[x + z * width] + const top = get(x, h, z) + if (get(x, h + 1, z) !== 0 || h + 12 >= height) continue + const roll = rand() + if (top === B.grass) { + const treeChance = kind === 1 ? 0.03 : 0.0025 + if (roll < treeChance) { + const pick = rand() + if (kind === 1 && pick < 0.35) pine(level, x, h + 1, z, rand) + else if (pick < 0.12) bigOak(level, x, h + 1, z, rand) + else oak(level, x, h + 1, z, rand) + } else if (roll < treeChance + 0.05) { + set(x, h + 1, z, kind === 1 && rand() < 0.3 ? (rand() < 0.5 ? B.brownMush : B.redMush) : rand() < 0.75 ? B.sapling : rand() < 0.5 ? B.rose : B.dandelion) + } + } else if (top === B.snow && kind === 3 && roll < 0.008 && h < sea + height * 0.3) { + pine(level, x, h + 1, z, rand, true) + } else if (top === B.sand && kind === 2 && roll < 0.004) { + const tall = 1 + Math.floor(rand() * 3) + for (let i = 1; i <= tall; i++) set(x, h + i, z, B.green) // cactus + } + } + } + + // spawn on dry, mild land close to the middle + let best = null + for (let r = 0; r < Math.max(width, length) / 2 && !best; r += 2) { + for (let a = 0; a < 32 && !best; a++) { + const x = Math.floor(width / 2 + Math.cos(a / 32 * Math.PI * 2) * r) + const z = Math.floor(length / 2 + Math.sin(a / 32 * Math.PI * 2) * r) + if (x < 4 || z < 4 || x >= width - 4 || z >= length - 4) continue + const h = surface(x, z) + if ((biome[x + z * width] === 0 || biome[x + z * width] === 1) && h > sea + 1 && h < sea + height * 0.15) best = { x, z, h } + } + } + if (!best) best = { x: Math.floor(width / 2), z: Math.floor(length / 2), h: Math.max(sea, surface(Math.floor(width / 2), Math.floor(length / 2))) } + level.spawn = { x: best.x + 0.5, y: best.h + 1, z: best.z + 0.5, yaw: 0, pitch: 0 } + level.meta.seed = seed +} + +function leafBlob (level, cx, cy, cz, r, rand) { + for (let dx = -r; dx <= r; dx++) { + for (let dy = -r; dy <= r; dy++) { + for (let dz = -r; dz <= r; dz++) { + const d = dx * dx + dy * dy * 1.6 + dz * dz + if (d > r * r + 0.5 || (d > (r - 0.5) * (r - 0.5) && rand() < 0.35)) continue + const x = cx + dx; const y = cy + dy; const z = cz + dz + if (level.inBounds(x, y, z) && level.getBlock(x, y, z) === 0) level.setBlockRaw(x, y, z, B.leaves) + } + } + } +} + +function oak (level, x, y, z, rand) { + const h = 4 + Math.floor(rand() * 3) + for (let i = 0; i < h; i++) level.setBlockRaw(x, y + i, z, B.log) + leafBlob(level, x, y + h - 1, z, 2, rand) +} + +function bigOak (level, x, y, z, rand) { + const h = 7 + Math.floor(rand() * 4) + for (let i = 0; i < h; i++) level.setBlockRaw(x, y + i, z, B.log) + leafBlob(level, x, y + h, z, 3, rand) + for (let b = 0; b < 3; b++) { + const dx = Math.round((rand() - 0.5) * 6); const dz = Math.round((rand() - 0.5) * 6) + const by = y + h - 3 + Math.floor(rand() * 2) + for (let s = 1; s <= 2; s++) { + const bx = x + Math.round(dx * s / 3); const bz = z + Math.round(dz * s / 3) + if (level.inBounds(bx, by + s, bz)) level.setBlockRaw(bx, by + s, bz, B.log) + } + leafBlob(level, x + dx, by + 3, z + dz, 2, rand) + } +} + +function pine (level, x, y, z, rand, snowy = false) { + const h = 7 + Math.floor(rand() * 5) + for (let i = 0; i < h; i++) level.setBlockRaw(x, y + i, z, B.log) + for (let i = 2; i <= h; i++) { + const r = Math.max(0, Math.round((h - i) / 3.2 + (i % 2 ? 0.4 : 0))) + for (let dx = -r; dx <= r; dx++) { + for (let dz = -r; dz <= r; dz++) { + if (Math.abs(dx) + Math.abs(dz) > r + (r > 1 ? 1 : 0)) continue + const lx = x + dx; const lz = z + dz; const ly = y + i + if (!level.inBounds(lx, ly, lz) || level.getBlock(lx, ly, lz) !== 0) continue + level.setBlockRaw(lx, ly, lz, B.leaves) + if (snowy && level.inBounds(lx, ly + 1, lz) && level.getBlock(lx, ly + 1, lz) === 0 && rand() < 0.6) level.setBlockRaw(lx, ly + 1, lz, B.snow) + } + } + } + if (level.inBounds(x, y + h + 1, z)) level.setBlockRaw(x, y + h + 1, z, B.leaves) +} + +module.exports = { generate } diff --git a/lib/level/generators.js b/lib/level/generators.js index b38de65..542c096 100644 --- a/lib/level/generators.js +++ b/lib/level/generators.js @@ -220,7 +220,16 @@ function tree (level, x, y, z, rand) { for (let dy = 0; dy < trunk; dy++) level.setBlockRaw(x, y + dy, z, 17) } +generators.biomes = { + description: 'Plains, forests, deserts and snowy mountains with rivers, caves and ores (seeded)', + generate (level, opts = {}) { + const seed = opts.seed ?? Date.now() + require('./biomes').generate(level, { seed, rand: mulberry32(seed), makeNoise }) + } +} + // aliases +generators.nature = generators.biomes generators.natural = generators.terrain generators.hills = generators.terrain generators.water = generators.ocean diff --git a/lib/level/manager.js b/lib/level/manager.js index e20d349..e652b13 100644 --- a/lib/level/manager.js +++ b/lib/level/manager.js @@ -229,6 +229,15 @@ class LevelManager { } // Load or create the main level, migrating the pre-2.0 levels/level.dat if present + // Makes another level the main one (where players join), and remembers it in config/server.json + setMain (name) { + const level = this.get(name) || this.load(name) + this.main = level + this.server.config.mainLevel = level.name + if (this.server.config.file) this.server.saveConfig() + return level + } + initMain (name) { if (this.exists(name)) { this.main = this.load(name) diff --git a/lib/network/connection.js b/lib/network/connection.js index 95e00f4..5c04ba4 100644 --- a/lib/network/connection.js +++ b/lib/network/connection.js @@ -20,8 +20,10 @@ class Connection extends EventEmitter { this.extBlocks = false this.extPos = false this.socketIp = (socket.remoteAddress || '').replace(/^::ffff:/, '') + this.localAddress = (socket.localAddress || '').replace(/^::ffff:/, '') // the server address this client used this.forwardedFor = null // X-Forwarded-For of web clients behind a proxy (only trusted if configured) this.isWebSocket = false + this.httpHandler = null // (method, path) => { type, body } | null, for plain HTTP requests socket.setNoDelay(true) socket.on('data', data => this._onSocketData(data)) @@ -33,10 +35,11 @@ class Connection extends EventEmitter { 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) { + // 'G' = HTTP GET => WebSocket upgrade (web client) or a file; 'H' = HTTP HEAD (texture pack size check) + if (data[0] === 0x47 || data[0] === 0x48) { this.isWebSocket = true this.transport = new WebSocketTransport(this.socket) + this.transport.onHttp = this.httpHandler || null this.transport.on('data', d => this._onData(d)) } } diff --git a/lib/network/websocket.js b/lib/network/websocket.js index b0d64b5..b0bdd85 100644 --- a/lib/network/websocket.js +++ b/lib/network/websocket.js @@ -41,7 +41,22 @@ class WebSocketTransport extends EventEmitter { const key = headers['sec-websocket-key'] if (!key || !/websocket/i.test(headers.upgrade || '')) { - this.socket.end('HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nThis is a ClassiCube server. Connect with the game client.\r\n') + // a plain HTTP request: texture packs are served from here, anything else gets a short note + const [method, target] = head.split('\r\n')[0].split(' ') + const file = this.onHttp ? this.onHttp(method, target || '/') : null + if (file) { + this.socket.write([ + 'HTTP/1.1 200 OK', + `Content-Type: ${file.type}`, + `Content-Length: ${file.body.length}`, + 'Access-Control-Allow-Origin: *', + 'Cache-Control: public, max-age=300', + 'Connection: close' + ].join('\r\n') + '\r\n\r\n') + this.socket.end(method === 'HEAD' ? undefined : file.body) + } else { + this.socket.end('HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nThis is a ClassiCube server. Connect with the game client.\r\n') + } return false } diff --git a/lib/player.js b/lib/player.js index 0c0c086..e052932 100644 --- a/lib/player.js +++ b/lib/player.js @@ -469,7 +469,9 @@ class Player extends EventEmitter { }) } if (this.supports('EnvMapAspect')) { - const url = env.texture || this.server.config.defaultTexture || '' + let url = env.texture || this.server.config.defaultTexture || '' + // a bare file name is a pack from texpacks/, served by this server + if (url && !/^https?:\/\//i.test(url)) url = this.server.texturePackUrl(url, this) || '' this.conn.write('setMapEnvUrl', { url }) for (const [key, type] of Object.entries(ENV_PROPS)) { let value = env[key] diff --git a/lib/server.js b/lib/server.js index 15c660d..7cd4c7e 100644 --- a/lib/server.js +++ b/lib/server.js @@ -108,6 +108,9 @@ class MCScriptServer { this.levels.initMain(cfg.mainLevel) registerCoreCommands(this) this.plugins.loadAll() + for (const name of cfg.autoloadLevels || []) { + try { if (!this.levels.get(name)) this.levels.load(name) } catch (err) { this.log.warn(`Could not autoload ${name}: ${err.message}`) } + } this.tcp = net.createServer(socket => this._onConnection(socket)) this.tcp.maxConnections = cfg.maxPlayers + 10 @@ -193,12 +196,44 @@ class MCScriptServer { else this._connectionsPerIp.delete(ip) }) const conn = new Connection(socket) + conn.httpHandler = (method, target) => this._serveHttp(method, target) conn.on('socketError', err => this.log.debug(`Socket error (${conn.socketIp}): ${err.message}`)) conn.on('handlerError', (err, packet) => this.log.error(`Error handling ${packet.name} from ${conn.socketIp}:`, err)) // eslint-disable-next-line no-new new Player(this, conn) } + // Texture packs in texpacks/ can be downloaded from the game port: http://host:port/texpacks/ + _serveHttp (method, target) { + if (method !== 'GET' && method !== 'HEAD') return null + const m = String(target).split('?')[0].match(/^\/texpacks\/([A-Za-z0-9_.-]{1,48}\.(zip|png))$/) + if (!m || m[1].startsWith('.')) return null + const file = path.join(this.root, 'texpacks', m[1]) + try { + const stat = fs.statSync(file) + if (!stat.isFile() || stat.size > 16 * 1024 * 1024) return null + return { type: m[2] === 'zip' ? 'application/zip' : 'image/png', body: fs.readFileSync(file) } + } catch (err) { + return null + } + } + + // URL players' clients use to download a pack from texpacks/ (needs publicAddress in the config) + texturePackUrl (fileName, player = null) { + let address = String(this.config.publicAddress || '').replace(/^https?:\/\//, '').replace(/\/+$/, '') + // Pterodactyl tells us the address of the allocation + const panelIp = process.env.SERVER_IP + if (!address && panelIp && panelIp !== '0.0.0.0') address = `${panelIp}:${this.port}` + // a player on this machine or the same network can use the address they connected to + if (!address && player && player.conn.localAddress && isPrivateIp(player.conn.socketIp)) { + const ip = player.conn.localAddress + address = `${ip.includes(':') ? `[${ip}]` : ip}:${this.port}` + } + if (!address) return null + const host = /:\d+$/.test(address) ? address : `${address}:${this.port}` + return `http://${host}/texpacks/${fileName}` + } + _allocateId () { for (let id = 0; id <= MAX_ENTITY_ID; id++) { if (!this._usedIds.has(id)) { this._usedIds.add(id); return id } @@ -562,4 +597,12 @@ class ConsolePlayer { message (msg) { if (msg) this.server.log.info(msg) } } +// loopback, private ranges (10/8, 172.16/12, 192.168/16, 100.64/10) and IPv6 local addresses +function isPrivateIp (ip) { + if (!ip) return false + if (ip === '::1' || ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.')) return true + if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return true + return /^f[cd]/i.test(ip) || /^fe80:/i.test(ip) +} + module.exports = { MCScriptServer, ConsolePlayer, TabList } diff --git a/plugins/core-building/index.js b/plugins/core-building/index.js index 8b81b72..d3c746a 100644 --- a/plugins/core-building/index.js +++ b/plugins/core-building/index.js @@ -614,6 +614,7 @@ module.exports = { }) require('./more')(ctx, { select, blockArg, checkVolume, box, apply, clipboards, key }) + require('./tools')(ctx, { select, blockArg, checkVolume, box, apply }) // manual building diff --git a/plugins/core-building/tools.js b/plugins/core-building/tools.js new file mode 100644 index 0000000..687bc78 --- /dev/null +++ b/plugins/core-building/tools.js @@ -0,0 +1,115 @@ +'use strict' + +// /replacenot /triangle /delete /static, as in MCGalaxy + +module.exports = function tools (ctx, h) { + const { server, CommandError } = ctx + const { select, blockArg, checkVolume, box, apply } = h + + ctx.command({ + name: 'replacenot', + category: 'building', + rank: 'Builder', + usage: '/replacenot ', + description: 'Replaces everything except one block inside a marked box (air too)', + inGame: true, + async run (player, args, { usage }) { + if (args.length < 2) return usage() + const level = player.level + const keep = level.parseBlock(args[0]) + if (keep === null) throw new CommandError(`Unknown block "${args[0]}".`) + const to = blockArg(player, args[1]) + const marks = await select(player, 2, 'Replace not') + if (!marks) return + const b = box(marks[0], marks[1]) + checkVolume(player, (b.x2 - b.x1 + 1) * (b.y2 - b.y1 + 1) * (b.z2 - b.z1 + 1)) + const changes = [] + for (let y = b.y1; y <= b.y2; y++) { + for (let z = b.z1; z <= b.z2; z++) { + for (let x = b.x1; x <= b.x2; x++) if (level.getBlock(x, y, z) !== keep) changes.push([x, y, z, to]) + } + } + apply(player, level, changes, 'Replace not') + } + }) + + ctx.command({ + name: 'triangle', + category: 'building', + rank: 'AdvBuilder', + usage: '/triangle [block]', + description: 'Fills the triangle between three marked corners', + inGame: true, + async run (player, args) { + const block = blockArg(player, args[0]) + const marks = await select(player, 3, 'Triangle') + if (!marks) return + const [a, b, c] = marks + const len = (p, q) => Math.hypot(p.x - q.x, p.y - q.y, p.z - q.z) + // sample the triangle densely enough that no block is skipped + const steps = Math.max(1, Math.ceil(2 * Math.max(len(a, b), len(a, c), len(b, c)))) + checkVolume(player, Math.ceil(steps * steps / 8)) + const seen = new Set() + const changes = [] + for (let i = 0; i <= steps; i++) { + for (let j = 0; j <= steps - i; j++) { + const u = i / steps; const v = j / steps + const x = Math.round(a.x + (b.x - a.x) * u + (c.x - a.x) * v) + const y = Math.round(a.y + (b.y - a.y) * u + (c.y - a.y) * v) + const z = Math.round(a.z + (b.z - a.z) * u + (c.z - a.z) * v) + const k = `${x},${y},${z}` + if (seen.has(k) || !player.level.inBounds(x, y, z)) continue + seen.add(k) + changes.push([x, y, z, block]) + } + } + apply(player, player.level, changes, 'Triangle') + } + }) + + ctx.command({ + name: 'delete', + category: 'building', + rank: 'Builder', + usage: '/delete', + description: 'Removes the block you mark (use /static delete to keep going)', + inGame: true, + async run (player) { + const marks = await select(player, 1, 'Delete') + if (!marks) return + const { x, y, z } = marks[0] + apply(player, player.level, [[x, y, z, 0]], 'Delete') + } + }) + + // /static : runs a drawing command again each time it finishes, until /abort + ctx.on('playerAbort', ({ player }) => { player.data['building.static'] = false }) + ctx.command({ + name: 'static', + category: 'building', + rank: 'Builder', + usage: '/static [args]', + description: 'Repeats a drawing command (for example /static cuboid stone) until you type /abort', + inGame: true, + async run (player, args, { usage }) { + if (!args.length) { + if (player.data['building.static']) { player.data['building.static'] = false; return player.message('&eStatic mode off.') } + return usage() + } + const cmd = server.commands.find(args[0]) + if (!cmd) throw new CommandError(`Unknown command "/${args[0]}".`) + if (cmd.name === 'static') throw new CommandError('That would never end.') + if (cmd.category !== 'building') throw new CommandError('Static mode only works with building commands.') + player.data['building.static'] = true + player.message(`&eStatic mode: /${args.join(' ')} repeats until you type &f/abort&e.`) + const line = '/' + args.join(' ') + for (let i = 0; i < 1000 && player.data['building.static'] && !player.conn.closed; i++) { + const before = Date.now() + await server.commands.execute(player, line) + // a command that didn't wait for marks would spin; stop instead + if (Date.now() - before < 50) break + } + player.data['building.static'] = false + } + }) +} diff --git a/plugins/core-essentials/index.js b/plugins/core-essentials/index.js index bde412b..4e78cef 100644 --- a/plugins/core-essentials/index.js +++ b/plugins/core-essentials/index.js @@ -53,10 +53,18 @@ module.exports = { ctx.command({ name: 'main', category: 'essentials', - usage: '/main', - description: 'Takes you to the main level', - inGame: true, - run (player) { + usage: '/main [level]', + description: 'Takes you to the main level. Admins can pick another main level with /main ', + help: ['The main level is where players arrive when they join. It is saved in config/server.json.'], + run (player, args) { + if (args[0]) { + if (player.permission < server.ranks.permissionOf('Admin')) throw new CommandError('Only admins can change the main level.') + const name = server.levels.resolveName(args[0]) + if (!name) throw new CommandError(`There is no level called ${args[0]}.`) + const level = server.levels.setMain(name) + return server.broadcast(`&e${level.name} is now the main level.`, p => p.permission >= server.ranks.permissionOf('Admin') || p === player) + } + if (player.isConsole) return player.message(`&eThe main level is &f${server.levels.main.name}&e.`) if (player.level === server.levels.main) { const s = player.level.spawn return player.teleport(s.x, s.y, s.z, s.yaw, s.pitch) @@ -246,6 +254,7 @@ module.exports = { const onlinePlayer = server.findPlayerExact(record.name) player.message(`&e${rank.color}${record.name}&e ${onlinePlayer ? '&a(online)' : '&7(offline)'}`) if (record.nick) player.message(`&7 Nickname: &f${record.nick}`) + if (record.pronouns) player.message(`&7 Pronouns: &f${record.pronouns}`) player.message(`&7 Rank: ${rank.color}${rank.name}`) if (onlinePlayer) { player.message(`&7 In level: &f${onlinePlayer.level.name}&7, using &f${onlinePlayer.appName}${onlinePlayer.pingMs !== null ? `&7, ping &f${onlinePlayer.pingMs}ms` : ''}`) @@ -493,6 +502,7 @@ module.exports = { } require('./more')(ctx, { find }) + require('./social')(ctx) ctx.on('tabListEntry', (ev) => { if (isAfk(ev.player)) ev.listName += ' &7(AFK)' diff --git a/plugins/core-essentials/social.js b/plugins/core-essentials/social.js new file mode 100644 index 0000000..da72258 --- /dev/null +++ b/plugins/core-essentials/social.js @@ -0,0 +1,120 @@ +'use strict' + +// /vote /timer /pronouns /quit, as in MCGalaxy + +module.exports = function social (ctx) { + const { server, CommandError, text } = ctx + const isOp = p => p.permission >= server.ranks.permissionOf('Operator') + + // a yes/no vote everyone online can answer with /yes or /no (or by typing yes/no in chat) + let vote = null + const finishVote = () => { + if (!vote) return + const { question, yes, no } = vote + vote = null + server.broadcast(`&eVote over: &f${question} &a${yes.size} yes &7- &c${no.size} no`) + } + const answer = (player, choice) => { + if (!vote) throw new CommandError('There is no vote going on.') + const key = player.name.toLowerCase() + vote.yes.delete(key); vote.no.delete(key) + vote[choice].add(key) + player.message(`&eYou voted &f${choice}&e.`) + } + ctx.command({ + name: 'vote', + category: 'chat', + rank: 'Operator', + usage: '/vote | /vote end', + description: 'Asks everyone a yes/no question for 30 seconds', + run (player, args, { usage }) { + if (!args.length) return usage() + if (args[0].toLowerCase() === 'end') { if (!vote) throw new CommandError('There is no vote going on.'); return finishVote() } + if (vote) throw new CommandError('A vote is already going on. /vote end stops it.') + const question = text.sanitize(args.join(' ')) + vote = { question, yes: new Set(), no: new Set(), timer: ctx.setTimeout(finishVote, 30000) } + server.broadcast(`&eVote by ${player.coloredName}&e: &f${question}`) + server.broadcast('&eAnswer with &a/yes&e or &c/no&e (30 seconds).') + } + }) + ctx.command({ name: 'yes', category: 'chat', usage: '/yes', description: 'Votes yes', run (p) { answer(p, 'yes') } }) + ctx.command({ name: 'no', category: 'chat', usage: '/no', description: 'Votes no', run (p) { answer(p, 'no') } }) + + // countdown shown in the middle of everyone's screen + let countdown = null + ctx.command({ + name: 'timer', + category: 'chat', + rank: 'Operator', + usage: '/timer [message] | /timer stop', + description: 'Counts down on everyone\'s screen', + run (player, args, { usage }) { + if ((args[0] || '').toLowerCase() === 'stop') { + if (!countdown) throw new CommandError('No timer is running.') + ctx.clearTimer(countdown); countdown = null + return server.broadcast('&eTimer stopped.', null, 'announce') + } + const seconds = Number(args[0]) + if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600) return usage() + if (countdown) ctx.clearTimer(countdown) + const label = text.sanitize(args.slice(1).join(' ')) || 'Time left' + let left = seconds + const tick = () => { + if (left <= 0) { + ctx.clearTimer(countdown); countdown = null + return server.broadcast(`&e${label}: &cTime's up!`, null, 'announce') + } + if (left <= 10 || left % 10 === 0 || left === seconds) server.broadcast(`&e${label}: &f${text.formatDuration(left * 1000)}`, null, 'announce') + left-- + } + tick() + countdown = ctx.setInterval(tick, 1000) + } + }) + + ctx.command({ + name: 'pronouns', + category: 'essentials', + usage: '/pronouns [player]', + description: 'Sets the pronouns shown in /whois, like they/them', + run (player, args, { usage }) { + if (!args.length) return usage() + let record = player.record + if (args[1]) { + if (!isOp(player)) throw new CommandError('Only operators can set other people\'s pronouns.') + record = server.playerDB.find(args[1]) + if (!record) throw new CommandError('Player not found.') + } + if (!record) throw new CommandError('Give a player name.') + const value = args[0].toLowerCase() === 'clear' ? null : text.stripColors(args[0]).slice(0, 24) + if (value && !/^[A-Za-z]+(\/[A-Za-z]+){0,3}$/.test(value)) throw new CommandError('Use something like she/her, he/him, they/them or any/all.') + record.pronouns = value + server.playerDB.save() + player.message(value ? `&ePronouns set to &f${value}&e.` : '&ePronouns cleared.') + } + }) + + const leave = (player, reason) => player.kick(reason) + ctx.command({ + name: 'quit', + category: 'essentials', + usage: '/quit [message]', + description: 'Leaves the server with a message', + inGame: true, + run (player, args) { leave(player, text.stripColors(args.join(' ')).slice(0, 60) || 'Left the game') } + }) + ctx.command({ + name: 'ragequit', + aliases: ['rq'], + category: 'essentials', + usage: '/ragequit', + description: 'Leaves the server in a hurry', + inGame: true, + run (player) { + server.broadcast(`${player.coloredName} &cragequit!`) + leave(player, 'RAGEQUIT!!') + } + }) + + return { pronounsOf: record => (record && record.pronouns) || null } +} diff --git a/plugins/core-moderation/alts.js b/plugins/core-moderation/alts.js new file mode 100644 index 0000000..fcd41c2 --- /dev/null +++ b/plugins/core-moderation/alts.js @@ -0,0 +1,22 @@ +'use strict' + +// /alts: accounts that have used the same IP address (MCGalaxy's /clones) + +module.exports = function alts (ctx, { findRecord }) { + const { server } = ctx + ctx.command({ + name: 'alts', + category: 'moderation', + rank: 'Operator', + usage: '/alts ', + description: 'Lists other accounts that joined from the same IP addresses', + run (player, args, { usage }) { + if (!args[0]) return usage() + const record = findRecord(args[0]) + const ips = new Set(record.ips || []) + const others = server.playerDB.all().filter(r => r.name.toLowerCase() !== record.name.toLowerCase() && (r.ips || []).some(ip => ips.has(ip))) + if (!others.length) return player.message(`&eNo other accounts share an IP with ${record.name}.`) + player.message(`&eAccounts sharing an IP with ${record.name}: &f${others.map(r => r.name + (r.ban ? ' &c(banned)&f' : '')).join(', ')}`) + } + }) +} diff --git a/plugins/core-moderation/index.js b/plugins/core-moderation/index.js index 79bec94..4c01405 100644 --- a/plugins/core-moderation/index.js +++ b/plugins/core-moderation/index.js @@ -308,7 +308,7 @@ module.exports = { ctx.command({ name: 'vanish', - aliases: ['hide', 'v'], + aliases: ['hide'], category: 'moderation', rank: 'Operator', usage: '/vanish', @@ -334,6 +334,7 @@ module.exports = { }) require('./more')(ctx, { find, findRecord, rankOf, checkHigher, actorName }) + require('./alts')(ctx, { findRecord }) // anti-spam diff --git a/plugins/core-worlds/index.js b/plugins/core-worlds/index.js index c414176..9bebb07 100644 --- a/plugins/core-worlds/index.js +++ b/plugins/core-worlds/index.js @@ -2,6 +2,9 @@ // Multiple levels: create, load, visit, import, backup and customize environment (colors, texture pack, weather). +const fs = require('fs') +const path = require('path') + const PRESETS = { normal: { skyColor: null, cloudColor: null, fogColor: null, shadowColor: null, sunColor: null, skyboxColor: null }, sunny: { skyColor: '70a3f7', cloudColor: 'ffffff', fogColor: 'ffffff', shadowColor: '9b9b9b', sunColor: 'ffffff' }, @@ -254,6 +257,8 @@ module.exports = { player.message(`&7 Build: ${rankName(level.buildRank)}&7+, visit: ${rankName(level.visitRank)}&7+, players here: &f${level.players.length}`) if (level.owners.length) player.message(`&7 Owners: &f${level.owners.join(', ')}`) player.message(`&7 MOTD: &f${level.motd}`) + const rating = level.meta.ratings + if (rating && (rating.likes.length || rating.dislikes.length)) player.message(`&7 Rating: &a${rating.likes.length} likes&7, &c${rating.dislikes.length} dislikes`) const e = level.env player.message(`&7 Texture: &f${e.texture || 'default'}&7, weather: &f${['sun', 'rain', 'snow'][e.weather]}`) player.message(`&7 Custom blocks: &f${Object.keys(level.blockDefs).length}&7 level, &f${Object.keys(server.blockDefs).length}&7 global`) @@ -321,6 +326,7 @@ module.exports = { }) require('./more')(ctx, { levelArg }) + require('./ratings')(ctx) // backups @@ -448,14 +454,26 @@ module.exports = { aliases: ['tex', 'texturepack'], category: 'world', rank: 'Operator', - usage: '/texture [global]', + usage: '/texture [global]', description: 'Sets the texture pack (.zip) or terrain (.png) of your level, or of all levels with "global"', + help: ['Packs you put in the texpacks/ folder are downloaded from the server itself: /texture mypack.zip', '(set publicAddress in config/server.json to your server\'s domain or IP first)'], inGame: true, run (player, args, { usage }) { if (!args[0]) return usage() + if (args[0].toLowerCase() === 'list') { + const dir = path.join(server.root, 'texpacks') + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /\.(zip|png)$/i.test(f)) : [] + return player.message(files.length ? `&eTexture packs in texpacks/: &f${files.join(', ')}` : '&eThe texpacks/ folder is empty.') + } const reset = args[0].toLowerCase() === 'reset' const url = reset ? '' : args[0] - if (!reset) { + const local = !reset && !/^https?:\/\//i.test(url) && /^[A-Za-z0-9_.-]+\.(zip|png)$/i.test(url) + if (local) { + // only the file name is stored; the full URL is made when it's sent, so a new address just works + if (!fs.existsSync(path.join(server.root, 'texpacks', url))) throw new CommandError(`texpacks/${url} doesn't exist.`) + if (!server.texturePackUrl(url)) player.message('&eSet "publicAddress" in config/server.json (your domain or IP) so players can download it.') + } + if (!reset && !local) { 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.') diff --git a/plugins/core-worlds/ratings.js b/plugins/core-worlds/ratings.js new file mode 100644 index 0000000..72f19da --- /dev/null +++ b/plugins/core-worlds/ratings.js @@ -0,0 +1,47 @@ +'use strict' + +// /like /dislike (level ratings, shown in /mapinfo) and /autoload, as in MCGalaxy + +module.exports = function ratings (ctx) { + const { server, CommandError } = ctx + + const ratingsOf = level => (level.meta.ratings = level.meta.ratings || { likes: [], dislikes: [] }) + const rate = (player, like) => { + const level = player.level + const r = ratingsOf(level) + const name = player.name.toLowerCase() + r.likes = r.likes.filter(n => n !== name) + r.dislikes = r.dislikes.filter(n => n !== name) + ;(like ? r.likes : r.dislikes).push(name) + level.dirty = true + player.message(`&eYou ${like ? '&aliked' : '&cdisliked'}&e ${level.name}. It has &a${r.likes.length}&e likes and &c${r.dislikes.length}&e dislikes.`) + } + ctx.command({ name: 'like', category: 'world', usage: '/like', description: 'Rates the level you are in', inGame: true, run (p) { rate(p, true) } }) + ctx.command({ name: 'dislike', category: 'world', usage: '/dislike', description: 'Rates the level you are in', inGame: true, run (p) { rate(p, false) } }) + + ctx.command({ + name: 'autoload', + category: 'world', + rank: 'Admin', + usage: '/autoload [on|off] | /autoload list', + description: 'Levels loaded when the server starts, besides the main one', + run (player, args, { usage }) { + const list = server.config.autoloadLevels || (server.config.autoloadLevels = []) + if (!args[0]) return usage() + if (args[0].toLowerCase() === 'list') { + return player.message(list.length ? `&eLoaded at startup: &f${list.join(', ')}` : '&eNo levels are loaded at startup besides the main one.') + } + const name = server.levels.resolveName(args[0]) + if (!name) throw new CommandError(`There is no level called ${args[0]}.`) + const on = (args[1] || 'on').toLowerCase() !== 'off' + const i = list.findIndex(n => n.toLowerCase() === name.toLowerCase()) + if (on && i === -1) list.push(name) + if (!on && i !== -1) list.splice(i, 1) + server.saveConfig() + if (on && !server.levels.get(name)) server.levels.load(name) + player.message(on ? `&e${name} is loaded at startup.` : `&e${name} is no longer loaded at startup.`) + } + }) + + return { ratingsOf } +} diff --git a/test/level.test.js b/test/level.test.js index b96b206..f4a6a18 100644 --- a/test/level.test.js +++ b/test/level.test.js @@ -89,7 +89,7 @@ test('legacy MCScript level.dat import', () => { }) test('generators produce valid levels', () => { - for (const name of ['empty', 'flat', 'pixel', 'space', 'ocean', 'island', 'terrain']) { + for (const name of ['empty', 'flat', 'pixel', 'space', 'ocean', 'island', 'terrain', 'biomes']) { const level = new Level({ name, width: 64, height: 32, length: 64 }) generators[name].generate(level, { seed: 1 }) assert.ok(level.spawn.y >= 0 && level.spawn.y <= 34, name) diff --git a/test/server.test.js b/test/server.test.js index 36a8c91..3ea0a93 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -929,3 +929,111 @@ test('minigame arenas are remembered and loaded at startup', async (t) => { assert.doesNotMatch(await command(guest, '/zombie join', 400), /There is no/) assert.equal(server.findPlayerExact('Guest').level.name, 'arena') }) + +test('MCGalaxy shortcuts, /main , autoload and the new commands', async (t) => { + const { server, root } = await startServer({ owners: ['Boss'] }) + t.after(() => server.stop()) + const boss = await join(server, 'Boss') + const guest = await join(server, 'Guest') + + // plain shortcuts and shortcuts that carry arguments + assert.equal(server.commands.find('gen').name, 'newlvl') + assert.equal(server.commands.find('ld').name, 'lockdown') + assert.equal(server.commands.find('v').name, 'paste') + assert.equal(server.commands.find('pl').name, 'place') + assert.deepEqual(server.commands.aliasArgs('zadd'), ['add']) + assert.match(await command(boss, '/gen second 64 32 64 flat', 400), /created|Created/) + assert.match(await command(boss, '/zonelist'), /zone|Zone/) + assert.match(await command(boss, '/help zadd'), /\/zone/) + + // /main + assert.match(await command(guest, '/main second'), /Only admins/) + assert.match(await command(boss, '/main second'), /second is now the main level/) + assert.equal(server.levels.main.name, 'second') + assert.equal(JSON.parse(require('fs').readFileSync(require('path').join(root, 'config', 'server.json'), 'utf8')).mainLevel, 'second') + await command(boss, '/main main') + + // autoload + assert.match(await command(boss, '/autoload second'), /loaded at startup/) + assert.deepEqual(server.config.autoloadLevels, ['second']) + + // ratings show up in /mapinfo + await command(guest, '/like') + assert.match(await command(guest, '/mapinfo'), /1 likes/) + + // votes + await command(boss, '/vote Build a castle?') + assert.match(await command(guest, '/yes'), /voted &fyes/) + assert.match(await command(boss, '/vote end'), /1 yes .*0 no/) + + // pronouns in /whois + assert.match(await command(guest, '/pronouns they/them'), /they\/them/) + assert.match(await command(boss, '/whois Guest'), /Pronouns: &fthey\/them/) + assert.match(await command(guest, '/pronouns