From cdbf08de3b55f0eb9d7e055b045ca442362f5407 Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:29:27 +0000 Subject: [PATCH 1/3] Ignore movement packets sent before a teleport arrived The client keeps reporting its old position until it receives the teleport (or the new level), and those packets were taken as the new position. After walking into a portal the player could be moved back or trigger portals and checkpoints at the old coordinates in the new level. For two seconds after the server moves a player, packets that are still next to the old spot and far from the new one are dropped; the first packet near the new position ends that early. --- CHANGELOG.md | 3 +++ lib/player.js | 20 ++++++++++++++++++++ test/server.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cea9094..b324060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ - `staffChat` event, and `createConsoleActor` can take a rank. ### Fixed +- Right after a teleport or a level change, movement packets the client had sent from its old position + were taken as its new position. That could fire portals, message blocks or parkour checkpoints in the + wrong place (walking into a portal could bounce you straight back). Those late packets are now ignored. - Several commands skipped rank checks for anything that was not a player (for example "only give ranks lower than your own"). They now compare permission levels, so actions from Discord or other relays follow the rank rules. The real console is unaffected. diff --git a/lib/player.js b/lib/player.js index 31b58e4..d2058db 100644 --- a/lib/player.js +++ b/lib/player.js @@ -355,6 +355,7 @@ class Player extends EventEmitter { if (from && !opts.keepPosition) this.rememberPosition() const at = opts.keepPosition ? { ...this.feetPos, yaw: this.yaw, pitch: this.pitch } : level.spawn + if (!opts.keepPosition) this._movedByServer() this.spawned = false this.level = level if (this.selection && from !== level) this.cancelSelection() @@ -569,6 +570,7 @@ class Player extends EventEmitter { teleport (x, y, z, yaw = this.yaw, pitch = this.pitch) { this.rememberPosition() + this._movedByServer() this.pos = { x: toUnits(x), y: toUnits(y) + EYE, z: toUnits(z) } this.yaw = yaw & 255 this.pitch = pitch & 255 @@ -773,8 +775,26 @@ class Player extends EventEmitter { // incoming packets + // The client keeps sending where it was until it gets the teleport (or the new level), so for a + // moment packets from the old spot are still arriving. Remember that spot to drop them. + _movedByServer () { + if (this.pos && this.spawned) this._staleFrom = { pos: this.pos, until: Date.now() + 2000 } + } + + _isStale (p) { + const stale = this._staleFrom + if (!stale) return false + const dist = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z)) + if (Date.now() > stale.until || dist(p, this.pos) <= 64) { + this._staleFrom = null // the client caught up (or it's been long enough) + return false + } + return dist(p, stale.pos) <= 128 + } + _onPosition (p) { if (!this.spawned || this.loadingLevel) return + if (this._isStale(p)) return this.lastActivity = Date.now() if (this.supports('HeldBlock')) this.heldBlock = p.heldBlock diff --git a/test/server.test.js b/test/server.test.js index 7c6144f..80d942d 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -623,6 +623,8 @@ test('minigames: capture the flag', async (t) => { 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]] + // like a real client, first confirm the team spawn the round teleported us to + moveTo(op, me.feetPos.x, me.feetPos.y, me.feetPos.z); await sleep(50) 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) @@ -865,3 +867,40 @@ test('plugins can be turned off and back on, and it survives a restart', async ( assert.ok(current.plugins.get('warps')) assert.deepEqual(current.config.disabledPlugins, []) }) + +test('position packets sent before a teleport or level change arrived are ignored', async (t) => { + const { server } = await startServer({ owners: ['Runner'] }) + t.after(() => server.stop()) + await server.commands.execute(server.console, '/newlvl other 64 32 64 flat') + const c = await join(server, 'Runner') + const p = server.findPlayerExact('Runner') + const units = (x, y, z) => ({ id: -1, x: Math.round(x * 32), y: Math.round(y * 32) + 51, z: Math.round(z * 32), yaw: 0, pitch: 0 }) + + // walking around at (10, 17, 10) + c.send('position', units(10.5, 17, 10.5)) + await sleep(100) + assert.equal(p.blockPos.x, 10) + + // the server teleports the player, but a packet from the old spot is still on its way + p.teleport(40.5, 17, 40.5) + c.send('position', units(11, 17, 10.5)) + await sleep(100) + assert.equal(p.blockPos.x, 40, 'the late packet did not pull the player back') + + // once the client moves from the new spot everything is normal again + c.send('position', units(41.5, 17, 40.5)) + c.send('position', units(12.5, 17, 12.5)) + await sleep(100) + assert.equal(p.blockPos.x, 12) + + // same after changing level: old coordinates don't leak into the new level + let moves = 0 + server.events.on('playerMove', () => { moves++ }) + p.changeLevel(server.levels.get('other')) + await c.waitFor(pk => pk.name === 'levelFinalize') + c.send('position', units(12.5, 17, 13)) + await sleep(100) + assert.equal(moves, 0) + const spawn = server.levels.get('other').spawn + assert.equal(p.blockPos.x, Math.floor(spawn.x)) +}) From 2ad0a4d69b75b49617885703d2cbf1d70afa9547 Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:34:04 +0000 Subject: [PATCH 2/3] Let a supervisor handle /restart /restart started a new copy of the server and exited. Under systemd, pm2 or a start script loop the supervisor then started another one, and the two fought over the port. With MCSCRIPT_SUPERVISED set, /restart only exits. --- CHANGELOG.md | 2 ++ README.md | 5 ++++- lib/commands/core.js | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b324060..4d80868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - `staffChat` event, and `createConsoleActor` can take a rank. ### Fixed +- `/restart` under a supervisor (systemd, pm2, a start script loop) started a second copy of the server. + With `MCSCRIPT_SUPERVISED=1` set it now just exits and lets the supervisor restart it. - Right after a teleport or a level change, movement packets the client had sent from its old position were taken as its new position. That could fire portals, message blocks or parkour checkpoints in the wrong place (walking into a portal could bounce you straight back). Those late packets are now ignored. diff --git a/README.md b/README.md index 46a40cf..6d84f9a 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,10 @@ 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. +server as root. When something else restarts the server (systemd, pm2 or a start script with a loop), set the +environment variable `MCSCRIPT_SUPERVISED=1` (for systemd: `Environment=MCSCRIPT_SUPERVISED=1` under +`[Service]`). `/restart` then just exits and lets the supervisor start it again, instead of starting a second +copy itself. **Any system with pm2:** diff --git a/lib/commands/core.js b/lib/commands/core.js index ed392a9..bb5071e 100644 --- a/lib/commands/core.js +++ b/lib/commands/core.js @@ -276,6 +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) const { spawn } = require('child_process') spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit', cwd: process.cwd(), detached: true }).unref() process.exit(0) From 3e6b87da82f88ccf1bb7f9a454b1a2e2ec0e414d Mon Sep 17 00:00:00 2001 From: perronosaurio <27968093+perronosaurio@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:37:34 +0000 Subject: [PATCH 3/3] Remember minigame arenas; keep the first spot when moved twice Arenas enabled with / enable are stored and loaded at startup, so / join works from the lobby instead of only from a loaded arena. /warp changes level and then teleports; the second move replaced the spot the client was really coming from, so a normal step near the new spawn looked like a late packet. Keep the first spot while it's fresh and only drop packets closer to it than to the new position. --- CHANGELOG.md | 2 ++ lib/player.js | 12 +++++++++--- plugins/minigames/game-command.js | 18 +++++++++++++++++- test/server.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d80868..552c531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - Tests that play ViaFabricPlus (ViaLegacy) and the vanilla Classic 0.30 client and check they only get packets they understand. - `staffChat` event, and `createConsoleActor` can take a rank. +- Minigame arenas are remembered: levels enabled with `/tntwars enable`, `/ctf enable` or `/zombie enable` are + loaded at startup, so `/ join` works from any level. `/ join ` loads the arena if needed. ### Fixed - `/restart` under a supervisor (systemd, pm2, a start script loop) started a second copy of the server. diff --git a/lib/player.js b/lib/player.js index d2058db..0c0c086 100644 --- a/lib/player.js +++ b/lib/player.js @@ -778,18 +778,24 @@ class Player extends EventEmitter { // The client keeps sending where it was until it gets the teleport (or the new level), so for a // moment packets from the old spot are still arriving. Remember that spot to drop them. _movedByServer () { - if (this.pos && this.spawned) this._staleFrom = { pos: this.pos, until: Date.now() + 2000 } + if (!this.pos || !this.spawned) return + const now = Date.now() + // two moves in a row (a level change, then a teleport): the client is still at the first spot + if (this._staleFrom && now < this._staleFrom.until) this._staleFrom.until = now + 2000 + else this._staleFrom = { pos: this.pos, until: now + 2000 } } _isStale (p) { const stale = this._staleFrom if (!stale) return false const dist = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z)) - if (Date.now() > stale.until || dist(p, this.pos) <= 64) { + const toNew = dist(p, this.pos) + if (Date.now() > stale.until || toNew <= 64) { this._staleFrom = null // the client caught up (or it's been long enough) return false } - return dist(p, stale.pos) <= 128 + const toOld = dist(p, stale.pos) + return toOld <= 128 && toOld < toNew } _onPosition (p) { diff --git a/plugins/minigames/game-command.js b/plugins/minigames/game-command.js index 663ebe6..c13acc9 100644 --- a/plugins/minigames/game-command.js +++ b/plugins/minigames/game-command.js @@ -8,6 +8,18 @@ module.exports = function gameCommand (ctx, { name, aliases = [], title, metaKey const rounds = new Map() // level name -> Round const configured = () => [...server.levels.loaded.values()].filter(l => l.meta[metaKey]) + + // Arenas are remembered, so they are loaded at startup and / join finds them from any level + const arenaData = ctx.loadData('arenas.json', {}) + const arenas = new Set(arenaData[metaKey] || []) + const saveArenas = () => { arenaData[metaKey] = [...arenas].sort(); ctx.saveData('arenas.json', arenaData) } + const loadArena = (levelName) => { + const level = server.levels.get(levelName) + if (level) return level + if (!server.levels.exists(levelName)) return null + try { return server.levels.load(levelName) } catch (err) { ctx.log.warn(`Could not load ${title} arena ${levelName}: ${err.message}`); return null } + } + for (const levelName of arenas) loadArena(levelName) const roundFor = (level) => { let r = rounds.get(level.name) if (!r) { r = createRound(level); rounds.set(level.name, r) } @@ -36,7 +48,7 @@ module.exports = function gameCommand (ctx, { name, aliases = [], title, metaKey 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 + let level = args[1] ? loadArena(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) @@ -59,12 +71,16 @@ module.exports = function gameCommand (ctx, { name, aliases = [], title, metaKey needOp() player.level.meta[metaKey] = player.level.meta[metaKey] || {} player.level.dirty = true + arenas.add(player.level.name) + saveArenas() 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 + arenas.delete(player.level.name) + saveArenas() return player.message(`&a${player.level.name} is no longer a ${title} arena.`) case 'start': { needOp() diff --git a/test/server.test.js b/test/server.test.js index 80d942d..36a8c91 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -903,4 +903,29 @@ test('position packets sent before a teleport or level change arrived are ignore assert.equal(moves, 0) const spawn = server.levels.get('other').spawn assert.equal(p.blockPos.x, Math.floor(spawn.x)) + + // a level change followed by a teleport (what /warp does): normal moves near the new spot still count + p.changeLevel(server.levels.main) + p.teleport(30.5, 17, 30.5) + await c.waitFor(pk => pk.name === 'levelFinalize') + c.send('position', units(34.5, 17, 30.5)) + await sleep(100) + assert.equal(p.blockPos.x, 34) +}) + +test('minigame arenas are remembered and loaded at startup', async (t) => { + const first = await startServer({ owners: ['Op'] }) + await first.server.commands.execute(first.server.console, '/newlvl arena 64 32 64 flat') + const op = await join(first.server, 'Op') + await command(op, '/goto arena', 400) + assert.match(await command(op, '/zombie enable'), /now a Zombie Survival arena/) + op.close() + await first.server.stop() + + const { server } = await startServer({}, first.root) + t.after(() => server.stop()) + assert.ok(server.levels.get('arena'), 'the arena is loaded without anyone visiting it') + const guest = await join(server, 'Guest') + assert.doesNotMatch(await command(guest, '/zombie join', 400), /There is no/) + assert.equal(server.findPlayerExact('Guest').level.name, 'arena') })