commit 8cd92694e7fa924f42b82c6a72056278fdc77b6c Author: s8n-ru <279801990+s8n-ru@users.noreply.github.com> Date: Thu Apr 30 18:23:13 2026 +0100 Initial commit: RackedLimbo 1.0.0 Auth-limbo + login-restore fix for Paper 1.21+. Bypasses the AuthMe `teleportOnLogin` race (PaperMC/Paper#4085) by listening at MONITOR priority, reading the player's saved quit-location from AuthMe's SQLite DB, pinning the destination chunk via addPluginChunkTicket, then chaining getChunkAtAsyncUrgently and teleportAsync. Bundles a void auth_limbo world via custom ChunkGenerator so the plugin removes the need for Multiverse-Core for offline-mode auth flows. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9017655 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,61 @@ +--- +name: Bug report +about: Report a problem with RackedLimbo +title: "[bug] " +labels: bug +assignees: '' +--- + +## Summary + +A one-paragraph description of what is broken. + +## Environment + +| Field | Value | +|-------------------------|----------------------------------------| +| RackedLimbo version | e.g. 1.0.0 | +| Server software | Paper / Purpur / Folia | +| Server version | e.g. 1.21.11 build #142 | +| Java version | output of `java -version` | +| AuthMe variant | e.g. AuthMe-ReReloaded HaHaWTH b49 | +| OS | e.g. Debian 13, Windows 11, Docker | +| Other auth/world plugins| e.g. Multiverse-Core, Essentials, ... | + +## Steps to reproduce + +1. ... +2. ... +3. ... + +## Expected behaviour + +What you expected to happen. + +## Actual behaviour + +What actually happened. Include exact log lines. + +## Logs + +Paste the relevant section of `logs/latest.log` here. Search for +`RackedLimbo` to filter our log lines. If you are running with +`debug: true` in `config.yml`, please include those lines as well. + +``` +(paste logs) +``` + +## Configuration + +If you have customised `plugins/RackedLimbo/config.yml`, paste the +relevant keys here. + +```yaml +(paste config) +``` + +## Additional context + +Anything else that might be relevant. Screenshots, stack traces, +related plugin versions, recent changes to the server. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..beb687a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,46 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + # The AuthMe jar is a system-scope dependency referenced by pom.xml + # but is not redistributed in this repo. Set the AUTHME_JAR_URL repo + # secret to a direct download URL for AuthMe-5.6.0-FORK-Universal.jar + # before this workflow can succeed. See lib/README.md for context. + - name: Fetch AuthMe jar + env: + AUTHME_JAR_URL: ${{ secrets.AUTHME_JAR_URL }} + run: | + if [ -z "$AUTHME_JAR_URL" ]; then + echo "::error::AUTHME_JAR_URL secret is not set. See lib/README.md." + exit 1 + fi + curl -fsSL "$AUTHME_JAR_URL" -o lib/AuthMe-5.6.0-FORK-Universal.jar + ls -la lib/ + + - name: Build with Maven + run: mvn -B -ntp package + + - name: Upload jar artifact + uses: actions/upload-artifact@v4 + with: + name: RackedLimbo-jar + path: target/RackedLimbo-*.jar + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..15d9c60 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + # See .github/workflows/build.yml for context on this secret. + - name: Fetch AuthMe jar + env: + AUTHME_JAR_URL: ${{ secrets.AUTHME_JAR_URL }} + run: | + if [ -z "$AUTHME_JAR_URL" ]; then + echo "::error::AUTHME_JAR_URL secret is not set. See lib/README.md." + exit 1 + fi + curl -fsSL "$AUTHME_JAR_URL" -o lib/AuthMe-5.6.0-FORK-Universal.jar + + - name: Build with Maven + run: mvn -B -ntp package + + - name: Create GitHub release + uses: softprops/action-gh-release@v1 + with: + files: target/RackedLimbo-*.jar + generate_release_notes: true + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4cafd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Build output +target/ +*.class + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.project +.classpath +.settings/ + +# Logs and OS +*.log +.DS_Store +Thumbs.db + +# Maven wrapper (we don't ship one) +.mvn/ +mvnw +mvnw.cmd +lib/AuthMe-*.jar diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47d2f5f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to RackedLimbo are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-04-30 + +Initial public release. + +### Added +- Auth-limbo void world manager. Creates and configures `auth_limbo` world + with no Multiverse-Core dependency. +- AuthMe `LoginEvent` listener at `MONITOR` priority. Reads saved quit + coordinates directly from `plugins/AuthMe/authme.db` (SQLite, read-only) + and forces a delayed `teleportAsync` to those coordinates after AuthMe's + own broken teleport runs. +- `AuthMeAsyncPreLoginEvent` listener for chunk preload. Pins the + destination chunk via `Chunk#addPluginChunkTicket` before login + completes to avoid the unloaded-chunk race documented in + [Paper #4085](https://github.com/PaperMC/Paper/issues/4085). Tickets + are released 5 seconds after the teleport completes. +- Optional 5x5 barrier platform at limbo spawn so unauth players can't + fall into the void. +- `/rackedlimbo reload` and `/rackedlimbo tp ` admin commands + gated on `rackedlimbo.admin`. +- Shaded SQLite JDBC driver (`org.xerial:sqlite-jdbc 3.46.1.3`, + relocated to `ru.racked.limbo.shaded.sqlite`) so the plugin reads + AuthMe's database without classpath collisions. + +### Compatibility +- Paper / Purpur 1.21.11 (api-version `1.21`). +- Java 21. +- AuthMe-ReReloaded HaHaWTH fork b49 (`fr.xephi:authme:5.6.0-FORK-b49`) + as a hard dependency. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8b4b95 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 s8n-ru / racked.ru + +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 new file mode 100644 index 0000000..0d223c6 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# RackedLimbo + +Auth-limbo + login-restore fix for Paper 1.21+. + +[![Build](https://github.com/s8n-ru/racked-limbo/actions/workflows/build.yml/badge.svg)](https://github.com/s8n-ru/racked-limbo/actions/workflows/build.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg)](LICENSE) +[![Paper](https://img.shields.io/badge/Paper-1.21.11%2B-lightgrey.svg)](https://papermc.io/) +[![Java](https://img.shields.io/badge/Java-21%2B-lightgrey.svg)](https://adoptium.net/) + +A small Paper plugin that fixes a chronic AuthMe-ReReloaded post-login +teleport bug and bundles a void auth-limbo world so you don't need +Multiverse-Core just to host one void world. + +--- + +## The problem + +After a player runs `/login`, AuthMe is supposed to teleport them back +to the coordinates they had when they last quit. In current +AuthMe-ReReloaded forks (verified on the HaHaWTH fork b49), this +restore-teleport often fails: players land at world spawn instead of +their saved location. + +Root cause is a known Paper teleport-race documented at +[PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085). +AuthMe's `teleportOnLogin` flow calls `Player#teleportAsync` without +preloading the destination chunk first. The future resolves before the +chunk is loaded, Paper's safety logic then snaps the player back to the +nearest loaded position, and that position is world spawn. + +We tried fixing this in AuthMe config. We tried removing Multiverse. +The bug kept reappearing. RackedLimbo is the long-term fix. + +--- + +## What this plugin does + +Two things, narrowly. + +1. **Hosts a void `auth_limbo` world.** A custom `ChunkGenerator` + produces empty chunks, an optional 5x5 barrier platform sits under + spawn, and the world is configured with no daylight cycle, no + weather, no mob spawning, and no PvP. AuthMe can use it as its + pre-auth limbo without you installing Multiverse-Core. + +2. **Overrides AuthMe's restore-teleport.** A `LoginEvent` listener at + `MONITOR` priority runs *after* AuthMe's own broken teleport, reads + the player's saved quit-location directly from + `plugins/AuthMe/authme.db`, pins the destination chunk via + `Chunk#addPluginChunkTicket`, then chains + `World#getChunkAtAsyncUrgently` into an authoritative + `Player#teleportAsync`. This is the canonical fix described in + [PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085) + and used by + [PaperLib's `AsyncTeleportPaper`](https://github.com/PaperMC/PaperLib). + +That is the entire surface area. No password handling, no permissions +beyond admin commands, no register flow, no chat formatting. AuthMe owns +all of that. + +--- + +## Install + +1. Download `RackedLimbo-1.0.0.jar` from the + [Releases page](https://github.com/s8n-ru/racked-limbo/releases). +2. Drop it into your server's `plugins/` directory. +3. Restart the server (do not use `/reload`). + +AuthMe-ReReloaded is a **hard dependency**. The plugin will refuse to +load without it. + +For the `itzg/minecraft-server` Docker image, see +[`docs/installation.md`](docs/installation.md) for the `PLUGINS:` +environment variable form. + +--- + +## Configuration + +`plugins/RackedLimbo/config.yml` is created on first start. Defaults: + +```yaml +limbo: + world: auth_limbo # Bukkit world name + spawn-x: 0.5 + spawn-y: 128.0 + spawn-z: 0.5 + build-platform: true # 5x5 barrier under spawn + platform-y: 127 + +authme: + db-path: plugins/AuthMe/authme.db + teleport-delay-ticks: 10 # ticks to wait after LoginEvent + preload-chunks: true # forceload chunk before teleport + +debug: false # verbose logging +``` + +Full reference in [`docs/configuration.md`](docs/configuration.md). + +--- + +## Commands + +| Command | Permission | Effect | +|----------------------------|---------------------|-----------------------------------------------------| +| `/rackedlimbo reload` | `rackedlimbo.admin` | Reload `config.yml`. | +| `/rackedlimbo tp ` | `rackedlimbo.admin` | Manually teleport a player to their saved location. | + +Aliases: `/rlimbo`. + +--- + +## Compatibility + +| Component | Status | Notes | +|-----------------------------------|----------|----------------------------------------| +| Paper 1.21.11 | Yes | Primary target. | +| Purpur 1.21.11 | Yes | Same Paper API surface. | +| Folia | Unknown | Untested. Login event threading may differ. | +| AuthMe-ReReloaded (HaHaWTH b49) | Yes | Verified on production server. | +| AuthMe-ReReloaded other 5.x forks | Untested | Schema is the same, should work. | +| Multiverse-Core | Untested | Not required. Possible teleport-intercept conflict — see [`docs/compatibility.md`](docs/compatibility.md). | + +--- + +## Build from source + +```bash +mvn clean package +``` + +The shaded jar lands at `target/RackedLimbo-1.0.0.jar`. Requires Java +21+ and a Maven 3.9+ install. The `lib/AuthMe-5.6.0-FORK-Universal.jar` +in the repo is referenced as a `system`-scope dependency so the build +does not need any private repository credentials. + +--- + +## Why not just use Multiverse-Core for the void world? + +Multiverse-Core is a 2 MB plugin that does world creation, world +listing, dimension portals, world-specific permissions, world inventory +separation, and a dozen other things. It also intercepts teleports for +its own portal and respawn logic, which is exactly the contention point +we are trying to avoid here. RackedLimbo is ~400 lines of code and only +manages the one void world AuthMe needs. If you are already running +Multiverse for other reasons, you can ignore the limbo manager and only +benefit from the LoginEvent fix. + +--- + +## License + +MIT. See [`LICENSE`](LICENSE). + +--- + +## Author + +Built by [s8n-ru](https://github.com/s8n-ru) for [racked.ru](https://racked.ru/). diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..08413dc --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,51 @@ +# Compatibility + +## Tested matrix + +| Component | Version | Status | Notes | +|-----------|---------|--------|-------| +| Paper | 1.21.11 | Verified | Primary target. Builds against `1.21.11-R0.1-SNAPSHOT`. | +| Purpur | 1.21.11 | Expected to work | Purpur is a Paper fork with a superset of the Paper API used here. Untested in CI but the relevant API surface (`teleportAsync`, `setChunkForceLoaded`, modern `ChunkGenerator`) is identical. | +| Folia | any | Untested | Folia's regionised threading model changes how `LoginEvent` is dispatched. The current `Bukkit.getScheduler().runTaskLater` calls would need to be reworked to use Folia's region scheduler. Do not run this plugin on Folia until it has been audited. | +| Java | 21 | Required | `pom.xml` targets Java 21. | +| Java | 22 / 23 / 24 | Expected to work | Source compatibility level is 21. No reflection or unsafe APIs are used. | + +## Auth plugins + +| Plugin | Version | Status | Notes | +|--------|---------|--------|-------| +| AuthMe-ReReloaded (HaHaWTH fork) | b49 | Verified | Hard dependency. The fork's SQLite schema is the one we read. | +| AuthMe-ReReloaded (other 5.x forks) | any | Expected to work | Other forks track the same upstream schema (`authme` table with `username, x, y, z, world, yaw, pitch`). The plugin will compile and load against any 5.x AuthMe binary because we depend on `fr.xephi.authme.events.LoginEvent` which has been stable since 5.0. | +| AuthMe-ReReloaded (6.x) | any | Untested | API likely the same; no reason to expect a break. | +| Original AuthMe (`fr.xephi:authme` 5.x) | any | Expected to work | Same Java package, same event class. | +| nLogin / FastLogin / SimpleAuth / others | n/a | Not supported | This plugin is hard-coupled to AuthMe events. It will refuse to load without `AuthMe` in `plugin.yml`. | +| MySQL-backed AuthMe | n/a | Not supported (yet) | The plugin reads `authme.db` directly via SQLite JDBC. If your AuthMe stores data in MySQL, the LoginEvent restore is a no-op. | + +## Other plugins + +| Plugin | Status | Notes | +|--------|--------|-------| +| Multiverse-Core | Untested | Multiverse intercepts teleports for portals and respawn. The two plugins should coexist because RackedLimbo runs at `MONITOR` priority and overrides whatever Multiverse does to the post-login location. If you only run Multiverse for the limbo world, you do not need it any more — RackedLimbo's `LimboWorldManager` covers that case. | +| EssentialsX | Untested | Essentials' spawn-on-join (`spawn-on-join` in `essentials.yml`) is the most common conflicting feature. With Essentials' default ordering, RackedLimbo's `MONITOR` listener still runs last and wins. If you see the spawn-on-join location winning, set `spawn-on-join: false`. | +| WorldGuard | Should not interfere | WorldGuard does not teleport on login. | +| BetterReload / PlugManX | Avoid | Hot-reloading any of (RackedLimbo, AuthMe, Paper itself) will leave dangling listeners. Always restart. | + +## Database compatibility + +The plugin opens `plugins/AuthMe/authme.db` with a fresh JDBC connection +per query. AuthMe also keeps a connection open to the same file. SQLite +supports multiple readers under WAL mode, which is AuthMe's default; we +never `PRAGMA journal_mode` ourselves, never write, and never wrap the +read in a transaction. There is no risk of corruption from this access +pattern. + +If AuthMe is configured to use MySQL or PostgreSQL instead of SQLite, +this plugin will log a warning on first lookup and the LoginEvent +restore will be skipped. A MySQL backend is on the wishlist but not +implemented. + +## Reporting compatibility results + +If you run this plugin on a configuration not covered above, please +[open an issue](https://github.com/s8n-ru/racked-limbo/issues/new?template=bug_report.md) +or PR an update to this table. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..2835645 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,142 @@ +# Configuration reference + +`plugins/RackedLimbo/config.yml` is created on first start with the +defaults below. Reload at runtime with `/rackedlimbo reload`. + +```yaml +limbo: + world: auth_limbo + spawn-x: 0.5 + spawn-y: 128.0 + spawn-z: 0.5 + build-platform: true + platform-y: 127 + +authme: + db-path: plugins/AuthMe/authme.db + teleport-delay-ticks: 10 + preload-chunks: true + +debug: false +``` + +## `limbo.*` + +Settings for the void auth-limbo world. The plugin creates this world +on enable if it does not already exist. + +### `limbo.world` + +| | | +|---|---| +| Type | string | +| Default | `auth_limbo` | + +The Bukkit world name. The world folder will live at the server data +root (`/`). Changing this **after** the world has been +created will cause the plugin to create a second world with the new +name on next start; the old one is left on disk and you can delete it +manually. + +### `limbo.spawn-x`, `limbo.spawn-y`, `limbo.spawn-z` + +| | | +|---|---| +| Type | double | +| Defaults | `0.5`, `128.0`, `0.5` | + +World spawn coordinates inside `auth_limbo`. The `.5` offsets put the +player on the centre of a block, which avoids fall-through edge cases +when standing on the barrier platform. + +### `limbo.build-platform` + +| | | +|---|---| +| Type | boolean | +| Default | `true` | + +If true, the plugin builds a 5x5 patch of `BARRIER` blocks at +`platform-y` directly under spawn so unauth players cannot fall into +the void. Disable if you want to manage the platform yourself with a +schematic. + +### `limbo.platform-y` + +| | | +|---|---| +| Type | integer | +| Default | `127` | + +Y-level of the barrier platform. Should be exactly one below `spawn-y` +so players land on top of the barriers. + +## `authme.*` + +Settings for the AuthMe integration. + +### `authme.db-path` + +| | | +|---|---| +| Type | string | +| Default | `plugins/AuthMe/authme.db` | + +Path to AuthMe's SQLite database. Resolved relative to the server data +directory if not absolute. Only SQLite is supported — if your AuthMe +deployment uses MySQL, this plugin won't read your data and the +LoginEvent restore will be a no-op. + +### `authme.teleport-delay-ticks` + +| | | +|---|---| +| Type | long | +| Default | `10` (≈ 0.5 s at 20 TPS) | + +How many ticks to wait after AuthMe's `LoginEvent` fires before issuing +our authoritative teleport. The delay exists so AuthMe's own (broken) +teleport runs first; we then overwrite the player position with the +correct one. + +If you see players briefly appearing at world spawn before snapping to +their saved location, lower this value. If you see the saved-location +teleport fail because some other plugin teleports the player in +between, raise it. + +### `authme.preload-chunks` + +| | | +|---|---| +| Type | boolean | +| Default | `true` | + +If true, the plugin listens to `AuthMeAsyncPreLoginEvent` and pins the +chunk at the saved quit-location with `Chunk#addPluginChunkTicket` +before the password check completes. This warms the chunk so the actual +`getChunkAtAsyncUrgently` + `teleportAsync` chain roughly 1 second +later does not race against an unloaded chunk +([PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085)). + +The ticket is released ~5 seconds after the teleport completes — long +enough for the client to download the chunk before it can unload. + +Disabling this is safe — the post-login `getChunkAtAsyncUrgently` call +will still load the chunk before teleporting — but you may see the +original teleport bug re-emerge under load if AuthMe's own teleport +fires before the chunk is ready. + +## `debug` + +| | | +|---|---| +| Type | boolean | +| Default | `false` | + +If true, the plugin logs every chunk forceload, every teleport result, +and every "no saved location" decision at INFO level. Useful when +diagnosing a player report; noisy in production. + +`limbo.world` cannot be changed without a full server restart — the +limbo world is created during `onEnable`. Other keys take effect on +`/rackedlimbo reload`. diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..d0843e7 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,142 @@ +# How it works + +A technical walkthrough of the bug RackedLimbo fixes and how the fix is +implemented. + +## The bug + +AuthMe-ReReloaded saves the player's last quit-location into its +`authme` SQLite table on `PlayerQuitEvent`. On `/login`, AuthMe calls +its internal `teleportOnLogin` flow, which builds a `Location` from the +DB row and invokes `Player#teleportAsync(savedLocation)`. + +In current Paper builds, `teleportAsync` does **not** preload the +destination chunk before resolving the future. If the saved location is +in a chunk that is currently unloaded (which is the common case at +login — the player just connected, almost nothing is loaded yet), Paper +queues the chunk load asynchronously and returns a future that +"succeeds" once the player object's position field has been updated. + +Several things can then race against the actual chunk arrival: + +- Paper's `unknown-command-message`-style safety chunks get checked, + the destination still hasn't loaded, and the player gets snapped to + spawn. +- Other plugins (Multiverse-Core respawn, Essentials spawn-on-join, + generic safe-teleport guards) listen at the same priority and rewrite + the location. +- A `PlayerJoinEvent` listener in another plugin teleports the player + while the AuthMe teleport future is still in flight. + +The end state is the same regardless of which racer wins: the player +is at world spawn instead of where they logged out. This is documented +upstream as [PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085). + +## The fix + +RackedLimbo intercepts the post-login flow at two points. + +### 1. Pre-login: pin the chunk + +`AuthMeAsyncPreLoginEvent` fires on an AuthMe worker thread before the +password is checked. We listen at `MONITOR` priority, look up the saved +quit-location in `authme.db`, and schedule a main-thread task that +calls `Chunk#addPluginChunkTicket(plugin)` on the chunk containing the +saved coordinates. + +A plugin chunk ticket is the canonical Paper API for "keep this chunk +loaded for me until I release it". It does not survive a server restart +and it is per-plugin scoped, so we cannot accidentally clobber another +plugin's ticket. + +This guarantees the chunk is loaded by the time AuthMe actually +authenticates the password ~1 second later, so the destination is no +longer unloaded when the teleport happens. + +### 2. Post-login: authoritative teleport + +`LoginEvent` fires after AuthMe authenticates and runs its own +(broken) restore-teleport. We listen at `MONITOR` priority — the +documented contract is that monitor listeners run *last* in the chain +([Bukkit Javadoc on EventPriority][priority]). So at the point our +handler runs, AuthMe has already done its thing and any other plugin +that listens at lower priority has too. + +We schedule a main-thread task with a configurable delay +(`authme.teleport-delay-ticks`, default 10 ticks ≈ 0.5 s) that: + +1. Re-reads the saved location from `authme.db` (cheap; SQLite, single + prepared statement). +2. Verifies the player is still online and the saved world is loaded. +3. Adds a plugin chunk ticket on the destination if one is not already + active from the pre-login phase. +4. Calls `World#getChunkAtAsyncUrgently(cx, cz)` and chains the + teleport onto its completion. This is the same pattern PaperLib's + `AsyncTeleportPaper` uses, and is the fix called out by Paper + maintainers in [PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085). +5. Inside that callback, calls `Player#teleportAsync(saved, TeleportCause.PLUGIN)`. +6. On the resulting future's success callback, schedules a second task + to release the chunk ticket 5 seconds later — long enough for the + client to download the chunk before it is allowed to unload. + +If any future fails (chunk load timeout, player disconnect mid-teleport, +teleportAsync rejection), the ticket release runs anyway via the +`exceptionally` branches. Tickets cannot leak across server restarts. + +## Why this is reliable + +- The `addPluginChunkTicket` + `getChunkAtAsyncUrgently` + + `teleportAsync` chain is the canonical fix called out by Paper + maintainers in + [PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085) + and is exactly what + [PaperLib's `AsyncTeleportPaper`](https://github.com/PaperMC/PaperLib) + does internally. +- `MONITOR` priority is contractually the last priority that runs. + Listening here means we are not in a race with other plugins that + might also try to teleport on login. +- We never modify AuthMe's database. All reads are through a + short-lived JDBC connection on a fresh `DriverManager#getConnection`, + so we cannot deadlock with AuthMe's own connection pool. +- The shaded SQLite driver + (`org.xerial:sqlite-jdbc:3.46.1.3`, relocated to + `ru.racked.limbo.shaded.sqlite`) means we do not depend on whatever + driver AuthMe ships with, and we cannot accidentally clobber it. + +## Code map + +All Java sources live under `src/main/java/ru/racked/limbo/`. + +| File | Responsibility | +|------|----------------| +| `RackedLimbo.java` | `JavaPlugin` entry point. Loads config, builds the limbo world, registers the listener, exposes `/rackedlimbo` admin commands. | +| `AuthMeDatabase.java` | Read-only JDBC wrapper around AuthMe's SQLite DB. One method: `getQuitLocation(playerName)`. Returns a `Location` or null. | +| `LimboWorldManager.java` | Creates the `auth_limbo` world with `VoidGenerator`, sets game rules, builds the optional barrier platform. | +| `VoidGenerator.java` | Modern (1.17+) `ChunkGenerator` that produces empty chunks. Disables noise, surface, bedrock, caves, decorations, mobs, and structures. | +| `LoginListener.java` | The fix itself. Two `@EventHandler` methods on `MONITOR` priority for `AuthMeAsyncPreLoginEvent` (chunk preload) and `LoginEvent` (delayed authoritative teleport). | + +## Threading model + +- `AuthMeAsyncPreLoginEvent` fires on an AuthMe worker thread. Our + handler reads the DB on that thread, then schedules the actual + `setChunkForceLoaded` call on the main thread because Bukkit world + state mutation must be single-threaded. +- `LoginEvent` is dispatched async by AuthMe's HaHaWTH fork. Our + handler reads the DB on that thread, then uses + `Bukkit.getScheduler().runTaskLater` to bounce onto the main thread + for the teleport. +- `Player#teleportAsync` itself returns a `CompletableFuture` + whose completion runs on Paper's chunk-loading thread pool. We use + `thenAccept` for the success callback, which Paper handles correctly. + +## What this plugin does not do + +- It does not read or write AuthMe's password column. AuthMe owns + authentication entirely. +- It does not manage permissions, groups, or chat formatting. +- It does not handle `/register`, `/changepassword`, or any other + AuthMe command. +- It does not bypass AuthMe's own teleport — AuthMe still runs its + broken restore. We just run *after* it and overwrite the position. + +[priority]: https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/event/EventPriority.html diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..fab486f --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,100 @@ +# Installation + +## Prerequisites + +| Component | Minimum | Notes | +|-----------|---------|-------| +| Server software | Paper 1.21.11 (or Purpur on the same MC version) | Folia is untested. | +| Java | 21 | Required by Paper 1.21+. | +| AuthMe-ReReloaded | any 5.x or 6.x fork | Verified on the HaHaWTH fork build 49 (`5.6.0-FORK-b49`). Hard dependency. | + +## Bare-metal / VPS install + +1. Download `RackedLimbo-X.Y.Z.jar` from the + [Releases page](https://github.com/s8n-ru/racked-limbo/releases/latest). +2. Drop the jar into your server's `plugins/` directory. +3. Restart the server. Do **not** use `/reload` — the listener wiring + does not survive a hot reload. +4. On first start the plugin will create: + - `plugins/RackedLimbo/config.yml` + - The `auth_limbo/` world directory at the server data root. +5. Tail the log and look for: + + ``` + [RackedLimbo] Using AuthMe DB at: /path/to/plugins/AuthMe/authme.db + [RackedLimbo] Limbo world 'auth_limbo' already loaded. + [RackedLimbo] Enabled. Listening for AuthMe LoginEvent at MONITOR. + ``` + + If you see "No saved location for ..." on a successful `/login`, that + user has no row in `authme.db` yet — log out, log back in, and the + row will be written by AuthMe on quit. + +## Docker (`itzg/minecraft-server`) + +This is the recommended distribution. Add the plugin via the auto-loader. + +```yaml +services: + mc: + image: itzg/minecraft-server:java21 + environment: + EULA: "TRUE" + TYPE: PAPER + VERSION: "1.21.11" + PLUGINS: | + https://github.com/s8n-ru/racked-limbo/releases/download/v1.0.0/RackedLimbo-1.0.0.jar + # Pin the version — itzg's auto-loader purges unrecognised jars on restart. + REMOVE_OLD_MODS: "TRUE" + REMOVE_OLD_MODS_INCLUDE: "*.jar" + REMOVE_OLD_MODS_EXCLUDE: "RackedLimbo*.jar,AuthMe*.jar,(other-plugins)*.jar" + volumes: + - ./data:/data + ports: + - "25565:25565" +``` + +Notes: +- Replace `v1.0.0` and `1.0.0` with the version you want to pin. +- `REMOVE_OLD_MODS_EXCLUDE` must list every jar you want kept; everything + else gets wiped on restart. +- AuthMe must also be in `PLUGINS` (or otherwise present in `plugins/`) + before this plugin loads — Paper enforces the `depend` ordering from + `plugin.yml`. + +## Verifying the install + +Run as an op: + +``` +/rackedlimbo +``` + +You should see: + +``` +RackedLimbo 1.0.0 - sub: reload | tp +``` + +To confirm the DB read path is working, log out a test account, then run +`/rackedlimbo tp ` — the plugin should report the world and +coordinates it pulled from `authme.db`. + +## Updating + +1. Stop the server. +2. Replace the old jar with the new one. +3. Start the server. + +`config.yml` is preserved across updates. Breaking config changes will +be flagged in `CHANGELOG.md` for the affected version. + +## Uninstalling + +1. Stop the server. +2. Delete `plugins/RackedLimbo-*.jar` and the `plugins/RackedLimbo/` + folder. +3. Optionally delete the `auth_limbo/` world directory if you no longer + want the void world. AuthMe will fall back to whatever + `Settings.RestrictionsSettings.unrestrictedWorlds` / spawn it was + using before. diff --git a/lib/.gitkeep b/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..91433a8 --- /dev/null +++ b/lib/README.md @@ -0,0 +1,46 @@ +# `lib/` — system-scope dependencies + +This directory holds binary dependencies that are not available from a +public Maven repository and so are referenced as `system`-scope in +`pom.xml`. + +## `AuthMe-5.6.0-FORK-Universal.jar` + +The HaHaWTH fork of AuthMe-ReReloaded, build 49. Used at compile time +only — `system` scope means it is never bundled into the shaded plugin +jar. + +This jar is not checked in to the public repository because: + +1. It is not licensed by us — it ships under AuthMe-ReReloaded's GPL. +2. It is several MB; redistributing it bloats the repo for no benefit. +3. Any 5.x AuthMe-ReReloaded fork exposes the same `LoginEvent` and + `AuthMeAsyncPreLoginEvent` API and works as a drop-in compile target. + +To build locally: + +1. Download the AuthMe jar that matches the runtime you intend to + target. Verified against + [HaHaWTH/AuthMeReloaded](https://github.com/HaHaWTH/AuthMeReloaded) + build 49 (`AuthMe-5.6.0-FORK-Universal.jar`). +2. Drop it into this directory with the exact filename + `AuthMe-5.6.0-FORK-Universal.jar`. +3. Run `mvn clean package` from the repo root. + +## CI builds + +`.github/workflows/build.yml` and `.github/workflows/release.yml` both +include a `Fetch AuthMe jar` step that downloads the dependency before +running Maven. The download URL is read from the repository secret +`AUTHME_JAR_URL`. To make CI builds succeed: + +1. Go to the repo's **Settings → Secrets and variables → Actions**. +2. Add a new repository secret named `AUTHME_JAR_URL`. +3. Set the value to a direct download URL for the AuthMe jar that + matches the version pinned in `pom.xml` (currently + `5.6.0-FORK-b49`). A GitHub release asset URL works. + +Until that secret is set, the `Build` and `Release` workflows will fail +on the first run with `AUTHME_JAR_URL secret is not set`. This is the +expected gating — do not remove the check; it stops a broken jar from +shipping silently. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9e69186 --- /dev/null +++ b/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + ru.racked + RackedLimbo + 1.0.0 + jar + + RackedLimbo + AuthMe-aware void limbo + reliable post-login teleport for Paper 1.21.11. + + + UTF-8 + 21 + 21 + 1.21.11-R0.1-SNAPSHOT + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + sonatype + https://oss.sonatype.org/content/groups/public/ + + + + + + + io.papermc.paper + paper-api + ${paper.api.version} + provided + + + + + fr.xephi + authme + 5.6.0-FORK-b49 + system + ${project.basedir}/lib/AuthMe-5.6.0-FORK-Universal.jar + + + + + org.xerial + sqlite-jdbc + 3.46.1.3 + + + + + ${project.name}-${project.version} + + + + src/main/resources + true + + + + + + maven-compiler-plugin + 3.13.0 + + 21 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + + package + + shade + + + false + false + + + org.sqlite + ru.racked.limbo.shaded.sqlite + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + META-INF/MANIFEST.MF + + + + + + + + + + diff --git a/src/main/java/ru/racked/limbo/AuthMeDatabase.java b/src/main/java/ru/racked/limbo/AuthMeDatabase.java new file mode 100644 index 0000000..2b4ec71 --- /dev/null +++ b/src/main/java/ru/racked/limbo/AuthMeDatabase.java @@ -0,0 +1,97 @@ +package ru.racked.limbo; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.logging.Level; + +/** + * Read-only access to AuthMe's SQLite {@code authme} table. + * + * Schema (from AuthMe-ReReloaded fork b49): + * username VARCHAR PRIMARY KEY (lowercased real name) + * realname VARCHAR + * x DOUBLE, y DOUBLE, z DOUBLE + * world VARCHAR (default 'world') + * yaw FLOAT, pitch FLOAT + * + * We never write to this DB — AuthMe owns it. We only read on login to + * find the player's last quit-location, then teleport them ourselves. + */ +public final class AuthMeDatabase { + + private final RackedLimbo plugin; + private final File dbFile; + private final String url; + + public AuthMeDatabase(RackedLimbo plugin, File dbFile) { + this.plugin = plugin; + this.dbFile = dbFile; + this.url = "jdbc:sqlite:" + dbFile.getAbsolutePath(); + + // Force-load shaded SQLite driver class so DriverManager finds it. + try { + Class.forName("ru.racked.limbo.shaded.sqlite.JDBC"); + } catch (ClassNotFoundException e) { + plugin.getLogger().log(Level.SEVERE, + "[RackedLimbo] Shaded SQLite driver class missing — build is broken!", e); + } + } + + /** + * Look up a player's saved quit location by exact name (case-insensitive). + * Returns null if no row, no world, or any error. + */ + public Location getQuitLocation(String playerName) { + if (playerName == null || playerName.isEmpty()) return null; + String lower = playerName.toLowerCase(); + + String sql = "SELECT x, y, z, world, yaw, pitch FROM authme WHERE LOWER(username) = ? LIMIT 1"; + + try (Connection con = DriverManager.getConnection(url); + PreparedStatement ps = con.prepareStatement(sql)) { + + ps.setString(1, lower); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + if (plugin.debug()) { + plugin.getLogger().info("[RackedLimbo][debug] No DB row for " + playerName); + } + return null; + } + double x = rs.getDouble("x"); + double y = rs.getDouble("y"); + double z = rs.getDouble("z"); + String worldName = rs.getString("world"); + float yaw = rs.getFloat("yaw"); + float pitch = rs.getFloat("pitch"); + + if (worldName == null || worldName.isEmpty()) worldName = "world"; + World world = Bukkit.getWorld(worldName); + if (world == null) { + plugin.getLogger().warning("[RackedLimbo] World '" + worldName + + "' from authme.db is not loaded — cannot restore " + + playerName + "."); + return null; + } + return new Location(world, x, y, z, yaw, pitch); + } + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, + "[RackedLimbo] Failed to read authme.db for " + playerName, e); + return null; + } + } + + public void close() { + // No persistent connection — DriverManager.getConnection() opens a new + // one per query. Nothing to close globally. + } +} diff --git a/src/main/java/ru/racked/limbo/LimboWorldManager.java b/src/main/java/ru/racked/limbo/LimboWorldManager.java new file mode 100644 index 0000000..fa7f643 --- /dev/null +++ b/src/main/java/ru/racked/limbo/LimboWorldManager.java @@ -0,0 +1,92 @@ +package ru.racked.limbo; + +import org.bukkit.Bukkit; +import org.bukkit.GameRule; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.WorldCreator; +import org.bukkit.block.Block; +import org.bukkit.configuration.file.FileConfiguration; + +/** + * Creates and configures the {@code auth_limbo} world. + * + * Why a separate manager: AuthMe's pre-auth limbo logic is independent of + * Multiverse — it only needs a Bukkit world to teleport unauth players into. + * We create that world ourselves with a void generator and a tiny barrier + * platform at spawn so falling unauth players don't take fall damage in void. + */ +public final class LimboWorldManager { + + private final RackedLimbo plugin; + + public LimboWorldManager(RackedLimbo plugin) { + this.plugin = plugin; + } + + public void ensureLimbo() { + FileConfiguration cfg = plugin.getConfig(); + String name = cfg.getString("limbo.world", "auth_limbo"); + + World world = Bukkit.getWorld(name); + if (world == null) { + plugin.getLogger().info("[RackedLimbo] Creating limbo world '" + name + "'..."); + WorldCreator wc = new WorldCreator(name) + .environment(World.Environment.THE_END) + .generator(new VoidGenerator()) + .generateStructures(false); + world = wc.createWorld(); + if (world == null) { + plugin.getLogger().severe("[RackedLimbo] Failed to create limbo world!"); + return; + } + } else { + plugin.getLogger().info("[RackedLimbo] Limbo world '" + name + "' already loaded."); + } + + double sx = cfg.getDouble("limbo.spawn-x", 0.5); + double sy = cfg.getDouble("limbo.spawn-y", 128.0); + double sz = cfg.getDouble("limbo.spawn-z", 0.5); + world.setSpawnLocation((int) sx, (int) sy, (int) sz); + + // World rules: no time progression, no weather, no mobs, no PvP. + world.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false); + world.setGameRule(GameRule.DO_WEATHER_CYCLE, false); + world.setGameRule(GameRule.DO_MOB_SPAWNING, false); + world.setGameRule(GameRule.DO_FIRE_TICK, false); + world.setPVP(false); + world.setKeepSpawnInMemory(true); + + if (cfg.getBoolean("limbo.build-platform", true)) { + int py = cfg.getInt("limbo.platform-y", 127); + int cx = (int) sx; + int cz = (int) sz; + int built = 0; + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + Block b = world.getBlockAt(cx + dx, py, cz + dz); + if (b.getType() != Material.BARRIER) { + b.setType(Material.BARRIER, false); + built++; + } + } + } + if (built > 0) { + plugin.getLogger().info("[RackedLimbo] Built " + built + " barrier blocks at " + + cx + "," + py + "," + cz + "."); + } + } + } + + public Location spawn() { + FileConfiguration cfg = plugin.getConfig(); + String name = cfg.getString("limbo.world", "auth_limbo"); + World world = Bukkit.getWorld(name); + if (world == null) return null; + return new Location(world, + cfg.getDouble("limbo.spawn-x", 0.5), + cfg.getDouble("limbo.spawn-y", 128.0), + cfg.getDouble("limbo.spawn-z", 0.5)); + } +} diff --git a/src/main/java/ru/racked/limbo/LoginListener.java b/src/main/java/ru/racked/limbo/LoginListener.java new file mode 100644 index 0000000..c3dd48e --- /dev/null +++ b/src/main/java/ru/racked/limbo/LoginListener.java @@ -0,0 +1,193 @@ +package ru.racked.limbo; + +import fr.xephi.authme.events.AuthMeAsyncPreLoginEvent; +import fr.xephi.authme.events.LoginEvent; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerTeleportEvent; + +import java.util.HashSet; +import java.util.Set; + +/** + * Listens for AuthMe's two relevant events: + * + * 1. {@link AuthMeAsyncPreLoginEvent} — fired before AuthMe authenticates + * the password. We pin the destination chunk via a plugin chunk-ticket + * so it's fully loaded by the time the actual teleport runs. + * + * 2. {@link LoginEvent} — fired AFTER AuthMe successfully authenticates + * and runs its own (often broken) post-login teleport. We listen at + * MONITOR priority so we are LAST in the chain, then fire an + * authoritative teleport that overrides whatever AuthMe / Paper safety + * checks did to the player's location. + * + * Threading: + * - AuthMeAsyncPreLoginEvent fires async (AuthMe worker thread). + * - LoginEvent in this fork fires on the main thread, but we treat it + * as if it could be async — all chunk/teleport work goes through + * scheduler hops to the main thread. + * - {@code teleportAsync} and {@code getChunkAtAsyncUrgently} are + * thread-safe per Paper docs. + * + * Pattern (from Paper Issue #4085 + PaperLib AsyncTeleportPaper): + * addPluginChunkTicket(cx, cz) + * -> getChunkAtAsyncUrgently(cx, cz, true) + * .thenAccept(chunk -> + * player.teleportAsync(loc, PLUGIN) + * .thenAccept(ok -> removePluginChunkTicket(cx, cz) after 5s) + * ); + */ +public final class LoginListener implements Listener { + + private final RackedLimbo plugin; + private final AuthMeDatabase db; + + /** Tracks active plugin-chunk-tickets so we don't double-add or fail to release. */ + private final Set activeTickets = new HashSet<>(); + + public LoginListener(RackedLimbo plugin, AuthMeDatabase db) { + this.plugin = plugin; + this.db = db; + } + + /* ---------------- Pre-login: pin the chunk early ---------------- */ + + @EventHandler(priority = EventPriority.MONITOR) + public void onAsyncPreLogin(AuthMeAsyncPreLoginEvent event) { + if (!plugin.getConfig().getBoolean("authme.preload-chunks", true)) return; + + Player player = event.getPlayer(); + if (player == null) return; + final String name = player.getName(); + + final Location saved = db.getQuitLocation(name); + if (saved == null || saved.getWorld() == null) return; + + final World world = saved.getWorld(); + final int cx = saved.getBlockX() >> 4; + final int cz = saved.getBlockZ() >> 4; + + Bukkit.getScheduler().runTask(plugin, () -> { + String key = world.getName() + ":" + cx + ":" + cz; + if (activeTickets.add(key)) { + try { + world.getChunkAt(cx, cz).addPluginChunkTicket(plugin); + if (plugin.debug()) { + plugin.getLogger().info("[RackedLimbo][debug] Chunk-ticket added " + + key + " for " + name); + } + } catch (Throwable t) { + plugin.getLogger().warning("[RackedLimbo] addPluginChunkTicket failed for " + + name + ": " + t.getMessage()); + activeTickets.remove(key); + } + } + }); + } + + /* ---------------- Post-login: authoritative teleport ---------------- */ + + @EventHandler(priority = EventPriority.MONITOR) + public void onLogin(LoginEvent event) { + Player player = event.getPlayer(); + if (player == null) return; + final String name = player.getName(); + + final Location saved = db.getQuitLocation(name); + if (saved == null) { + plugin.getLogger().info("[RackedLimbo] No saved location for " + + name + " — leaving where AuthMe put them."); + return; + } + + long delay = Math.max(0, plugin.getConfig().getLong("authme.teleport-delay-ticks", 10L)); + + Bukkit.getScheduler().runTaskLater(plugin, () -> doTeleport(player, name, saved), delay); + } + + /* ---------------- Core teleport with chunk-prep ---------------- */ + + private void doTeleport(Player player, String name, Location saved) { + if (!player.isOnline()) { + plugin.getLogger().info("[RackedLimbo] " + name + + " went offline before restore — skipping."); + return; + } + World world = saved.getWorld(); + if (world == null) { + plugin.getLogger().warning("[RackedLimbo] Saved world for " + + name + " is no longer loaded."); + return; + } + + plugin.getLogger().info(String.format( + "[RackedLimbo] Restoring %s to %s(%.1f, %.1f, %.1f)", + name, world.getName(), saved.getX(), saved.getY(), saved.getZ())); + + final int cx = saved.getBlockX() >> 4; + final int cz = saved.getBlockZ() >> 4; + final String key = world.getName() + ":" + cx + ":" + cz; + + // Make sure the chunk is loaded before teleporting. Paper's + // teleportAsync alone has been observed to drop the player at + // world spawn briefly when the destination chunk isn't ready. + try { + if (activeTickets.add(key)) { + world.getChunkAt(cx, cz).addPluginChunkTicket(plugin); + } + } catch (Throwable t) { + // non-fatal — fall through to chunk-load + teleport + } + + world.getChunkAtAsyncUrgently(cx, cz).thenAccept((Chunk chunk) -> { + player.teleportAsync(saved, PlayerTeleportEvent.TeleportCause.PLUGIN) + .thenAccept(success -> { + if (Boolean.TRUE.equals(success)) { + if (plugin.debug()) { + plugin.getLogger().info("[RackedLimbo][debug] Teleport ok for " + name); + } + } else { + plugin.getLogger().warning("[RackedLimbo] teleportAsync returned false for " + + name + " — Paper may have rejected the location."); + } + // Release the ticket 5s later — gives the client time to + // download the chunk before we let it unload. + scheduleTicketRelease(world, cx, cz, key); + }) + .exceptionally(ex -> { + plugin.getLogger().warning("[RackedLimbo] teleportAsync threw for " + + name + ": " + ex.getMessage()); + scheduleTicketRelease(world, cx, cz, key); + return null; + }); + }).exceptionally(ex -> { + plugin.getLogger().warning("[RackedLimbo] getChunkAtAsyncUrgently threw for " + + name + ": " + ex.getMessage()); + scheduleTicketRelease(world, cx, cz, key); + return null; + }); + } + + private void scheduleTicketRelease(World world, int cx, int cz, String key) { + if (!activeTickets.contains(key)) return; + Bukkit.getScheduler().runTaskLater(plugin, () -> { + try { + world.getChunkAt(cx, cz).removePluginChunkTicket(plugin); + } catch (Throwable t) { + // best-effort + } finally { + activeTickets.remove(key); + if (plugin.debug()) { + plugin.getLogger().info("[RackedLimbo][debug] Chunk-ticket released " + key); + } + } + }, 20L * 5L); + } +} diff --git a/src/main/java/ru/racked/limbo/RackedLimbo.java b/src/main/java/ru/racked/limbo/RackedLimbo.java new file mode 100644 index 0000000..d702a84 --- /dev/null +++ b/src/main/java/ru/racked/limbo/RackedLimbo.java @@ -0,0 +1,125 @@ +package ru.racked.limbo; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; + +/** + * RackedLimbo — companion plugin for AuthMe-ReReloaded. + * + * Two responsibilities: + * 1. Provide a void {@code auth_limbo} world (no Multiverse required). + * 2. Listen to {@link fr.xephi.authme.events.LoginEvent} at MONITOR priority, + * then teleport the player to their saved quit-location from authme.db + * AFTER AuthMe's own (broken) teleport has run. + * + * The DB is read directly via JDBC SQLite — we never modify it. + */ +public final class RackedLimbo extends JavaPlugin { + + private static RackedLimbo instance; + private LimboWorldManager limboManager; + private AuthMeDatabase authmeDb; + private LoginListener loginListener; + + public static RackedLimbo getInstance() { + return instance; + } + + @Override + public void onEnable() { + instance = this; + saveDefaultConfig(); + + FileConfiguration cfg = getConfig(); + + // Resolve AuthMe DB path. Server data dir is the JVM working dir under itzg. + String dbPathStr = cfg.getString("authme.db-path", "plugins/AuthMe/authme.db"); + File dbFile = new File(dbPathStr); + if (!dbFile.isAbsolute()) { + dbFile = new File(getServer().getWorldContainer(), dbPathStr); + } + getLogger().info("[RackedLimbo] Using AuthMe DB at: " + dbFile.getAbsolutePath()); + this.authmeDb = new AuthMeDatabase(this, dbFile); + + // Build the limbo world before AuthMe gets a chance to teleport players there. + this.limboManager = new LimboWorldManager(this); + this.limboManager.ensureLimbo(); + + // Register listener LAST — AuthMe is depend so it's already enabled. + this.loginListener = new LoginListener(this, authmeDb); + Bukkit.getPluginManager().registerEvents(loginListener, this); + + getLogger().info("[RackedLimbo] Enabled. Listening for AuthMe LoginEvent at MONITOR."); + } + + @Override + public void onDisable() { + if (authmeDb != null) authmeDb.close(); + getLogger().info("[RackedLimbo] Disabled."); + } + + public LimboWorldManager limbo() { + return limboManager; + } + + public AuthMeDatabase db() { + return authmeDb; + } + + public boolean debug() { + return getConfig().getBoolean("debug", false); + } + + /* ---------------- /rackedlimbo admin command ---------------- */ + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!command.getName().equalsIgnoreCase("rackedlimbo")) return false; + + if (args.length == 0) { + sender.sendMessage("RackedLimbo " + getDescription().getVersion() + + " — sub: reload | tp "); + return true; + } + + switch (args[0].toLowerCase()) { + case "reload" -> { + reloadConfig(); + sender.sendMessage("[RackedLimbo] config reloaded."); + return true; + } + case "tp" -> { + if (args.length < 2) { + sender.sendMessage("Usage: /rackedlimbo tp "); + return true; + } + Player target = Bukkit.getPlayerExact(args[1]); + if (target == null) { + sender.sendMessage("Player not online: " + args[1]); + return true; + } + Location saved = authmeDb.getQuitLocation(target.getName()); + if (saved == null) { + sender.sendMessage("No saved location in authme.db for " + target.getName()); + return true; + } + target.teleportAsync(saved); + sender.sendMessage("Teleported " + target.getName() + " to " + + saved.getWorld().getName() + "(" + + (int) saved.getX() + "," + (int) saved.getY() + "," + (int) saved.getZ() + ")"); + return true; + } + default -> { + sender.sendMessage("Unknown subcommand: " + args[0]); + return true; + } + } + } +} diff --git a/src/main/java/ru/racked/limbo/VoidGenerator.java b/src/main/java/ru/racked/limbo/VoidGenerator.java new file mode 100644 index 0000000..1aa7227 --- /dev/null +++ b/src/main/java/ru/racked/limbo/VoidGenerator.java @@ -0,0 +1,62 @@ +package ru.racked.limbo; + +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; +import org.jetbrains.annotations.NotNull; + +import java.util.Random; + +/** + * Empty-chunk generator for the limbo world. Every chunk is air — players will + * fall into the void unless we drop them onto a barrier platform at spawn. + * + * Uses Paper's modern generator API (1.17+) — does NOT override the legacy + * {@code generateChunkData} so it works correctly with chunk caching. + */ +public final class VoidGenerator extends ChunkGenerator { + + @Override + public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, + int chunkX, int chunkZ, @NotNull ChunkData chunkData) { + // no-op: leave chunk as air + } + + @Override + public void generateSurface(@NotNull WorldInfo worldInfo, @NotNull Random random, + int chunkX, int chunkZ, @NotNull ChunkData chunkData) { + // no-op + } + + @Override + public void generateBedrock(@NotNull WorldInfo worldInfo, @NotNull Random random, + int chunkX, int chunkZ, @NotNull ChunkData chunkData) { + // no-op — we don't even want a floor + } + + @Override + public void generateCaves(@NotNull WorldInfo worldInfo, @NotNull Random random, + int chunkX, int chunkZ, @NotNull ChunkData chunkData) { + // no-op + } + + @Override + public boolean shouldGenerateNoise() { return false; } + + @Override + public boolean shouldGenerateSurface() { return false; } + + @Override + public boolean shouldGenerateBedrock() { return false; } + + @Override + public boolean shouldGenerateCaves() { return false; } + + @Override + public boolean shouldGenerateDecorations() { return false; } + + @Override + public boolean shouldGenerateMobs() { return false; } + + @Override + public boolean shouldGenerateStructures() { return false; } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..df06030 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,28 @@ +# RackedLimbo configuration + +# Limbo world settings — pre-auth players are kept here. +limbo: + # Bukkit world name. Folder will be created at /data//. + world: auth_limbo + # Spawn coordinates inside the limbo world. + spawn-x: 0.5 + spawn-y: 128.0 + spawn-z: 0.5 + # Build a 5x5 barrier platform under spawn so unauth players don't fall. + build-platform: true + platform-y: 127 + +# Where AuthMe's SQLite DB lives, relative to the server data dir. +# Default matches itzg/minecraft-server with a /data bind mount. +authme: + db-path: plugins/AuthMe/authme.db + # Tick delay between LoginEvent firing and us forcing the teleport. + # 10 ticks (~0.5s) lets AuthMe's own broken teleport finish first + # so our async teleport is the LAST one to run. + teleport-delay-ticks: 10 + # Whether to forceload the chunk at the saved quit-loc 1s before login + # completes (via AuthMeAsyncPreLoginEvent), then unforce after teleport. + preload-chunks: true + +# Set to true for verbose logs. False = only INFO on actual restores. +debug: false diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..9cd89e0 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,29 @@ +name: RackedLimbo +main: ru.racked.limbo.RackedLimbo +version: ${project.version} +api-version: '1.21' +authors: [s8n / racked.ru] +description: AuthMe-aware void limbo + reliable post-login teleport. +website: https://racked.ru/ + +# Hard depend on AuthMe — we listen to fr.xephi.authme.events.LoginEvent +# so AuthMe must be present and load first. +depend: + - AuthMe + +# We want to run AFTER VoidWorldGenerator (if present) so it can serve our +# limbo world with a vanilla void chunk generator if our own generator misses. +softdepend: + - VoidWorldGenerator + +commands: + rackedlimbo: + description: RackedLimbo admin commands. + aliases: [rlimbo] + permission: rackedlimbo.admin + usage: /rackedlimbo > + +permissions: + rackedlimbo.admin: + description: Manage RackedLimbo at runtime. + default: op