Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0ba5362e | ||
|
|
e6f73e3f05 | ||
|
|
c6ddf65c64 | ||
|
|
b23571906f | ||
|
|
00f40c231f | ||
|
|
7dd3aff5a5 | ||
|
|
3acaf6f509 | ||
|
|
9c84a44ced | ||
|
|
826a7ba255 | ||
|
|
08ed22ee2b | ||
|
|
7e2cefdb7f |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions (Questions & Ideas)
|
||||
url: https://github.com/SunsetRQ/AntiSeedCracker/discussions
|
||||
url: https://github.com/SunsetRQ7/AntiSeedCracker/discussions
|
||||
about: Ask questions, share configs, or discuss ideas here before opening an issue.
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvn clean package -q
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AntiSeedCracker-${{ github.sha }}
|
||||
path: target/AntiSeedCracker-*.jar
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvn clean package -q
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: target/AntiSeedCracker-*.jar
|
||||
generate_release_notes: true
|
||||
@@ -12,3 +12,5 @@ hs_err_pid*.log
|
||||
Thumbs.db
|
||||
dependency-reduced-pom.xml
|
||||
META-INF/
|
||||
.vscode/
|
||||
.claude/
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to AntiSeedCracker are documented here. Format loosely follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [3.5.0.2] - 2026-07-24
|
||||
|
||||
### Fixed
|
||||
- **Buried treasure map locations were never actually scrambled**: `MapSeedInterceptor`
|
||||
filtered map decorations by name, checking for `"TREASURE"`, `"PILLAGER"`, and
|
||||
`"BASTION"` — none of which are real vanilla map decoration type IDs (verified against
|
||||
`org.bukkit.map.MapCursor.Type` directly: no such constants exist, so those three checks
|
||||
never matched anything, ever). Buried treasure maps actually use `TARGET_X`, which was
|
||||
missing entirely. Buried treasure is one of this plugin's own named SeedCrackerX
|
||||
structure signals, so its map marker was leaking the real, unscrambled location on every
|
||||
treasure map handed to a player. Now correctly matches `TARGET_X`; the two other
|
||||
never-matching checks were removed.
|
||||
- **`end_cities`/`end_spikes` `modify_world: false` could permanently disable itself**:
|
||||
both `EndCityProtector` and `EndSpikeProtector` marked a chunk/world as "already
|
||||
processed" as soon as it was scanned, even when `modify_world` was off and nothing was
|
||||
actually changed. Turning `modify_world` on later via `/asc toggle` or a config edit
|
||||
would find that flag already set and skip the modification forever, for any chunk/world
|
||||
already loaded once while it was off. Both now only mark a chunk/world "done" when the
|
||||
modification actually ran (or, for End Cities, when a chunk provably has no End City at
|
||||
all and therefore never will need modifying).
|
||||
- **Redundant respawn-cycle polling in `EndSpikeProtector`**: all four dragon-respawn End
|
||||
Crystals fire their placement event independently, each previously starting its own
|
||||
300-attempt polling loop to detect when the respawn animation finished — four times the
|
||||
necessary scheduled work per dragon fight. Now guarded so only one poll loop runs per
|
||||
world at a time.
|
||||
- Replaced reflection-based scheduled-task cancellation (`getMethod("cancel").invoke(...)`,
|
||||
silently swallowing any failure) in `FoliaSchedulerUtil` with direct, typed
|
||||
`ScheduledTask#cancel()` calls — verified against the real Paper API class. Faster, and
|
||||
a genuine cancellation failure is no longer silently discarded.
|
||||
- Removed a pointless seconds→ticks→seconds round-trip conversion in the seed rotation
|
||||
interval config.
|
||||
- Simplified the End Spike pillar-angle formula to a plain decagon spacing expression;
|
||||
behaviorally identical (verified via the periodicity of `cos`/`sin`), just no longer
|
||||
needlessly obscure.
|
||||
- Trimmed default `config.yml` down to essential settings, with fuller explanations kept
|
||||
in the README instead of long inline comments.
|
||||
|
||||
## [3.5.0.1] - 2026-07-22
|
||||
|
||||
### Fixed
|
||||
- **Eye of Ender redirection didn't actually hold**: `EyeOfEnderProtector` only ever set the
|
||||
thrown eye's *velocity* toward the fake stronghold. Vanilla's `EnderSignal` entity is
|
||||
constructed with a target location pointing at the **real** stronghold before
|
||||
`ProjectileLaunchEvent` even fires, and re-accelerates toward that stored target every
|
||||
tick on its own — independent of whatever velocity a plugin sets once at launch. Without
|
||||
calling `EnderSignal#setTargetLocation`, the eye would curve back toward the true bearing
|
||||
within a second or two of flight, regardless of the initial jitter, leaking the real
|
||||
direction to anything tracking the eye's trajectory rather than just its launch frame.
|
||||
Now sets the target location (with a plausible, per-player-stable fake Y as well as
|
||||
x/z) in addition to velocity, on both the primary and Folia-retry code paths.
|
||||
- **Fake stronghold silently moved on every `/asc reload`/`/asc toggle`**: `initPlayer()`
|
||||
unconditionally re-rolled each online player's fake stronghold every time it ran, which
|
||||
includes on every reload and every `/asc toggle` (which reloads internally) — not just on
|
||||
join. An admin flipping one unrelated toggle would make a previously-consistent `/locate
|
||||
stronghold` or Eye of Ender answer jump to a new location with no world change, undermining
|
||||
the exact consistency guarantee 3.5.0 added `getOrAssignFakeStructureLocation` to provide.
|
||||
Now only assigns a fake stronghold the first time a player is seen; already-assigned
|
||||
players keep theirs across reloads. `SeedManager` gained `hasFakeStronghold`/
|
||||
`getFakeStrongholdY` to support this.
|
||||
- Verified with a clean `mvn clean package` on JDK 25 after both fixes.
|
||||
|
||||
## [3.5.0] - 2026-07-22
|
||||
|
||||
Live-tested end to end on a real Paper 26.1.2 server via RCON (repeated boots, every
|
||||
`/asc` subcommand, both direct and `/execute`-wrapped bypass attempts, config
|
||||
persistence across restarts). Several of the fixes below were only found because of
|
||||
that live testing, not static review.
|
||||
|
||||
### Fixed
|
||||
- **Build was broken as shipped**: `pom.xml` targeted Java 21 while the bundled
|
||||
`folia-api` 26.1.2 dependency ships Java 25 class files, so a clean checkout could
|
||||
not compile. Bumped to release 25 (JDK 25 confirmed installed and working).
|
||||
- **`/execute`-wrapping command-blocker bypass**: vanilla's `/execute ... run <command>`
|
||||
re-dispatches its trailing command internally without re-firing
|
||||
`PlayerCommandPreprocessEvent`, so `/execute run seed` (or nested chains, even with a
|
||||
dimension switch like `/execute in minecraft:the_end run seed`) sailed straight past
|
||||
the blocker. Added `CommandUtil`, which recursively resolves what an `/execute` chain
|
||||
ultimately dispatches to before the block check runs. Verified live via RCON.
|
||||
- **`/asc stats` bled into the next RCON command's response**: the command computed its
|
||||
result asynchronously and replied a tick later, after the RCON round-trip had already
|
||||
closed — the reply then attached itself to whatever command ran next over the same
|
||||
connection instead of `stats`. Found live via RCON testing. Now synchronous for
|
||||
console/RCON senders (async is kept for real players, to avoid blocking a tick on file
|
||||
I/O from chat).
|
||||
- **Windows console mojibake**: em-dashes and box-drawing characters in
|
||||
`logger.info/warning()` calls rendered as `�` on a real Windows console (confirmed
|
||||
live). Chat-facing messages (Adventure `Component`) were unaffected and left as-is;
|
||||
only raw JUL logger output was converted to plain ASCII.
|
||||
- **`/locate` answers were inconsistent on repeat queries**: non-stronghold structures
|
||||
got a brand-new random fake location on every call, letting a suspicious player detect
|
||||
the spoof just by re-running the command in place. Now cached per player+structure,
|
||||
same as strongholds already were.
|
||||
- **Treasure/explorer map icons visibly jittered**: fake decoration coordinates were
|
||||
redrawn from fresh randomness on every `MAP_DATA` packet, so the icon jumped around
|
||||
the map on every refresh instead of sitting at one (wrong) spot. Now derived
|
||||
deterministically from map ID + icon type + real position, so it's stable but still
|
||||
unpredictable to the player.
|
||||
- **Unbounded cache growth in `StructureReconMonitor`**: the per-chunk "does this chunk
|
||||
have a tracked structure" cache never evicted, growing for the entire server uptime.
|
||||
Now evicted on `ChunkUnloadEvent`, bounding it to currently-loaded chunks.
|
||||
- **`extra_blocked_commands` namespace-stripping bug**: the old colon-stripping logic
|
||||
stripped from the *first* colon anywhere in the string, not just a leading
|
||||
`namespace:` prefix, so a command with a namespaced argument later in the string
|
||||
could have its root command mangled. Fixed in the new shared `CommandUtil`.
|
||||
- Version strings were inconsistent across `pom.xml` (3.4.0, itself unreleased/broken),
|
||||
`plugin.yml` (3.3.1), and `README.md` (3.3.0); `plugin.yml`'s description still
|
||||
mentioned "plugin-API interception," a feature removed back in 3.3.0. All unified.
|
||||
|
||||
### Added
|
||||
- `/asc toggle <feature> [true|false]` — flip or explicitly set one protection layer
|
||||
live, persisted to `config.yml`, no full reload needed. Tab-completes feature names.
|
||||
- Granular permissions: `antiseedcracker.admin.status` (read-only) and
|
||||
`antiseedcracker.admin.manage` (reload/toggle), both covered by the existing
|
||||
`antiseedcracker.admin` umbrella so nothing breaks for existing setups.
|
||||
- `/asc help` — lists only the subcommands the sender actually has permission for.
|
||||
- **Command Probing Monitor**: logs (never blocks, same philosophy as the structure
|
||||
recon monitor) when a player triggers several blocked seed commands in a short
|
||||
window — a pattern consistent with a script/macro probing for a bypass.
|
||||
- `structure_recon_monitor.tracked_structures` is now configurable; defaults now
|
||||
include `buried_treasure`, which the plugin's own threat-model docs already named as
|
||||
a SeedCrackerX data source but which was missing from the actual tracked set.
|
||||
|
||||
### Researched
|
||||
- Surveyed SeedCrackerX's current (1.18+) technique set, LiquidBounce/Wurst/Meteor
|
||||
Client's public source and issue trackers (no seed-cracking integration or
|
||||
spoof-detection features found in any of them — ESP/X-ray modules only render chunk
|
||||
data already sent to the client), and competing plugins (`akshualy/AntiSeedCracker`,
|
||||
`zAntiCracker`, `DrexHD/SeedGuard`, `Earthcomputer/SecureSeed`). Findings folded into
|
||||
the README's Threat Model section, including an honest note that hashed-seed
|
||||
spoofing/rotation is a "raises the bar" layer, not a load-bearing one, against a
|
||||
well-informed attacker relying on structure-position data instead.
|
||||
- Documented, as a known limitation, that command-block- and data-pack-`/function`-
|
||||
issued commands never fire any Bukkit command-preprocess event at all — there is no
|
||||
supported API hook for either, so this can't be closed without NMS-level hacks the
|
||||
project deliberately avoids.
|
||||
|
||||
## [3.3.1] - 2026-07-12
|
||||
|
||||
- `pluginConfig`/`databaseManager` were plain fields reassigned by `/asc reload` on the
|
||||
main thread but read from PacketEvents Netty threads and the async scheduler with no
|
||||
happens-before edge; marked `volatile`.
|
||||
- `extra_blocked_commands` was never lowercased at load, so any mixed-case entry in
|
||||
`config.yml` silently failed to match.
|
||||
- `StructureReconMonitor` called `chunk.getStructures()` on every chunk crossing for
|
||||
every player with no cross-player caching; added a per-world chunk-level cache so
|
||||
each chunk's lookup happens once total. Added `ANCIENT_CITY` to the tracked set.
|
||||
- `DatabaseManager` opened and closed a file handle per audit event; now keeps a
|
||||
persistent writer, rotated only on date change.
|
||||
- Removed dead code left over from the removed `WorldSeedInterceptor`.
|
||||
- Verified live on Paper 26.1.2-74 and Folia 26.1.2-8, zero errors, real seed confirmed
|
||||
byte-for-byte intact on disk.
|
||||
|
||||
## [3.3.0] - 2026-07-11
|
||||
|
||||
- **Critical fix**: removed `WorldSeedInterceptor`, which patched the live NMS seed
|
||||
field once per second on every loaded world. That field is re-read fresh by vanilla
|
||||
chunk decoration (trees, ores, structure placement) for every chunk generated, not
|
||||
cached once at world load — any chunk decorated while it held a fake value generated
|
||||
from randomness instead of the real seed. This was the root cause of reported "seed
|
||||
changing disrupts chunk generation" issues.
|
||||
- Replaced it with `SeedIntegrityMonitor`, a read-only watchdog that only ever reads
|
||||
`world.getSeed()` and warns on drift, never writes to it.
|
||||
- `EndCityProtector` now scans only the structure's bounding box instead of the full
|
||||
chunk column.
|
||||
- Player-seed safety-net task interval reduced from 1s to 15s.
|
||||
- Researched LiquidBounce/SeedCrackerX mechanics and documented honestly that seed
|
||||
cracking via legitimately-visited structure coordinates is not a network exploit and
|
||||
cannot be prevented server-side.
|
||||
- Added `StructureReconMonitor` (audit-log only, never blocks) and
|
||||
`SlimeChunkObfuscator` (opt-in, off by default).
|
||||
- Documented that block/ore ESP (XRay, StorageESP) is out of scope — use Paper's
|
||||
native anti-xray instead.
|
||||
|
||||
## [3.2.0] - 2026-07-04
|
||||
|
||||
- Added bStats (plugin id 32378) with platform/protection charts.
|
||||
- Upgraded PacketEvents 2.12.2 → 2.13.0.
|
||||
- Configurable seed-blocked messages via `config.yml`.
|
||||
- `/asc reload` now applies database enable/disable without a restart.
|
||||
- Verified live: Paper 26.1.2-72 and Folia 26.1.2-8 boot with zero errors.
|
||||
|
||||
## [3.1.1] - 2026-06-13
|
||||
|
||||
- Fixed `NoClassDefFoundError: net/kyori/adventure/nbt/BinaryTag` on Folia/Paper by
|
||||
bundling `adventure-nbt` in the shaded jar (PacketEvents declared it as a compile
|
||||
dependency without bundling it).
|
||||
- Upgraded PacketEvents 2.12.0 → 2.12.2.
|
||||
|
||||
## [3.1.0] - 2026-06-13
|
||||
|
||||
- Initial release: professional multi-layer world seed protection for Paper, Purpur,
|
||||
and Folia.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 SunsetRQ7_
|
||||
|
||||
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.
|
||||
@@ -1,8 +1,13 @@
|
||||
# AntiSeedCracker
|
||||
|
||||
**Version:** 3.1.0 | **Target:** Minecraft 26.1.2 | **Author:** SunsetRQ7_
|
||||
[](https://github.com/SunsetRQ7/AntiSeedCracker/actions/workflows/build.yml)
|
||||
[](https://github.com/SunsetRQ7/AntiSeedCracker/releases/latest)
|
||||
[](LICENSE)
|
||||
[](https://adoptium.net/)
|
||||
|
||||
AntiSeedCracker is a professional, multi-layer world seed protection plugin for Paper, Purpur, Folia, and Spigot. It blocks every known technique a player can use to recover your world seed, protecting your server from ESP clients and seed-cracking tools.
|
||||
**Version:** 3.5.0.2 | **Target:** Minecraft 26.1.2 | **Author:** SunsetRQ7_
|
||||
|
||||
AntiSeedCracker is a professional, multi-layer world seed protection plugin for Paper, Purpur, and Folia. It blocks every known technique a player can use to recover your world seed, protecting your server from ESP clients, ghost clients, and seed-cracking tools such as SeedCrackerX.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,34 +16,42 @@ AntiSeedCracker is a professional, multi-layer world seed protection plugin for
|
||||
| Protection Layer | Description |
|
||||
|---|---|
|
||||
| **Login & Respawn Packet Spoofing** | Replaces the hashed seed in `JOIN_GAME` and `RESPAWN` network packets with a per-player cryptographically random fake seed. Operates at the Netty I/O level via PacketEvents before any client ever receives the real value. |
|
||||
| **Dynamic Seed Rotation** | Periodically assigns each online player a new random fake seed (configurable interval, minimum 30 s). Defeats crackers that accumulate packets across multiple sessions. |
|
||||
| **Plugin API Protection** | Patches the NMS seed field via reflection every second so any plugin calling `World.getSeed()` receives a rotating fake value. Re-applies unconditionally to prevent other plugins from undoing the patch. |
|
||||
| **Dynamic Seed Rotation** | Periodically assigns each online player a new random fake seed (configurable interval, minimum 30 s). Only affects the network-facing hashed seed, never the generation seed. Defeats crackers that accumulate packets across multiple sessions. |
|
||||
| **Seed Integrity Monitor** | Read-only watchdog that compares each world's live generation seed against its value at startup and warns in the console if it ever drifts. Never writes to the seed itself — see [Correctness & Large-Server Notes](#correctness--large-server-notes) for why. |
|
||||
| **Eye of Ender Redirection** | Intercepts thrown Eyes of Ender and redirects their velocity toward a per-player fake stronghold location. Applies a ±8° angular jitter to prevent exact triangulation across repeated throws. |
|
||||
| **/locate Spoofing** | Cancels `/locate` and replies with convincingly fake coordinates. Stronghold queries use the player's stable fake location for consistency. |
|
||||
| **/seed Command Block** | Blocks `/seed`, `/minecraft:seed`, `/getseed`, `/worldseed`, and any extra commands configured in `config.yml` for players, console, and RCON. Also strips seed commands from tab-complete results. |
|
||||
| **End Spike Randomization** | Shuffles the bedrock cap heights of all 10 End obsidian pillars at world load and after each dragon respawn cycle. Breaks the vanilla height fingerprint `{76, 79, 82 … 103}` that uniquely identifies the seed. |
|
||||
| **End City Glass Replacement** | Replaces magenta stained glass in End City chunks with purpur blocks on first load. Prevents End City coordinate fingerprinting via the distinctive glass pattern. |
|
||||
| **End City Glass Replacement** | Replaces magenta stained glass in End City chunks (scanning only the structure's bounding box, not the full chunk column) with purpur blocks on first load. Prevents long-range visual fingerprinting of the distinctive glass pattern. |
|
||||
| **Treasure & Explorer Map Scrambling** | Intercepts `MAP_DATA` packets and randomises the on-map position of every structure icon (buried treasure, woodland mansion, ocean monument, and others). |
|
||||
| **Audit Log** | Appends every blocked command, spoofed packet, and redirected Eye to a flat-file TSV log in `plugins/AntiSeedCracker/logs/`. Zero external dependencies — no SQLite. |
|
||||
| **Slime Chunk Obfuscation** *(opt-in)* | Cancels natural slime spawns in configured worlds so tools sampling spawn/no-spawn per chunk get no signal. Disables slime farms in those worlds — off by default. |
|
||||
| **Structure Reconnaissance Monitor** | Detects (logs only, never blocks) when a player visits an unusual number of seed-cracking-relevant structures in a short window — the exact structure set SeedCrackerX-style tools use for bit accumulation. Structure list is fully configurable; the chunk-qualifies cache is evicted on chunk unload instead of growing for the entire server uptime. See [Threat Model](#threat-model-liquidbounce--seedcrackerx-research) below. |
|
||||
| **Command Probing Monitor** | Detects (logs only, never blocks) a player triggering several blocked seed commands in a short window — a pattern consistent with a script/macro probing for a command-blocker bypass rather than one curious `/seed`. |
|
||||
| **`/execute`-Wrapping Protection** | `/seed`, `/locate`, and every extra blocked command are also checked against the command an `/execute ... run ...` chain ultimately dispatches to (recursively, for nested chains), closing a well-known Bukkit command-blocker gap where `PlayerCommandPreprocessEvent` never re-fires for vanilla's internal command re-dispatch. |
|
||||
| **Audit Log** | Appends every blocked command, spoofed packet, redirected Eye, and recon flag to a flat-file TSV log in `plugins/AntiSeedCracker/logs/`. Zero external dependencies — no SQLite. |
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
| Platform | Supported |
|
||||
|---|---|
|
||||
| Paper 26.1.2 | ✔ |
|
||||
| Purpur 26.1.2 | ✔ |
|
||||
| Folia 26.1.2 | ✔ (region-threaded scheduling) |
|
||||
| Spigot 26.1.2 | ✔ (BukkitScheduler fallback) |
|
||||
| Platform | Supported | Verified |
|
||||
|---|---|---|
|
||||
| Paper 26.1.2 | ✔ | ✔ Live-tested on build 74, zero errors, seed integrity verified byte-for-byte on disk |
|
||||
| Folia 26.1.2 | ✔ (region-threaded scheduling) | ✔ Live-tested on build 8, zero errors, seed integrity verified byte-for-byte on disk |
|
||||
| Purpur 26.1.2 | ✔ (Paper-compatible) | — |
|
||||
| Spigot / CraftBukkit | ✘ Not supported — the plugin uses Paper-only APIs (Adventure, AsyncScheduler) | — |
|
||||
|
||||
**Runtime:** Java 25 or higher is required.
|
||||
|
||||
## Metrics
|
||||
|
||||
Anonymous usage statistics are collected via [bStats](https://bstats.org/plugin/bukkit/AntiSeedCracker/32378). This can be disabled globally in `plugins/bStats/config.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download `AntiSeedCracker-3.1.0.jar` from [Releases](../../releases/latest).
|
||||
1. Download `AntiSeedCracker-3.5.0.2.jar` from [Releases](../../releases/latest).
|
||||
2. Place it in your server's `plugins/` folder.
|
||||
3. Start or restart the server.
|
||||
4. Edit `plugins/AntiSeedCracker/config.yml` to your preference.
|
||||
@@ -50,12 +63,14 @@ AntiSeedCracker is a professional, multi-layer world seed protection plugin for
|
||||
|
||||
| Command | Description | Permission |
|
||||
|---|---|---|
|
||||
| `/asc reload` | Reloads config and restarts all protection tasks | `antiseedcracker.admin` |
|
||||
| `/asc status` | Shows which protection layers are active | `antiseedcracker.admin` |
|
||||
| `/asc stats` | Displays aggregate event counts from the audit log | `antiseedcracker.admin` |
|
||||
| `/asc info` | Shows version, author, platform, and update status | `antiseedcracker.admin` |
|
||||
| `/asc reload` | Reloads config and restarts all protection tasks | `antiseedcracker.admin.manage` |
|
||||
| `/asc toggle <feature> [true\|false]` | Flips (or explicitly sets) one feature live — persists to `config.yml` and reloads automatically. Tab-completes feature names. | `antiseedcracker.admin.manage` |
|
||||
| `/asc status` | Shows which protection layers are active | `antiseedcracker.admin.status` |
|
||||
| `/asc stats` | Displays aggregate event counts from the audit log | `antiseedcracker.admin.status` |
|
||||
| `/asc info` | Shows version, author, platform, and update status | `antiseedcracker.admin.status` |
|
||||
| `/asc help` | Lists the subcommands the sender has permission to use | `antiseedcracker.admin.status` |
|
||||
|
||||
The `antiseedcracker.admin` permission defaults to server operators.
|
||||
`antiseedcracker.admin` is the umbrella permission (default: op) and grants both `antiseedcracker.admin.status` (read-only) and `antiseedcracker.admin.manage` (state-changing) as children, so you can hand trusted moderators view-only access without letting them change config. Toggleable feature names for `/asc toggle`: `login`, `respawn`, `rotation`, `endspikes`, `endcities`, `locate`, `integrity`, `slime`, `recon`, `treasuremap`, `database`, `probing`.
|
||||
|
||||
---
|
||||
|
||||
@@ -85,9 +100,25 @@ structure_protection:
|
||||
enabled: true
|
||||
max_offset: 2000
|
||||
|
||||
plugin_api_protection:
|
||||
seed_integrity_monitor:
|
||||
enabled: true
|
||||
|
||||
slime_chunk_obfuscation:
|
||||
enabled: false
|
||||
worlds: []
|
||||
|
||||
structure_recon_monitor:
|
||||
enabled: true
|
||||
threshold: 6
|
||||
window_seconds: 900
|
||||
# Leave empty to use the built-in default list — see Configuration section below.
|
||||
tracked_structures: []
|
||||
|
||||
command_probing_monitor:
|
||||
enabled: true
|
||||
threshold: 5
|
||||
window_seconds: 30
|
||||
|
||||
eye_of_ender_protection:
|
||||
enabled: true
|
||||
worlds:
|
||||
@@ -107,6 +138,10 @@ database:
|
||||
treasure_map_protection:
|
||||
enabled: true
|
||||
|
||||
messages:
|
||||
seed_blocked_player: "&c[AntiSeedCracker] Access to world seed information is restricted."
|
||||
seed_blocked_console: "&c[AntiSeedCracker] Seed access is restricted. The real seed is never exposed."
|
||||
|
||||
extra_blocked_commands:
|
||||
- "seedcracker"
|
||||
- "seedfind"
|
||||
@@ -128,11 +163,40 @@ AntiSeedCracker never reads, stores, or logs the real world seed. The only world
|
||||
|
||||
Both modifications use `physicsUpdate: false` to prevent block-update cascades. Each chunk is tagged with a Persistent Data Container key so the modification runs **at most once per chunk** across all server restarts. No terrain, biome, or structure data outside these specific block types is ever altered.
|
||||
|
||||
**AntiSeedCracker never writes to a world's generation seed, at any point, for any reason.** Earlier versions did (see below) — this caused real damage and has been removed entirely.
|
||||
|
||||
---
|
||||
|
||||
## Correctness & Large-Server Notes
|
||||
|
||||
**Version 3.3.0 fixes a serious bug present in 3.2.0 and earlier.** The old "Plugin API Protection" feature patched the live in-memory seed field that Minecraft's own chunk generator reads, once per second, forever, on every loaded world. That field isn't a copy — it's the exact same value the vanilla decoration step (trees, ores, ravines, structure placement) re-reads *fresh for every chunk it generates*, not just once at world load. The result: any chunk decorated while that field held a fake value got seeded from randomness instead of your real seed, causing visibly broken terrain/structure generation, and the constant reflection-based writing added avoidable overhead and cross-plugin risk on busy servers.
|
||||
|
||||
This has been completely removed. In its place, `SeedIntegrityMonitor` only *reads* `world.getSeed()` on a low-frequency timer and logs a warning if it ever changes — it never writes to it. This was verified by booting real Paper 26.1.2 and Folia 26.1.2 servers with explicit known seeds and confirming, at the raw NBT byte level in `world_gen_settings.dat`, that the seed never drifted after extended runtime.
|
||||
|
||||
Other large-server changes in this release:
|
||||
- `EndCityProtector` now scans only the generated structure's bounding box instead of the entire chunk column (previously up to 256 vertical blocks × 256 columns per End City chunk).
|
||||
- The periodic player-seed safety-net task runs every 15s instead of every 1s (it no longer has any seed-patching work to do).
|
||||
|
||||
---
|
||||
|
||||
## Threat Model: LiquidBounce & SeedCrackerX Research
|
||||
|
||||
This release includes research into how modern cheat clients and cracking tools actually recover seeds, so protections target real techniques rather than guesses:
|
||||
|
||||
- **SeedCrackerX** (the dominant seed-cracking tool) primarily works by combining the *exact coordinates* of structures the player has legitimately found — shipwrecks, desert pyramids, jungle temples, swamp huts, pillager outposts, ocean monuments, igloos, and End Cities — until it has enough bits to brute-force the seed. **This is not a network exploit.** A player standing at a real structure already knows their own coordinates; no plugin can hide a player's position from themselves without breaking movement entirely. This is a fundamental limitation of any server-side plugin, not just this one — treat any tool's claim of "100% uncrackable" against this technique with skepticism.
|
||||
- What we added because of this: `StructureReconMonitor` watches for the specific pattern SeedCrackerX data-gathering produces — visiting several of those exact structure types in a short time window — and logs it for admin review. It never blocks or punishes, matching this plugin's design; it just gives you visibility into likely automated cracking activity.
|
||||
- **Slime-chunk sampling** is another real technique (spawn/no-spawn per chunk leaks a bit of seed information). `slime_chunk_obfuscation` (opt-in, off by default) cancels natural slime spawns in configured worlds to deny this signal — at the cost of disabling slime farms there.
|
||||
- **LiquidBounce's `XRay` and `StorageESP` modules** (block/ore/chest ESP) are a **different threat entirely** — they exploit full chunk data the server must send for rendering, and have nothing to do with the world seed. This is not something a seed-protection plugin should attempt to solve by reimplementing chunk obfuscation (high risk, likely to break vanilla behavior — exactly what large-server admins told us to stop doing). **Use Paper's built-in anti-xray** (`anticheat.anti-xray` in `paper-world-defaults.yml`) for that threat class; it's mature, maintained, and the correct tool for the job. We surveyed the public source/issue trackers of LiquidBounce, Wurst, and Meteor Client directly: seed-cracking integration has been repeatedly requested against all three and never merged, and none of them ship any feature that detects or reacts to a server spoofing/rotating the hashed-seed packet field, faking stronghold triangulation, or scrambling map icons. Their ESP/X-ray modules only render blocks and entities the server already sent in chunk data — confirmed directly from LiquidBounce's own module docs — which is exactly why that threat class is out of this plugin's scope.
|
||||
- Packet-level hashed-seed spoofing (login/respawn) and `/locate` spoofing still close off the older, simpler leak vectors that predate SeedCrackerX's structure-based method. **Be honest about how load-bearing this layer is, though:** the hashed seed is a narrow, low-information channel (originally just cosmetic biome-border noise), and a well-informed cracker that has enough structure-position bits can simply deprioritize or ignore an inconsistent hash rather than being blocked by it. Treat hashed-seed spoofing/rotation as raising the bar against unsophisticated tools, not as a load-bearing defense on its own — the structure-, End-spike-, and End-City-level protections matter more against a serious attacker.
|
||||
- **`/execute`-wrapping bypass.** Vanilla's `/execute ... run <command>` re-dispatches its trailing command internally without re-firing `PlayerCommandPreprocessEvent` — a documented, long-standing Bukkit/Spigot architectural gap (command re-dispatch via `Server#dispatchCommand` or Brigadier's internal redirect never re-triggers that event). A command-blocker that only pattern-matches the literal typed message would let `/execute run seed` (or nested `/execute as @s run execute run seed`) straight through. **Fixed in 3.5.0**: every blocking check also recursively resolves what an `/execute` chain ultimately dispatches to before deciding whether to block it.
|
||||
- **Known remaining limitation, by design:** commands issued by a **command block** or a **data-pack `/function`** never fire `PlayerCommandPreprocessEvent` at all (not even the outer event `/execute` at least still fires) — there is no supported Paper/Bukkit API hook for either. Closing this would require NMS-level hooks into Brigadier's command dispatcher, which this plugin deliberately avoids for the same reason it avoids reflection-based seed patching (see [Correctness & Large-Server Notes](#correctness--large-server-notes)): version-fragile, cross-plugin-risky code that breaks on every Minecraft update. Mitigate by restricting who can place command blocks or edit data packs (both already require elevated permissions/file access on a well-run server) rather than expecting a plugin to police vanilla mechanics with no exposed event.
|
||||
- **Other anti-seed-cracker projects, for comparison.** [`akshualy/AntiSeedCracker`](https://github.com/akshualy/AntiSeedCracker) (ProtocolLib-based, Spigot 1.20.4–1.21) covers hashed-seed spoofing plus End spike/City modification, similar core scope to this plugin's packet+structure layers. [`zAntiCracker`](https://github.com/Zyralis/zAntiCracker) takes a pure behavioral-heuristic approach instead of packet spoofing — a "suspicion score" from command spam, structure-scan rate, and packet rate, with optional auto-kick; we deliberately don't kick (see Design Principles) and a patient/slow cracker script can trivially stay under rate-based thresholds, but its command-probing concept is genuinely useful as a *detection* signal, which is why 3.5.0 adds the non-punishing `command_probing_monitor`. On the **Fabric side** (a fundamentally different, server-generation-layer approach not available to Paper/Spigot plugins), [`DrexHD/SeedGuard`](https://github.com/DrexHD/SeedGuard) and [`Earthcomputer/SecureSeed`](https://github.com/Earthcomputer/SecureSeed) re-randomize or cryptographically replace the *per-feature* seeds vanilla derives from the world seed, so structure-position triangulation no longer correlates to anything real — architecturally stronger against the "unblockable" triangulation vector than any packet-layer defense, but it requires Fabric server-side and isn't something the Bukkit/Paper API exposes a hook for. If you can run Fabric, that class of plugin closes a gap this one structurally cannot.
|
||||
|
||||
---
|
||||
|
||||
## Folia Threading Model
|
||||
|
||||
All block mutations are dispatched to the region thread owning the relevant chunk via `RegionScheduler` (Folia) or the main thread (Paper/Spigot). Packet interceptors run on the Netty I/O thread and only perform `ConcurrentHashMap` lookups. The seed rotation and world-seed broadcast tasks run on the `AsyncScheduler` (Paper/Folia) or an asynchronous `BukkitTask` (Spigot).
|
||||
All block mutations are dispatched to the region thread owning the relevant chunk via `RegionScheduler` (Folia) or the main thread (Paper). Packet interceptors run on the Netty I/O thread and only perform `ConcurrentHashMap` lookups. The seed rotation and player-seed safety-net tasks run on the `AsyncScheduler` (Paper/Folia).
|
||||
|
||||
---
|
||||
|
||||
@@ -142,14 +206,19 @@ All block mutations are dispatched to the region thread owning the relevant chun
|
||||
|---|---|
|
||||
| Login/Respawn packet interception | PacketEvents HIGHEST-priority intercept |
|
||||
| `/seed`, `/getseed`, `/worldseed` | `PlayerCommandPreprocessEvent` + `ServerCommandEvent` + `RemoteServerCommandEvent` blocking |
|
||||
| `/execute ... run seed` (or nested) wrapping | Recursive `/execute` chain resolution before the block check — see Threat Model |
|
||||
| Eye of Ender triangulation | Per-player fake stronghold + ±8° jitter |
|
||||
| `/locate structure` triangulation | Spoofed coordinate output, consistent per-player stronghold fake |
|
||||
| Treasure / explorer maps | `MAP_DATA` packet decoration scrambling |
|
||||
| `/locate structure` triangulation | Spoofed coordinate output, cached per player+structure so repeated queries stay consistent |
|
||||
| Treasure / explorer maps | `MAP_DATA` packet decoration scrambling, deterministic per map+icon (no visible jitter on refresh) |
|
||||
| End spike height fingerprinting | Full Fisher-Yates shuffle of pillar cap heights |
|
||||
| End City coordinate fingerprinting | Magenta glass block replacement |
|
||||
| Other plugin API (`world.getSeed()`) | NMS field reflection patch, re-applied unconditionally every second |
|
||||
| End City visual fingerprinting | Magenta glass block replacement (bounding-box scoped) |
|
||||
| SeedCrackerX-style structure recon | Detection + audit logging (cannot be prevented — see Threat Model) |
|
||||
| Command-blocker bypass scripting/probing | Detection + audit logging (never blocks — see Threat Model) |
|
||||
| Slime-chunk seed sampling | Optional natural-spawn cancellation |
|
||||
| Tab-complete exposure | `TabCompleteEvent` filtering |
|
||||
| RCON seed commands | `RemoteServerCommandEvent` blocking |
|
||||
| Command-block / data-pack `/function` execution | **Known limitation, not interceptable via Bukkit API** — see Threat Model |
|
||||
| Block/ore ESP (XRay, StorageESP) | Out of scope — use Paper's native anti-xray |
|
||||
|
||||
---
|
||||
|
||||
@@ -158,8 +227,9 @@ All block mutations are dispatched to the region thread owning the relevant chun
|
||||
Requires Maven 3.6+ and **JDK 25**. The project compiles against folia-api 26.1.2 (Java 25 class files) and produces a Java 25 output jar.
|
||||
|
||||
```sh
|
||||
# Windows
|
||||
set JAVA_HOME=C:\Program Files\Java\jdk-25.0.3
|
||||
# Windows (adjust to wherever your JDK 25 is actually installed, e.g. under
|
||||
# "C:\Program Files\Eclipse Adoptium\" for Temurin builds)
|
||||
set JAVA_HOME=C:\Program Files\Java\jdk-25
|
||||
mvn clean package
|
||||
|
||||
# Linux / macOS
|
||||
@@ -167,10 +237,19 @@ export JAVA_HOME=/path/to/jdk-25
|
||||
mvn clean package
|
||||
```
|
||||
|
||||
The shaded jar is produced at `target/AntiSeedCracker-3.1.0.jar`. PacketEvents is bundled and relocated to `me.legendcraft.antiseedcracker.lib.packetevents`.
|
||||
The shaded jar is produced at `target/AntiSeedCracker-3.5.0.2.jar`. PacketEvents 2.13.0 and bStats 3.2.1 are bundled and relocated under `me.sunsetrqsv.antiseedcracker.lib`.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Never punish players.** AntiSeedCracker never kicks or bans anyone — it silently feeds cheat clients useless data.
|
||||
- **Never touch the real seed.** The plugin never reads, stores, transmits, or logs the actual world seed.
|
||||
- **Never corrupt the world.** All optional world modifications are single-block, physics-free, idempotent, and clearly documented.
|
||||
- **Fully asynchronous.** Packet work happens on Netty I/O threads; periodic tasks run on async schedulers; block mutations are dispatched to the owning region thread on Folia.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
All rights reserved. Redistribution without permission is prohibited.
|
||||
Released under the [MIT License](LICENSE). Free to use, modify, and redistribute.
|
||||
@@ -0,0 +1,92 @@
|
||||
# AntiSeedCracker
|
||||
|
||||
[](https://github.com/SunsetRQ7/AntiSeedCracker/actions/workflows/build.yml)
|
||||
[](https://github.com/SunsetRQ7/AntiSeedCracker/releases/latest)
|
||||
[](https://github.com/SunsetRQ7/AntiSeedCracker/blob/main/LICENSE)
|
||||
[](https://adoptium.net/)
|
||||
|
||||
### Stop seed crackers. Silently. Permanently.
|
||||
|
||||
Your world seed is the master key to your entire map. Whoever recovers it can compute — instantly, from outside the game, with zero in-game effort — the exact location of every stronghold, ancient city, buried treasure, ore vein, and slime chunk on your server. **That's not a minor leak. That's wallhack-grade ESP for your whole world.**
|
||||
|
||||
AntiSeedCracker shuts every known door a cheat client, ghost client, or tool like SeedCrackerX can use to get it. **14 protection layers. Zero kicks. Zero bans.** Cheaters just quietly receive cryptographically random garbage and never find out why their cracker keeps failing.
|
||||
|
||||
---
|
||||
|
||||
## Why protect your seed?
|
||||
|
||||
Once your seed is out, it doesn't matter how good your build-battle event or your economy plugin is — every hidden structure on the map is public information to anyone with a laptop. Seed-cracking tools don't need to hack your server or read your files: they reconstruct the seed purely from structure coordinates a player legitimately walked to. AntiSeedCracker can't make that impossible (nothing can — see **Honest limits** below), but it closes off every *other* way to leak it, and gives you visibility into the one it can't.
|
||||
|
||||
## Protection layers
|
||||
|
||||
**Network-level**
|
||||
- **Packet-level seed spoofing** — replaces the hashed seed in `JOIN_GAME` and `RESPAWN` packets with a per-player, cryptographically random fake before it ever leaves the server (Netty-level, via bundled PacketEvents).
|
||||
- **Dynamic seed rotation** — every player's fake seed rotates on a configurable interval, defeating crackers that accumulate data across sessions.
|
||||
- **Seed integrity monitor** — read-only watchdog confirming the real generation seed never drifts. AntiSeedCracker does not, and will not, write to your world's actual generation seed — see **Safe for your world** below.
|
||||
|
||||
**World & structure**
|
||||
- **Eye of Ender redirection** — thrown Eyes are redirected to a per-player fake stronghold for their entire flight path, with angular jitter, making triangulation useless.
|
||||
- **`/locate` spoofing** — returns convincing fake coordinates, stable per player and per structure so repeat queries can't be used to spot the fake.
|
||||
- **End spike randomization** — shuffles the bedrock cap heights of the ten End pillars, destroying the vanilla height fingerprint that uniquely identifies a seed.
|
||||
- **End City fingerprint removal** — replaces the distinctive magenta glass pattern in End Cities (scoped to the structure's bounding box, not the whole chunk).
|
||||
- **Treasure & explorer map scrambling** — buried treasure, mansion, monument, and other structure icons on maps are randomized in the `MAP_DATA` packet, stable per map so icons don't visibly jitter around.
|
||||
- **Slime chunk obfuscation** *(opt-in)* — denies slime-chunk-sampling-based cracking at the cost of disabling slime farms where enabled.
|
||||
|
||||
**Command & detection** *(all logging-only where noted — never punishes players)*
|
||||
- **Seed command blocking** — `/seed` and friends are blocked for players, console, and RCON, and stripped from tab-complete.
|
||||
- **`/execute`-wrapping protection** — every blocked command is also checked against what an `/execute ... run ...` chain ultimately dispatches to (recursively, for nested chains), closing a well-known Bukkit command-blocker gap.
|
||||
- **Structure reconnaissance monitor** — logs suspicious multi-structure visitation patterns matching known seed-cracking tool behavior; the tracked structure list is fully configurable.
|
||||
- **Command probing monitor** — logs a player racking up several blocked seed commands in a short window, a pattern consistent with a bypass-probing script rather than one curious `/seed`.
|
||||
- **Audit log** — every blocked attempt is recorded to a lightweight flat-file log, with `/asc stats` summaries in-game.
|
||||
|
||||
## Never punishes players
|
||||
|
||||
AntiSeedCracker follows a strict no-punishment philosophy: no kicks, no bans, ever. Cheat tools silently fail while legitimate gameplay is completely unaffected — nobody gets caught in the crossfire.
|
||||
|
||||
## Safe for your world
|
||||
|
||||
- The real seed is never read into logs, files, or network traffic, and is never rewritten by this plugin.
|
||||
- An earlier internal design continuously overwrote the live in-memory generation seed to spoof `World.getSeed()` for other plugins. That approach was removed in 3.3.0 after we found it could corrupt chunk decoration on affected servers — Minecraft re-reads that field fresh for every chunk it generates, not just once at world load. The plugin now only ever reads that value, never writes it.
|
||||
- Optional cosmetic changes (End spike caps, End City glass) are single-block, physics-free, run at most once per chunk, and can be disabled in the config.
|
||||
- Verified by live testing on real Paper 26.1.2 and Folia 26.1.2 servers: zero errors, real seed confirmed byte-for-byte intact on disk after extended runtime.
|
||||
|
||||
## Honest limits
|
||||
|
||||
No plugin can make a seed truly uncrackable against tools like SeedCrackerX, which reconstruct it from the real coordinates of structures a player has legitimately explored — that's not a network exploit, and coordinates can't be hidden from a player standing at their own position. AntiSeedCracker closes every server-side leak we're aware of (packet spoofing, triangulation, command access, structure fingerprinting) and adds detection for cracking-pattern behavior, but exploration-based cracking is a limit of the genre, not something this plugin claims to solve. If a listing promises "100% uncrackable," be skeptical of it — not of us for saying so.
|
||||
|
||||
## Performance
|
||||
|
||||
Fully asynchronous by design. Packet work runs on Netty I/O threads with lock-free map lookups; periodic tasks use the async scheduler; on Folia, block operations are dispatched to the owning region thread. Zero measurable TPS impact.
|
||||
|
||||
## Requirements & compatibility
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Server software | Paper, Purpur, or Folia |
|
||||
| Minecraft version | 26.1 and newer (26.1.x – 26.2) |
|
||||
| Java | 25 or newer |
|
||||
| Dependencies | None — PacketEvents is bundled |
|
||||
|
||||
Spigot/CraftBukkit and legacy Minecraft versions (1.16.5 – 25.x) are not supported by this release, as the plugin relies on modern Paper APIs for its async and region-threaded architecture.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Drop `AntiSeedCracker-3.5.0.2.jar` into your `plugins/` folder.
|
||||
2. Restart the server. Protection is active immediately with sensible defaults.
|
||||
3. Optionally tune `plugins/AntiSeedCracker/config.yml` — every layer, world list, message, and interval is configurable — and apply with `/asc reload`, or flip a single feature live with `/asc toggle`.
|
||||
|
||||
## Commands
|
||||
|
||||
- `/asc status` — see every protection layer at a glance
|
||||
- `/asc stats` — audit log statistics
|
||||
- `/asc reload` — apply config changes live
|
||||
- `/asc toggle <feature> [true|false]` — flip one feature live without a full reload
|
||||
- `/asc info` — version, platform, and update status
|
||||
- `/asc help` — lists the subcommands you have permission to use
|
||||
|
||||
Permissions: `antiseedcracker.admin` (umbrella, default: op), or split as `antiseedcracker.admin.status` (view-only) / `antiseedcracker.admin.manage` (reload/toggle)
|
||||
|
||||
---
|
||||
|
||||
**Open source** under the MIT License: https://github.com/SunsetRQ7/AntiSeedCracker
|
||||
Issues and feature requests welcome on GitHub. Anonymous usage metrics via bStats (opt-out supported).
|
||||
@@ -0,0 +1,98 @@
|
||||
## 3.5.0.2 — 2026-07-24
|
||||
|
||||
**Fixed**
|
||||
- Fixed buried treasure map locations never actually being scrambled: the treasure map protection layer was filtering map icons by the wrong internal name, so a real buried treasure map still pointed straight at the true, unmodified location. It now correctly detects and scrambles the treasure marker.
|
||||
- Fixed `end_cities`/`end_spikes` protection being able to permanently disable itself: if `modify_world` was left off for a world or chunk, turning it on later (including via `/asc toggle`) would silently do nothing for anything already loaded once while it was off. It now correctly applies the moment it's turned on.
|
||||
- Fixed the End obsidian pillar protection starting up to four times more background work than needed during a dragon respawn.
|
||||
- General code cleanup and minor performance tightening (task scheduling, config parsing) with no behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## 3.5.0.1 — 2026-07-22
|
||||
|
||||
**Fixed**
|
||||
- Fixed Eye of Ender redirection not actually holding: thrown eyes had their initial velocity redirected toward the fake stronghold, but Minecraft's own homing behavior would curve them back toward the real stronghold's direction within a second or two of flight. The eye's target location is now redirected as well, so it flies toward the fake stronghold for its entire flight, not just its first moment in the air.
|
||||
- Fixed the fake stronghold location silently changing for online players on `/asc reload` or `/asc toggle`, which could make a previously consistent `/locate stronghold` or Eye of Ender answer jump to a new location with no world change. Each player now keeps the same fake stronghold for their whole session, exactly as intended.
|
||||
|
||||
---
|
||||
|
||||
## 3.5.0 — 2026-07-22
|
||||
|
||||
**Fixed**
|
||||
- Fixed a compiler/target mismatch that prevented a clean build from source (build now correctly targets Java 25, matching the bundled Folia API).
|
||||
- Fixed an `/execute`-wrapped bypass of the seed-command blocker: `/execute run seed` (including nested chains and dimension switches like `/execute in minecraft:the_end run seed`) previously slipped past the blocker because `/execute` re-dispatches its inner command internally. Blocked commands are now resolved recursively through `/execute` chains before the check runs.
|
||||
- Fixed `/asc stats` occasionally attaching its output to the next RCON command's response due to an async delay. Console/RCON senders now get a synchronous reply; player replies remain async to avoid blocking on file I/O.
|
||||
- Fixed console log lines (em-dashes, box-drawing characters) rendering as garbled text on Windows consoles.
|
||||
- Fixed `/locate` returning a brand-new fake location on every repeated query for the same structure, which could tip off a suspicious player. Results are now stable per player and structure.
|
||||
- Fixed treasure/explorer map icons visibly jumping to a new fake position on every map refresh. The fake position is now stable per map.
|
||||
- Fixed unbounded memory growth in the structure reconnaissance monitor's per-chunk cache — entries are now evicted when their chunk unloads.
|
||||
- Fixed a namespace-stripping bug in `extra_blocked_commands` matching that could mismatch commands containing a colon later in the argument string.
|
||||
- Synced version numbers across `pom.xml`, `plugin.yml`, and `README.md`, and removed a stale reference to a feature already removed back in 3.3.0.
|
||||
|
||||
**Added**
|
||||
- `/asc toggle <feature> [true|false]` — flip any protection layer on or off live, persisted to `config.yml`, no reload required.
|
||||
- New granular permissions: `antiseedcracker.admin.status` (read-only) and `antiseedcracker.admin.manage` (reload/toggle) — both still covered by the existing `antiseedcracker.admin` permission, so existing setups keep working unchanged.
|
||||
- `/asc help` — lists only the subcommands the sender actually has permission to use.
|
||||
- New Command Probing Monitor: logs (never blocks — same philosophy as every other detection layer in this plugin) when a player triggers several blocked seed commands in a short window, a pattern consistent with a script or macro probing for a bypass.
|
||||
- `structure_recon_monitor.tracked_structures` is now configurable, and now includes Buried Treasure by default.
|
||||
|
||||
---
|
||||
|
||||
## 3.3.1 — 2026-07-12
|
||||
|
||||
**Fixed**
|
||||
- Fixed a thread-visibility bug where `/asc reload` updated config/database state in a way that wasn't reliably visible to packet-handling and async threads.
|
||||
- Fixed `extra_blocked_commands` entries with mixed-case letters silently failing to match.
|
||||
- Fixed repeated, uncached structure lookups on every chunk crossing for every player — lookups are now cached per chunk.
|
||||
- Fixed the audit log opening and closing a file handle for every single event; it now keeps a persistent writer and only rotates on date change.
|
||||
- Added Ancient City to the tracked structure set.
|
||||
- Removed dead code left over from a previously removed feature.
|
||||
- Verified live on Paper 26.1.2-74 and Folia 26.1.2-8 with zero errors; real seed confirmed byte-for-byte intact on disk after extended runtime.
|
||||
|
||||
---
|
||||
|
||||
## 3.3.0 — 2026-07-11
|
||||
|
||||
**Fixed**
|
||||
- **Critical fix**: removed a feature that patched the live world seed in memory once per second. That value is re-read by vanilla chunk decoration (trees, ores, structure placement) fresh for every chunk generated, not just once at world load — so any chunk decorated while it held a fake value generated incorrectly instead of from the real seed. This was the root cause of reports of broken chunk generation.
|
||||
- Replaced it with a read-only Seed Integrity Monitor that only ever reads the world seed and warns if it drifts — it never writes to it.
|
||||
- End City protection now scans only the structure's bounding box instead of the entire chunk column, reducing overhead on large servers.
|
||||
- Reduced the player-seed safety-net task's interval from every 1 second to every 15 seconds.
|
||||
|
||||
**Added**
|
||||
- Structure Reconnaissance Monitor (audit-log only — never blocks or punishes players).
|
||||
- Optional Slime Chunk Obfuscation (off by default).
|
||||
|
||||
**Notes**
|
||||
- Ore/block ESP protection (X-ray, storage ESP) is intentionally out of scope for this plugin — use your server software's built-in anti-X-ray instead.
|
||||
|
||||
---
|
||||
|
||||
## 3.2.0 — 2026-07-04
|
||||
|
||||
**Added**
|
||||
- Anonymous usage metrics (bStats), with platform and protection-layer charts.
|
||||
- Configurable seed-blocked messages in `config.yml`.
|
||||
- `/asc reload` now applies database enable/disable without a restart.
|
||||
|
||||
**Changed**
|
||||
- Upgraded PacketEvents 2.12.2 → 2.13.0.
|
||||
|
||||
**Verified**
|
||||
- Live-tested with zero errors on Paper 26.1.2-72 and Folia 26.1.2-8.
|
||||
|
||||
---
|
||||
|
||||
## 3.1.1 — 2026-06-13
|
||||
|
||||
**Fixed**
|
||||
- Fixed a `NoClassDefFoundError` on Folia/Paper caused by a missing bundled dependency.
|
||||
|
||||
**Changed**
|
||||
- Upgraded PacketEvents 2.12.0 → 2.12.2.
|
||||
|
||||
---
|
||||
|
||||
## 3.1.0 — 2026-06-13
|
||||
|
||||
Initial release: professional multi-layer world seed protection for Paper, Purpur, and Folia.
|
||||
@@ -4,13 +4,13 @@
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>me.legendcraft</groupId>
|
||||
<groupId>me.sunsetrqsv</groupId>
|
||||
<artifactId>AntiSeedCracker</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<version>3.5.0.2</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>AntiSeedCracker</name>
|
||||
<description>Professional multi-layer world seed protection plugin for Paper, Purpur, Folia, and Spigot 26.1.2+</description>
|
||||
<description>Professional multi-layer world seed protection plugin for Paper, Purpur, and Folia 26.1.2+</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
@@ -43,7 +43,7 @@
|
||||
<dependency>
|
||||
<groupId>com.github.retrooper</groupId>
|
||||
<artifactId>packetevents-spigot</artifactId>
|
||||
<version>2.12.0</version>
|
||||
<version>2.13.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
@@ -67,6 +67,13 @@
|
||||
<version>1.20-R0.2</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.bstats</groupId>
|
||||
<artifactId>bstats-bukkit</artifactId>
|
||||
<version>3.2.1</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -78,6 +85,7 @@
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>25</release>
|
||||
<debug>false</debug>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
@@ -91,11 +99,15 @@
|
||||
<relocations>
|
||||
<relocation>
|
||||
<pattern>com.github.retrooper.packetevents</pattern>
|
||||
<shadedPattern>me.legendcraft.antiseedcracker.lib.packetevents</shadedPattern>
|
||||
<shadedPattern>me.sunsetrqsv.antiseedcracker.lib.packetevents</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>io.github.retrooper.packetevents</pattern>
|
||||
<shadedPattern>me.legendcraft.antiseedcracker.lib.packetevents.impl</shadedPattern>
|
||||
<shadedPattern>me.sunsetrqsv.antiseedcracker.lib.packetevents.impl</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>org.bstats</pattern>
|
||||
<shadedPattern>me.sunsetrqsv.antiseedcracker.lib.bstats</shadedPattern>
|
||||
</relocation>
|
||||
</relocations>
|
||||
<transformers>
|
||||
@@ -120,10 +132,6 @@
|
||||
<artifact>net.kyori:adventure-key</artifact>
|
||||
<excludes><exclude>**</exclude></excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>net.kyori:adventure-nbt</artifact>
|
||||
<excludes><exclude>**</exclude></excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>net.kyori:examination-api</artifact>
|
||||
<excludes><exclude>**</exclude></excludes>
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package me.legendcraft.antiseedcracker.commands;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class AdminCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private static final String PERM = "antiseedcracker.admin";
|
||||
|
||||
private static final Component PREFIX =
|
||||
Component.text("[", NamedTextColor.DARK_GRAY)
|
||||
.append(Component.text("ASC", NamedTextColor.AQUA))
|
||||
.append(Component.text("] ", NamedTextColor.DARK_GRAY));
|
||||
|
||||
private static final List<String> SUBCOMMANDS = Arrays.asList("reload", "status", "stats", "info");
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
|
||||
public AdminCommand(AntiSeedCrackerPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!sender.hasPermission(PERM)) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"You do not have permission to use this command.", NamedTextColor.RED)));
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
sendUsage(sender);
|
||||
return true;
|
||||
}
|
||||
switch (args[0].toLowerCase()) {
|
||||
case "reload" -> handleReload(sender);
|
||||
case "status" -> handleStatus(sender);
|
||||
case "stats" -> handleStats(sender);
|
||||
case "info" -> handleInfo(sender);
|
||||
default -> sendUsage(sender);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (!sender.hasPermission(PERM)) return Collections.emptyList();
|
||||
if (args.length == 1) {
|
||||
String partial = args[0].toLowerCase();
|
||||
return SUBCOMMANDS.stream()
|
||||
.filter(s -> s.startsWith(partial))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private void handleReload(CommandSender sender) {
|
||||
sender.sendMessage(msg(Component.text("Reloading AntiSeedCracker…", NamedTextColor.YELLOW)));
|
||||
try {
|
||||
plugin.reload();
|
||||
sender.sendMessage(msg(Component.text("Reload successful.", NamedTextColor.GREEN)));
|
||||
} catch (Exception e) {
|
||||
sender.sendMessage(msg(Component.text("Reload failed: " + e.getMessage(), NamedTextColor.RED)));
|
||||
plugin.getLogger().severe("[AntiSeedCracker] Reload exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleStatus(CommandSender sender) {
|
||||
boolean loginIntercepted = plugin.getPluginConfig().isInterceptLogin();
|
||||
boolean respawnIntercepted = plugin.getPluginConfig().isInterceptRespawn();
|
||||
boolean rotationEnabled = plugin.getPluginConfig().isSeedRotationEnabled();
|
||||
boolean spikesEnabled = plugin.getPluginConfig().isEndSpikesEnabled();
|
||||
boolean citiesEnabled = plugin.getPluginConfig().isEndCitiesEnabled();
|
||||
boolean eyeEnabled = plugin.getPluginConfig().isEyeOfEnderEnabled();
|
||||
boolean locateEnabled = plugin.getPluginConfig().isSpoofLocateEnabled();
|
||||
boolean pluginApiEnabled = plugin.getPluginConfig().isPluginApiProtectionEnabled();
|
||||
boolean interceptorActive = plugin.getWorldSeedInterceptor().isAvailable();
|
||||
boolean mapProtEnabled = plugin.getPluginConfig().isTreasureMapProtectionEnabled();
|
||||
boolean dbEnabled = plugin.getDatabaseManager() != null;
|
||||
int trackedSeeds = plugin.getSeedManager().trackedCount();
|
||||
|
||||
sender.sendMessage(Component.empty());
|
||||
sender.sendMessage(msg(Component.text("━━ AntiSeedCracker v"
|
||||
+ plugin.getPluginMeta().getVersion() + " by SunsetRQ7_ ━━",
|
||||
NamedTextColor.GOLD)));
|
||||
sender.sendMessage(statusLine("Login Packet Intercept", loginIntercepted));
|
||||
sender.sendMessage(statusLine("Respawn Packet Intercept", respawnIntercepted));
|
||||
sender.sendMessage(statusLine("Seed Rotation", rotationEnabled));
|
||||
sender.sendMessage(statusLine("End Spike Protection", spikesEnabled));
|
||||
sender.sendMessage(statusLine("End City Protection", citiesEnabled));
|
||||
sender.sendMessage(statusLine("Eye of Ender Redirect", eyeEnabled));
|
||||
sender.sendMessage(statusLine("/locate Spoof", locateEnabled));
|
||||
sender.sendMessage(statusLine("Plugin API Protection", pluginApiEnabled && interceptorActive));
|
||||
sender.sendMessage(statusLine("Treasure Map Scramble", mapProtEnabled));
|
||||
sender.sendMessage(statusLine("Database / Audit Log", dbEnabled));
|
||||
sender.sendMessage(msg(
|
||||
Component.text("Tracked players: ", NamedTextColor.AQUA)
|
||||
.append(Component.text(String.valueOf(trackedSeeds), NamedTextColor.WHITE))));
|
||||
sender.sendMessage(Component.empty());
|
||||
}
|
||||
|
||||
private void handleStats(CommandSender sender) {
|
||||
me.legendcraft.antiseedcracker.database.DatabaseManager db =
|
||||
plugin.getDatabaseManager();
|
||||
if (db == null) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Database is disabled or failed to initialise.", NamedTextColor.RED)));
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(msg(Component.text("Loading stats…", NamedTextColor.YELLOW)));
|
||||
FoliaSchedulerUtil.runAsync(plugin, () -> {
|
||||
java.util.Map<String, Long> stats = db.getStats();
|
||||
FoliaSchedulerUtil.runGlobal(plugin, () -> {
|
||||
sender.sendMessage(Component.empty());
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"━━ Protection Statistics ━━", NamedTextColor.GOLD)));
|
||||
if (stats.isEmpty()) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"No events recorded yet.", NamedTextColor.GRAY)));
|
||||
} else {
|
||||
for (java.util.Map.Entry<String, Long> e : stats.entrySet()) {
|
||||
String label = e.getKey().replace('_', ' ').toLowerCase();
|
||||
label = Character.toUpperCase(label.charAt(0)) + label.substring(1);
|
||||
sender.sendMessage(
|
||||
Component.text(" ", NamedTextColor.DARK_GRAY)
|
||||
.append(Component.text(label + ": ", NamedTextColor.GRAY))
|
||||
.append(Component.text(String.valueOf(e.getValue()),
|
||||
NamedTextColor.WHITE)));
|
||||
}
|
||||
}
|
||||
sender.sendMessage(Component.empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void handleInfo(CommandSender sender) {
|
||||
me.legendcraft.antiseedcracker.util.UpdateChecker uc = plugin.getUpdateChecker();
|
||||
sender.sendMessage(Component.empty());
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"━━ AntiSeedCracker Info ━━", NamedTextColor.GOLD)));
|
||||
sender.sendMessage(msg(Component.text("Version: ", NamedTextColor.GRAY)
|
||||
.append(Component.text(plugin.getPluginMeta().getVersion(),
|
||||
NamedTextColor.WHITE))));
|
||||
sender.sendMessage(msg(Component.text("Author: ", NamedTextColor.GRAY)
|
||||
.append(Component.text("SunsetRQ7_", NamedTextColor.AQUA))));
|
||||
sender.sendMessage(msg(Component.text("Platform: ", NamedTextColor.GRAY)
|
||||
.append(Component.text(
|
||||
me.legendcraft.antiseedcracker.util.PlatformUtil.name(),
|
||||
NamedTextColor.WHITE))));
|
||||
if (uc != null) {
|
||||
if (uc.isUpToDate()) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Update: ✔ Up to date", NamedTextColor.GREEN)));
|
||||
} else if (uc.getLatestVersion() != null) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Update: ✘ v" + uc.getLatestVersion()
|
||||
+ " available on Modrinth!", NamedTextColor.YELLOW)));
|
||||
} else {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Update: not yet checked", NamedTextColor.GRAY)));
|
||||
}
|
||||
}
|
||||
sender.sendMessage(Component.empty());
|
||||
}
|
||||
|
||||
private static Component msg(Component body) {
|
||||
return PREFIX.append(body);
|
||||
}
|
||||
|
||||
private static Component statusLine(String label, boolean enabled) {
|
||||
Component status = enabled
|
||||
? Component.text("✔ enabled", NamedTextColor.GREEN)
|
||||
: Component.text("✘ disabled", NamedTextColor.RED);
|
||||
return Component.text(" ")
|
||||
.append(Component.text(label, NamedTextColor.GRAY))
|
||||
.append(Component.text(": ", NamedTextColor.DARK_GRAY))
|
||||
.append(status);
|
||||
}
|
||||
|
||||
private void sendUsage(CommandSender sender) {
|
||||
sender.sendMessage(msg(
|
||||
Component.text("Usage: ", NamedTextColor.YELLOW)
|
||||
.append(Component.text("/asc <reload|status|stats|info>",
|
||||
NamedTextColor.WHITE))));
|
||||
}
|
||||
}
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
package me.legendcraft.antiseedcracker.listeners;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.server.RemoteServerCommandEvent;
|
||||
import org.bukkit.event.server.ServerCommandEvent;
|
||||
import org.bukkit.event.server.TabCompleteEvent;
|
||||
|
||||
public final class CommandProtectionListener implements Listener {
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
|
||||
public CommandProtectionListener(AntiSeedCrackerPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
|
||||
String raw = event.getMessage().trim();
|
||||
if (!isSeedCommand(raw) && !isExtraBlocked(raw)) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
event.getPlayer().sendMessage(
|
||||
"§c[AntiSeedCracker] Access to world seed information is restricted.");
|
||||
plugin.getLogger().warning(
|
||||
"[AntiSeedCracker] Seed command blocked for player "
|
||||
+ event.getPlayer().getName() + ": " + raw);
|
||||
|
||||
me.legendcraft.antiseedcracker.database.DatabaseManager db =
|
||||
plugin.getDatabaseManager();
|
||||
if (db != null && plugin.getPluginConfig().isDatabaseLogEvents()) {
|
||||
db.logEvent(
|
||||
me.legendcraft.antiseedcracker.database.DatabaseManager.EventType
|
||||
.SEED_COMMAND_BLOCKED,
|
||||
event.getPlayer().getUniqueId().toString(),
|
||||
event.getPlayer().getName(),
|
||||
event.getPlayer().getWorld().getName(),
|
||||
raw);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void onServerCommand(ServerCommandEvent event) {
|
||||
String raw = event.getCommand().trim();
|
||||
if (!isSeedCommand("/" + raw) && !isExtraBlocked("/" + raw)) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
event.getSender().sendMessage(
|
||||
"§c[AntiSeedCracker] Seed access is restricted. The real seed is never exposed.");
|
||||
plugin.getLogger().warning(
|
||||
"[AntiSeedCracker] Console seed command blocked: " + raw);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void onRemoteServerCommand(RemoteServerCommandEvent event) {
|
||||
String raw = event.getCommand().trim();
|
||||
if (!isSeedCommand("/" + raw) && !isExtraBlocked("/" + raw)) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
event.getSender().sendMessage(
|
||||
"§c[AntiSeedCracker] Seed access is restricted. The real seed is never exposed.");
|
||||
plugin.getLogger().warning(
|
||||
"[AntiSeedCracker] RCON seed command blocked: " + raw);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onTabComplete(TabCompleteEvent event) {
|
||||
java.util.List<String> extra = plugin.getPluginConfig().getExtraBlockedCommands();
|
||||
event.getCompletions().removeIf(c -> {
|
||||
String lower = c.toLowerCase();
|
||||
if (lower.startsWith("/")) lower = lower.substring(1);
|
||||
int colon = lower.indexOf(':');
|
||||
if (colon >= 0) lower = lower.substring(colon + 1);
|
||||
return lower.equals("seed")
|
||||
|| lower.equals("getseed")
|
||||
|| lower.equals("worldseed")
|
||||
|| extra.contains(lower);
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isSeedCommand(String raw) {
|
||||
String lower = raw.toLowerCase();
|
||||
if (lower.startsWith("/")) lower = lower.substring(1);
|
||||
int colon = lower.indexOf(':');
|
||||
if (colon >= 0) lower = lower.substring(colon + 1);
|
||||
return lower.equals("seed")
|
||||
|| lower.startsWith("seed ")
|
||||
|| lower.equals("getseed")
|
||||
|| lower.startsWith("getseed ")
|
||||
|| lower.equals("worldseed")
|
||||
|| lower.startsWith("worldseed ");
|
||||
}
|
||||
|
||||
private boolean isExtraBlocked(String raw) {
|
||||
String lower = raw.toLowerCase();
|
||||
if (lower.startsWith("/")) lower = lower.substring(1);
|
||||
int colon = lower.indexOf(':');
|
||||
if (colon >= 0) lower = lower.substring(colon + 1);
|
||||
String root = lower.split("\\s+")[0];
|
||||
return plugin.getPluginConfig().getExtraBlockedCommands().contains(root);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
HandlerList.unregisterAll(this);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package me.legendcraft.antiseedcracker.seed;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class SeedManager {
|
||||
|
||||
private final ConcurrentHashMap<UUID, Long> playerSeeds = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<UUID, long[]> fakeStrongholds = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<UUID, Long> worldSeeds = new ConcurrentHashMap<>();
|
||||
|
||||
private final SecureRandom rng = new SecureRandom();
|
||||
private final Logger logger;
|
||||
|
||||
public SeedManager(Logger logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public long assignSeed(Player player) {
|
||||
long seed = generateSeed();
|
||||
playerSeeds.put(player.getUniqueId(), seed);
|
||||
return seed;
|
||||
}
|
||||
|
||||
public long getFakeSeed(Player player) {
|
||||
return playerSeeds.computeIfAbsent(player.getUniqueId(), k -> generateSeed());
|
||||
}
|
||||
|
||||
public void removePlayer(UUID uuid) {
|
||||
playerSeeds.remove(uuid);
|
||||
fakeStrongholds.remove(uuid);
|
||||
}
|
||||
|
||||
public int rotateAll() {
|
||||
int count = 0;
|
||||
for (UUID uuid : playerSeeds.keySet()) {
|
||||
playerSeeds.put(uuid, generateSeed());
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public void forgetWorld(UUID worldUID) {
|
||||
worldSeeds.remove(worldUID);
|
||||
}
|
||||
|
||||
public long assignWorldFakeSeed(World world) {
|
||||
return worldSeeds.computeIfAbsent(world.getUID(), k -> generateSeed());
|
||||
}
|
||||
|
||||
public long getWorldFakeSeed(World world) {
|
||||
return worldSeeds.computeIfAbsent(world.getUID(), k -> generateSeed());
|
||||
}
|
||||
|
||||
public void ensureAllPlayersHaveSeeds(Collection<? extends Player> players) {
|
||||
for (Player player : players) {
|
||||
playerSeeds.computeIfAbsent(player.getUniqueId(), k -> generateSeed());
|
||||
}
|
||||
}
|
||||
|
||||
public int[] assignFakeStronghold(Player player, int minDist, int maxDist) {
|
||||
double angle = rng.nextDouble() * 2.0 * Math.PI;
|
||||
int dist = minDist + rng.nextInt(Math.max(1, maxDist - minDist + 1));
|
||||
int x = (int) Math.round(Math.cos(angle) * dist);
|
||||
int z = (int) Math.round(Math.sin(angle) * dist);
|
||||
fakeStrongholds.put(player.getUniqueId(), new long[]{x, z});
|
||||
return new int[]{x, z};
|
||||
}
|
||||
|
||||
public int[] getFakeStronghold(UUID uuid) {
|
||||
long[] packed = fakeStrongholds.get(uuid);
|
||||
if (packed == null) return null;
|
||||
return new int[]{(int) packed[0], (int) packed[1]};
|
||||
}
|
||||
|
||||
public void purgeOfflinePlayers(java.util.Set<UUID> onlineUUIDs) {
|
||||
int removed = 0;
|
||||
for (UUID uuid : playerSeeds.keySet()) {
|
||||
if (!onlineUUIDs.contains(uuid)) {
|
||||
playerSeeds.remove(uuid);
|
||||
fakeStrongholds.remove(uuid);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
logger.info("[AntiSeedCracker] Audit removed " + removed + " stale seed entries.");
|
||||
}
|
||||
}
|
||||
|
||||
public int trackedCount() {
|
||||
return playerSeeds.size();
|
||||
}
|
||||
|
||||
private long generateSeed() {
|
||||
return rng.nextLong();
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package me.legendcraft.antiseedcracker.seed;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class WorldSeedInterceptor {
|
||||
|
||||
private Method getHandleMethod;
|
||||
private List<Field> containerPath = Collections.emptyList();
|
||||
private Field seedField;
|
||||
private final boolean available;
|
||||
private final Logger logger;
|
||||
|
||||
private static final int MAX_DEPTH = 7;
|
||||
private static final int MAX_VISITS = 10_000;
|
||||
private static final String[] TRAVERSE_PREFIXES = { "net.minecraft.", "com.mojang." };
|
||||
private static final long SENTINEL = 0xDEAD_C0DE_1337_4242L;
|
||||
|
||||
public WorldSeedInterceptor(Logger logger) {
|
||||
this.logger = logger;
|
||||
boolean ok = false;
|
||||
try {
|
||||
ok = init();
|
||||
} catch (Exception e) {
|
||||
logger.warning("[AntiSeedCracker] WorldSeedInterceptor init error: " + e);
|
||||
}
|
||||
this.available = ok;
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return available;
|
||||
}
|
||||
|
||||
public void patchWorld(World world, long fakeSeed) {
|
||||
if (!available) return;
|
||||
try {
|
||||
Object container = getHandleMethod.invoke(world);
|
||||
for (Field f : containerPath) {
|
||||
container = f.get(container);
|
||||
if (container == null) return;
|
||||
}
|
||||
seedField.setLong(container, fakeSeed);
|
||||
} catch (Exception e) {
|
||||
logger.log(Level.FINE, "[AntiSeedCracker] WorldSeedInterceptor.patchWorld failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean init() throws Exception {
|
||||
Class<?> craftWorldClass = resolveCraftWorldClass();
|
||||
getHandleMethod = craftWorldClass.getDeclaredMethod("getHandle");
|
||||
getHandleMethod.setAccessible(true);
|
||||
|
||||
List<World> worlds = Bukkit.getWorlds();
|
||||
if (worlds.isEmpty()) {
|
||||
logger.warning("[AntiSeedCracker] WorldSeedInterceptor: no worlds loaded yet — disabling.");
|
||||
return false;
|
||||
}
|
||||
World refWorld = worlds.get(0);
|
||||
Object nmsLevel = getHandleMethod.invoke(refWorld);
|
||||
|
||||
List<SeedPath> candidates = new ArrayList<>();
|
||||
collectDirectCandidates(nmsLevel, candidates);
|
||||
bfsFindAll(nmsLevel, candidates);
|
||||
|
||||
logger.info("[AntiSeedCracker] WorldSeedInterceptor: found "
|
||||
+ candidates.size() + " long 'seed' candidate(s) — verifying each...");
|
||||
|
||||
for (SeedPath path : candidates) {
|
||||
long orig = Long.MIN_VALUE;
|
||||
boolean origRead = false;
|
||||
try {
|
||||
path.seedField().setAccessible(true);
|
||||
orig = path.seedField().getLong(path.container());
|
||||
origRead = true;
|
||||
|
||||
path.seedField().setLong(path.container(), SENTINEL);
|
||||
boolean verified;
|
||||
try {
|
||||
verified = (refWorld.getSeed() == SENTINEL);
|
||||
} finally {
|
||||
path.seedField().setLong(path.container(), orig);
|
||||
}
|
||||
|
||||
if (verified) {
|
||||
containerPath = path.containerPath();
|
||||
seedField = path.seedField();
|
||||
for (Field f : containerPath) f.setAccessible(true);
|
||||
|
||||
logger.info("[AntiSeedCracker] WorldSeedInterceptor: ✔ active — "
|
||||
+ "world.getSeed() intercepted. Class: "
|
||||
+ path.seedField().getDeclaringClass().getSimpleName()
|
||||
+ ", field: " + path.seedField().getName()
|
||||
+ ", depth: " + containerPath.size());
|
||||
return true;
|
||||
} else {
|
||||
logger.fine("[AntiSeedCracker] WorldSeedInterceptor: candidate "
|
||||
+ path.seedField().getDeclaringClass().getSimpleName() + "."
|
||||
+ path.seedField().getName() + " did not affect getSeed() — skipping.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (origRead) {
|
||||
try { path.seedField().setLong(path.container(), orig); } catch (Exception ignored) {}
|
||||
}
|
||||
logger.fine("[AntiSeedCracker] WorldSeedInterceptor: candidate threw "
|
||||
+ e.getClass().getSimpleName() + " — skipping.");
|
||||
}
|
||||
}
|
||||
|
||||
logger.warning("[AntiSeedCracker] WorldSeedInterceptor: none of the "
|
||||
+ candidates.size() + " candidate(s) controlled world.getSeed(). "
|
||||
+ "Plugin-API seed protection is disabled; packet-level protection remains active.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void collectDirectCandidates(Object obj, List<SeedPath> out) {
|
||||
for (Field f : allDeclaredFields(obj.getClass())) {
|
||||
if (f.getType() == long.class && "seed".equalsIgnoreCase(f.getName())) {
|
||||
try {
|
||||
f.setAccessible(true);
|
||||
out.add(new SeedPath(Collections.emptyList(), f, obj));
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void bfsFindAll(Object root, List<SeedPath> out) {
|
||||
record Node(Object obj, List<Field> path) {}
|
||||
|
||||
Set<Integer> visited = new HashSet<>();
|
||||
ArrayDeque<Node> queue = new ArrayDeque<>();
|
||||
queue.add(new Node(root, Collections.emptyList()));
|
||||
int visits = 0;
|
||||
|
||||
while (!queue.isEmpty() && visits < MAX_VISITS) {
|
||||
Node node = queue.poll();
|
||||
if (node.obj() == null) continue;
|
||||
int id = System.identityHashCode(node.obj());
|
||||
if (!visited.add(id)) continue;
|
||||
visits++;
|
||||
|
||||
for (Field f : allDeclaredFields(node.obj().getClass())) {
|
||||
try {
|
||||
f.setAccessible(true);
|
||||
|
||||
if (f.getType() == long.class && "seed".equalsIgnoreCase(f.getName())) {
|
||||
out.add(new SeedPath(new ArrayList<>(node.path()), f, node.obj()));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.path().size() < MAX_DEPTH
|
||||
&& !f.getType().isPrimitive()
|
||||
&& !f.getType().isArray()
|
||||
&& !f.getType().isEnum()
|
||||
&& isTraversable(f.getType())) {
|
||||
Object nested = f.get(node.obj());
|
||||
if (nested != null && nested != node.obj()) {
|
||||
List<Field> np = new ArrayList<>(node.path());
|
||||
np.add(f);
|
||||
queue.add(new Node(nested, np));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTraversable(Class<?> cls) {
|
||||
String name = cls.getName();
|
||||
for (String prefix : TRAVERSE_PREFIXES) {
|
||||
if (name.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Field> allDeclaredFields(Class<?> clazz) {
|
||||
List<Field> result = new ArrayList<>();
|
||||
Class<?> c = clazz;
|
||||
while (c != null && c != Object.class) {
|
||||
for (Field f : c.getDeclaredFields()) result.add(f);
|
||||
c = c.getSuperclass();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Class<?> resolveCraftWorldClass() throws ClassNotFoundException {
|
||||
try {
|
||||
return Class.forName("org.bukkit.craftbukkit.CraftWorld");
|
||||
} catch (ClassNotFoundException e) {
|
||||
String ver = detectVersion();
|
||||
return Class.forName("org.bukkit.craftbukkit." + ver + ".CraftWorld");
|
||||
}
|
||||
}
|
||||
|
||||
private static String detectVersion() {
|
||||
try {
|
||||
String[] parts = Bukkit.getServer().getClass().getName().split("\\.");
|
||||
if (parts.length >= 5) return parts[3];
|
||||
} catch (Exception ignored) { }
|
||||
return "v1_21_R3";
|
||||
}
|
||||
|
||||
private record SeedPath(List<Field> containerPath, Field seedField, Object container) { }
|
||||
}
|
||||
+86
-31
@@ -1,25 +1,29 @@
|
||||
package me.legendcraft.antiseedcracker;
|
||||
package me.sunsetrqsv.antiseedcracker;
|
||||
|
||||
import com.github.retrooper.packetevents.PacketEvents;
|
||||
import io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder;
|
||||
import me.legendcraft.antiseedcracker.commands.AdminCommand;
|
||||
import me.legendcraft.antiseedcracker.config.PluginConfig;
|
||||
import me.legendcraft.antiseedcracker.database.DatabaseManager;
|
||||
import me.legendcraft.antiseedcracker.listeners.CommandProtectionListener;
|
||||
import me.legendcraft.antiseedcracker.listeners.EndCityProtector;
|
||||
import me.legendcraft.antiseedcracker.listeners.EndSpikeProtector;
|
||||
import me.legendcraft.antiseedcracker.listeners.EyeOfEnderProtector;
|
||||
import me.legendcraft.antiseedcracker.listeners.PlayerSessionListener;
|
||||
import me.legendcraft.antiseedcracker.packets.LoginPacketInterceptor;
|
||||
import me.legendcraft.antiseedcracker.packets.MapSeedInterceptor;
|
||||
import me.legendcraft.antiseedcracker.packets.RespawnPacketInterceptor;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import me.legendcraft.antiseedcracker.seed.SeedManager;
|
||||
import me.legendcraft.antiseedcracker.seed.WorldSeedInterceptor;
|
||||
import me.legendcraft.antiseedcracker.tasks.SeedBroadcastTask;
|
||||
import me.legendcraft.antiseedcracker.tasks.SeedRotationTask;
|
||||
import me.legendcraft.antiseedcracker.util.PlatformUtil;
|
||||
import me.legendcraft.antiseedcracker.util.UpdateChecker;
|
||||
import me.sunsetrqsv.antiseedcracker.commands.AdminCommand;
|
||||
import me.sunsetrqsv.antiseedcracker.config.PluginConfig;
|
||||
import me.sunsetrqsv.antiseedcracker.database.DatabaseManager;
|
||||
import me.sunsetrqsv.antiseedcracker.listeners.CommandProtectionListener;
|
||||
import me.sunsetrqsv.antiseedcracker.listeners.EndCityProtector;
|
||||
import me.sunsetrqsv.antiseedcracker.listeners.EndSpikeProtector;
|
||||
import me.sunsetrqsv.antiseedcracker.listeners.EyeOfEnderProtector;
|
||||
import me.sunsetrqsv.antiseedcracker.listeners.PlayerSessionListener;
|
||||
import me.sunsetrqsv.antiseedcracker.listeners.SlimeChunkObfuscator;
|
||||
import me.sunsetrqsv.antiseedcracker.listeners.StructureReconMonitor;
|
||||
import me.sunsetrqsv.antiseedcracker.packets.LoginPacketInterceptor;
|
||||
import me.sunsetrqsv.antiseedcracker.packets.MapSeedInterceptor;
|
||||
import me.sunsetrqsv.antiseedcracker.packets.RespawnPacketInterceptor;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import me.sunsetrqsv.antiseedcracker.seed.SeedIntegrityMonitor;
|
||||
import me.sunsetrqsv.antiseedcracker.seed.SeedManager;
|
||||
import me.sunsetrqsv.antiseedcracker.tasks.SeedBroadcastTask;
|
||||
import me.sunsetrqsv.antiseedcracker.tasks.SeedRotationTask;
|
||||
import me.sunsetrqsv.antiseedcracker.util.PlatformUtil;
|
||||
import me.sunsetrqsv.antiseedcracker.util.UpdateChecker;
|
||||
import org.bstats.bukkit.Metrics;
|
||||
import org.bstats.charts.SimplePie;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
@@ -28,21 +32,25 @@ import java.util.Objects;
|
||||
|
||||
public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
|
||||
private PluginConfig pluginConfig;
|
||||
private static final int BSTATS_PLUGIN_ID = 32378;
|
||||
|
||||
private volatile PluginConfig pluginConfig;
|
||||
private volatile DatabaseManager databaseManager;
|
||||
private SeedManager seedManager;
|
||||
private DatabaseManager databaseManager;
|
||||
private UpdateChecker updateChecker;
|
||||
|
||||
private SeedBroadcastTask seedBroadcastTask;
|
||||
private SeedRotationTask seedRotationTask;
|
||||
|
||||
private WorldSeedInterceptor worldSeedInterceptor;
|
||||
private SeedIntegrityMonitor seedIntegrityMonitor;
|
||||
|
||||
private PlayerSessionListener playerSessionListener;
|
||||
private CommandProtectionListener commandProtectionListener;
|
||||
private EyeOfEnderProtector eyeOfEnderProtector;
|
||||
private EndSpikeProtector endSpikeProtector;
|
||||
private EndCityProtector endCityProtector;
|
||||
private SlimeChunkObfuscator slimeChunkObfuscator;
|
||||
private StructureReconMonitor structureReconMonitor;
|
||||
|
||||
@Override
|
||||
public void onLoad() {
|
||||
@@ -60,7 +68,7 @@ public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
saveDefaultConfig();
|
||||
pluginConfig = new PluginConfig(getConfig());
|
||||
|
||||
seedManager = new SeedManager(getLogger());
|
||||
seedManager = new SeedManager();
|
||||
|
||||
if (pluginConfig.isDatabaseEnabled()) {
|
||||
try {
|
||||
@@ -72,7 +80,7 @@ public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
getLogger().warning(
|
||||
"[AntiSeedCracker] Audit log init failed ("
|
||||
+ e.getMessage()
|
||||
+ ") — event logging disabled.");
|
||||
+ ") - event logging disabled.");
|
||||
databaseManager = null;
|
||||
}
|
||||
}
|
||||
@@ -82,7 +90,7 @@ public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
updateChecker.checkAsync();
|
||||
}
|
||||
|
||||
worldSeedInterceptor = new WorldSeedInterceptor(getLogger());
|
||||
seedIntegrityMonitor = new SeedIntegrityMonitor(getLogger());
|
||||
|
||||
seedBroadcastTask = new SeedBroadcastTask(this);
|
||||
seedBroadcastTask.start();
|
||||
@@ -107,7 +115,24 @@ public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
playerSessionListener.initPlayer(player);
|
||||
}
|
||||
|
||||
getLogger().info("[AntiSeedCracker] Enabled — real seed is protected.");
|
||||
setupMetrics();
|
||||
|
||||
getLogger().info("[AntiSeedCracker] Enabled - real seed is protected.");
|
||||
}
|
||||
|
||||
private void setupMetrics() {
|
||||
Metrics metrics = new Metrics(this, BSTATS_PLUGIN_ID);
|
||||
metrics.addCustomChart(new SimplePie("platform_type", PlatformUtil::name));
|
||||
metrics.addCustomChart(new SimplePie("seed_integrity_monitor_enabled",
|
||||
() -> pluginConfig.isSeedIntegrityMonitorEnabled() ? "enabled" : "disabled"));
|
||||
metrics.addCustomChart(new SimplePie("seed_rotation_enabled",
|
||||
() -> pluginConfig.isSeedRotationEnabled() ? "enabled" : "disabled"));
|
||||
metrics.addCustomChart(new SimplePie("audit_log_enabled",
|
||||
() -> databaseManager != null ? "enabled" : "disabled"));
|
||||
metrics.addCustomChart(new SimplePie("slime_chunk_obfuscation_enabled",
|
||||
() -> pluginConfig.isSlimeChunkObfuscationEnabled() ? "enabled" : "disabled"));
|
||||
metrics.addCustomChart(new SimplePie("structure_recon_monitor_enabled",
|
||||
() -> pluginConfig.isStructureReconMonitorEnabled() ? "enabled" : "disabled"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -129,6 +154,22 @@ public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
reloadConfig();
|
||||
pluginConfig = new PluginConfig(getConfig());
|
||||
|
||||
if (pluginConfig.isDatabaseEnabled() && databaseManager == null) {
|
||||
try {
|
||||
databaseManager = new DatabaseManager(getDataFolder(), getLogger());
|
||||
databaseManager.init();
|
||||
final int maxDays = pluginConfig.getDatabaseMaxEventAgeDays();
|
||||
FoliaSchedulerUtil.runAsync(this, () -> databaseManager.pruneOldEvents(maxDays));
|
||||
} catch (Exception e) {
|
||||
getLogger().warning("[AntiSeedCracker] Audit log init failed on reload: "
|
||||
+ e.getMessage());
|
||||
databaseManager = null;
|
||||
}
|
||||
} else if (!pluginConfig.isDatabaseEnabled() && databaseManager != null) {
|
||||
databaseManager.close();
|
||||
databaseManager = null;
|
||||
}
|
||||
|
||||
registerListeners();
|
||||
|
||||
if (pluginConfig.isSeedRotationEnabled()) seedRotationTask.start();
|
||||
@@ -164,6 +205,16 @@ public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
endCityProtector = new EndCityProtector(this);
|
||||
pm.registerEvents(endCityProtector, this);
|
||||
}
|
||||
|
||||
if (pluginConfig.isSlimeChunkObfuscationEnabled()) {
|
||||
slimeChunkObfuscator = new SlimeChunkObfuscator(this);
|
||||
pm.registerEvents(slimeChunkObfuscator, this);
|
||||
}
|
||||
|
||||
if (pluginConfig.isStructureReconMonitorEnabled()) {
|
||||
structureReconMonitor = new StructureReconMonitor(this);
|
||||
pm.registerEvents(structureReconMonitor, this);
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterListeners() {
|
||||
@@ -172,11 +223,15 @@ public final class AntiSeedCrackerPlugin extends JavaPlugin {
|
||||
if (eyeOfEnderProtector != null) { eyeOfEnderProtector.unregister(); eyeOfEnderProtector = null; }
|
||||
if (endSpikeProtector != null) { endSpikeProtector.unregister(); endSpikeProtector = null; }
|
||||
if (endCityProtector != null) { endCityProtector.unregister(); endCityProtector = null; }
|
||||
if (slimeChunkObfuscator != null) { slimeChunkObfuscator.unregister(); slimeChunkObfuscator = null; }
|
||||
if (structureReconMonitor != null) { structureReconMonitor.unregister(); structureReconMonitor = null; }
|
||||
}
|
||||
|
||||
public PluginConfig getPluginConfig() { return pluginConfig; }
|
||||
public SeedManager getSeedManager() { return seedManager; }
|
||||
public WorldSeedInterceptor getWorldSeedInterceptor() { return worldSeedInterceptor; }
|
||||
public DatabaseManager getDatabaseManager() { return databaseManager; }
|
||||
public UpdateChecker getUpdateChecker() { return updateChecker; }
|
||||
public PluginConfig getPluginConfig() { return pluginConfig; }
|
||||
public SeedManager getSeedManager() { return seedManager; }
|
||||
public SeedIntegrityMonitor getSeedIntegrityMonitor() { return seedIntegrityMonitor; }
|
||||
public StructureReconMonitor getStructureReconMonitor() { return structureReconMonitor; }
|
||||
public CommandProtectionListener getCommandProtectionListener() { return commandProtectionListener; }
|
||||
public DatabaseManager getDatabaseManager() { return databaseManager; }
|
||||
public UpdateChecker getUpdateChecker() { return updateChecker; }
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package me.sunsetrqsv.antiseedcracker.commands;
|
||||
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class AdminCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
/** Read-only subcommands (status/stats/info). Granted to ops by default. */
|
||||
private static final String PERM_STATUS = "antiseedcracker.admin.status";
|
||||
/** State-changing subcommands (reload/toggle). Granted to ops by default. */
|
||||
private static final String PERM_MANAGE = "antiseedcracker.admin.manage";
|
||||
|
||||
private static final Component PREFIX =
|
||||
Component.text("[", NamedTextColor.DARK_GRAY)
|
||||
.append(Component.text("ASC", NamedTextColor.AQUA))
|
||||
.append(Component.text("] ", NamedTextColor.DARK_GRAY));
|
||||
|
||||
private static final List<String> STATUS_SUBCOMMANDS = Arrays.asList("status", "stats", "info", "help");
|
||||
private static final List<String> MANAGE_SUBCOMMANDS = Arrays.asList("reload", "toggle");
|
||||
|
||||
/** feature name -> config.yml path, for {@code /asc toggle}. Order defines tab-complete order. */
|
||||
private static final Map<String, String> TOGGLE_PATHS = buildTogglePaths();
|
||||
|
||||
private static Map<String, String> buildTogglePaths() {
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
m.put("login", "seed_obfuscation.intercept_login");
|
||||
m.put("respawn", "seed_obfuscation.intercept_respawn");
|
||||
m.put("rotation", "seed_rotation.enabled");
|
||||
m.put("endspikes", "structure_protection.end_spikes.enabled");
|
||||
m.put("endcities", "structure_protection.end_cities.enabled");
|
||||
m.put("locate", "structure_protection.spoof_locate_command.enabled");
|
||||
m.put("integrity", "seed_integrity_monitor.enabled");
|
||||
m.put("slime", "slime_chunk_obfuscation.enabled");
|
||||
m.put("recon", "structure_recon_monitor.enabled");
|
||||
m.put("treasuremap", "treasure_map_protection.enabled");
|
||||
m.put("database", "database.enabled");
|
||||
m.put("probing", "command_probing_monitor.enabled");
|
||||
return Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
|
||||
public AdminCommand(AntiSeedCrackerPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (args.length == 0) {
|
||||
handleHelp(sender);
|
||||
return true;
|
||||
}
|
||||
String sub = args[0].toLowerCase();
|
||||
switch (sub) {
|
||||
case "reload" -> runIfPermitted(sender, PERM_MANAGE, () -> handleReload(sender));
|
||||
case "toggle" -> runIfPermitted(sender, PERM_MANAGE, () -> handleToggle(sender, args));
|
||||
case "status" -> runIfPermitted(sender, PERM_STATUS, () -> handleStatus(sender));
|
||||
case "stats" -> runIfPermitted(sender, PERM_STATUS, () -> handleStats(sender));
|
||||
case "info" -> runIfPermitted(sender, PERM_STATUS, () -> handleInfo(sender));
|
||||
case "help" -> runIfPermitted(sender, PERM_STATUS, () -> handleHelp(sender));
|
||||
default -> handleHelp(sender);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void runIfPermitted(CommandSender sender, String permission, Runnable action) {
|
||||
if (!sender.hasPermission(permission)) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"You do not have permission to use this command.", NamedTextColor.RED)));
|
||||
return;
|
||||
}
|
||||
action.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (args.length == 1) {
|
||||
String partial = args[0].toLowerCase();
|
||||
List<String> options = new ArrayList<>();
|
||||
if (sender.hasPermission(PERM_STATUS)) options.addAll(STATUS_SUBCOMMANDS);
|
||||
if (sender.hasPermission(PERM_MANAGE)) options.addAll(MANAGE_SUBCOMMANDS);
|
||||
return options.stream().filter(s -> s.startsWith(partial)).collect(Collectors.toList());
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("toggle") && sender.hasPermission(PERM_MANAGE)) {
|
||||
String partial = args[1].toLowerCase();
|
||||
return TOGGLE_PATHS.keySet().stream()
|
||||
.filter(s -> s.startsWith(partial))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (args.length == 3 && args[0].equalsIgnoreCase("toggle") && sender.hasPermission(PERM_MANAGE)) {
|
||||
String partial = args[2].toLowerCase();
|
||||
return Arrays.asList("true", "false").stream()
|
||||
.filter(s -> s.startsWith(partial))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private void handleReload(CommandSender sender) {
|
||||
sender.sendMessage(msg(Component.text("Reloading AntiSeedCracker…", NamedTextColor.YELLOW)));
|
||||
try {
|
||||
plugin.reload();
|
||||
sender.sendMessage(msg(Component.text("Reload successful.", NamedTextColor.GREEN)));
|
||||
} catch (Exception e) {
|
||||
sender.sendMessage(msg(Component.text("Reload failed: " + e.getMessage(), NamedTextColor.RED)));
|
||||
plugin.getLogger().severe("[AntiSeedCracker] Reload exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleToggle(CommandSender sender, String[] args) {
|
||||
if (args.length < 2) {
|
||||
sender.sendMessage(msg(Component.text("Usage: ", NamedTextColor.YELLOW)
|
||||
.append(Component.text("/asc toggle <feature> [true|false]", NamedTextColor.WHITE))));
|
||||
sender.sendMessage(msg(Component.text("Features: ", NamedTextColor.GRAY)
|
||||
.append(Component.text(String.join(", ", TOGGLE_PATHS.keySet()), NamedTextColor.WHITE))));
|
||||
return;
|
||||
}
|
||||
|
||||
String feature = args[1].toLowerCase();
|
||||
String path = TOGGLE_PATHS.get(feature);
|
||||
if (path == null) {
|
||||
sender.sendMessage(msg(Component.text("Unknown feature '" + feature + "'. Valid: ", NamedTextColor.RED)
|
||||
.append(Component.text(String.join(", ", TOGGLE_PATHS.keySet()), NamedTextColor.WHITE))));
|
||||
return;
|
||||
}
|
||||
|
||||
boolean newValue;
|
||||
if (args.length >= 3) {
|
||||
if (!args[2].equalsIgnoreCase("true") && !args[2].equalsIgnoreCase("false")) {
|
||||
sender.sendMessage(msg(Component.text("Value must be 'true' or 'false'.", NamedTextColor.RED)));
|
||||
return;
|
||||
}
|
||||
newValue = Boolean.parseBoolean(args[2]);
|
||||
} else {
|
||||
newValue = !plugin.getConfig().getBoolean(path, true);
|
||||
}
|
||||
|
||||
plugin.getConfig().set(path, newValue);
|
||||
plugin.saveConfig();
|
||||
try {
|
||||
plugin.reload();
|
||||
sender.sendMessage(msg(Component.text(feature + " ", NamedTextColor.AQUA)
|
||||
.append(Component.text("set to ", NamedTextColor.GRAY))
|
||||
.append(Component.text(String.valueOf(newValue),
|
||||
newValue ? NamedTextColor.GREEN : NamedTextColor.RED))));
|
||||
} catch (Exception e) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Config updated but reload failed: " + e.getMessage(), NamedTextColor.RED)));
|
||||
plugin.getLogger().severe("[AntiSeedCracker] Reload exception after toggle: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleStatus(CommandSender sender) {
|
||||
boolean loginIntercepted = plugin.getPluginConfig().isInterceptLogin();
|
||||
boolean respawnIntercepted = plugin.getPluginConfig().isInterceptRespawn();
|
||||
boolean rotationEnabled = plugin.getPluginConfig().isSeedRotationEnabled();
|
||||
boolean spikesEnabled = plugin.getPluginConfig().isEndSpikesEnabled();
|
||||
boolean citiesEnabled = plugin.getPluginConfig().isEndCitiesEnabled();
|
||||
boolean eyeEnabled = plugin.getPluginConfig().isEyeOfEnderEnabled();
|
||||
boolean locateEnabled = plugin.getPluginConfig().isSpoofLocateEnabled();
|
||||
boolean integrityEnabled = plugin.getPluginConfig().isSeedIntegrityMonitorEnabled();
|
||||
boolean slimeObfEnabled = plugin.getPluginConfig().isSlimeChunkObfuscationEnabled();
|
||||
boolean reconEnabled = plugin.getPluginConfig().isStructureReconMonitorEnabled();
|
||||
boolean probingEnabled = plugin.getPluginConfig().isCommandProbingMonitorEnabled();
|
||||
boolean mapProtEnabled = plugin.getPluginConfig().isTreasureMapProtectionEnabled();
|
||||
boolean dbEnabled = plugin.getDatabaseManager() != null;
|
||||
int trackedSeeds = plugin.getSeedManager().trackedCount();
|
||||
|
||||
sender.sendMessage(Component.empty());
|
||||
sender.sendMessage(msg(Component.text("━━ AntiSeedCracker v"
|
||||
+ plugin.getPluginMeta().getVersion() + " by SunsetRQ7_ ━━",
|
||||
NamedTextColor.GOLD)));
|
||||
sender.sendMessage(statusLine("Login Packet Intercept", loginIntercepted));
|
||||
sender.sendMessage(statusLine("Respawn Packet Intercept", respawnIntercepted));
|
||||
sender.sendMessage(statusLine("Seed Rotation", rotationEnabled));
|
||||
sender.sendMessage(statusLine("End Spike Protection", spikesEnabled));
|
||||
sender.sendMessage(statusLine("End City Protection", citiesEnabled));
|
||||
sender.sendMessage(statusLine("Eye of Ender Redirect", eyeEnabled));
|
||||
sender.sendMessage(statusLine("/locate Spoof", locateEnabled));
|
||||
sender.sendMessage(statusLine("Seed Integrity Monitor", integrityEnabled));
|
||||
sender.sendMessage(statusLine("Slime Chunk Obfuscation", slimeObfEnabled));
|
||||
sender.sendMessage(statusLine("Structure Recon Monitor", reconEnabled));
|
||||
sender.sendMessage(statusLine("Command Probing Monitor", probingEnabled));
|
||||
sender.sendMessage(statusLine("Treasure Map Scramble", mapProtEnabled));
|
||||
sender.sendMessage(statusLine("Database / Audit Log", dbEnabled));
|
||||
sender.sendMessage(msg(
|
||||
Component.text("Tracked players: ", NamedTextColor.AQUA)
|
||||
.append(Component.text(String.valueOf(trackedSeeds), NamedTextColor.WHITE))));
|
||||
sender.sendMessage(Component.empty());
|
||||
}
|
||||
|
||||
private void handleStats(CommandSender sender) {
|
||||
me.sunsetrqsv.antiseedcracker.database.DatabaseManager db =
|
||||
plugin.getDatabaseManager();
|
||||
if (db == null) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Database is disabled or failed to initialise.", NamedTextColor.RED)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender instanceof org.bukkit.entity.Player) {
|
||||
// Real players: hop off-thread so reading the log files never taxes a tick.
|
||||
sender.sendMessage(msg(Component.text("Loading stats...", NamedTextColor.YELLOW)));
|
||||
FoliaSchedulerUtil.runAsync(plugin, () -> {
|
||||
java.util.Map<String, Long> stats = db.getStats();
|
||||
FoliaSchedulerUtil.runGlobal(plugin, () -> sendStats(sender, stats));
|
||||
});
|
||||
} else {
|
||||
// Console/RCON commands are dispatched and captured synchronously by the
|
||||
// server; deferring the reply to a later tick here would let it arrive after
|
||||
// the RCON round-trip already closed and bleed into whatever command runs
|
||||
// next over the same connection instead of this one's response.
|
||||
sendStats(sender, db.getStats());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendStats(CommandSender sender, java.util.Map<String, Long> stats) {
|
||||
sender.sendMessage(Component.empty());
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"━━ Protection Statistics ━━", NamedTextColor.GOLD)));
|
||||
if (stats.isEmpty()) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"No events recorded yet.", NamedTextColor.GRAY)));
|
||||
} else {
|
||||
for (java.util.Map.Entry<String, Long> e : stats.entrySet()) {
|
||||
String label = e.getKey().replace('_', ' ').toLowerCase();
|
||||
label = Character.toUpperCase(label.charAt(0)) + label.substring(1);
|
||||
sender.sendMessage(
|
||||
Component.text(" ", NamedTextColor.DARK_GRAY)
|
||||
.append(Component.text(label + ": ", NamedTextColor.GRAY))
|
||||
.append(Component.text(String.valueOf(e.getValue()),
|
||||
NamedTextColor.WHITE)));
|
||||
}
|
||||
}
|
||||
sender.sendMessage(Component.empty());
|
||||
}
|
||||
|
||||
private void handleInfo(CommandSender sender) {
|
||||
me.sunsetrqsv.antiseedcracker.util.UpdateChecker uc = plugin.getUpdateChecker();
|
||||
sender.sendMessage(Component.empty());
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"━━ AntiSeedCracker Info ━━", NamedTextColor.GOLD)));
|
||||
sender.sendMessage(msg(Component.text("Version: ", NamedTextColor.GRAY)
|
||||
.append(Component.text(plugin.getPluginMeta().getVersion(),
|
||||
NamedTextColor.WHITE))));
|
||||
sender.sendMessage(msg(Component.text("Author: ", NamedTextColor.GRAY)
|
||||
.append(Component.text("SunsetRQ7_", NamedTextColor.AQUA))));
|
||||
sender.sendMessage(msg(Component.text("Platform: ", NamedTextColor.GRAY)
|
||||
.append(Component.text(
|
||||
me.sunsetrqsv.antiseedcracker.util.PlatformUtil.name(),
|
||||
NamedTextColor.WHITE))));
|
||||
if (uc != null) {
|
||||
if (uc.isUpToDate()) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Update: ✔ Up to date", NamedTextColor.GREEN)));
|
||||
} else if (uc.getLatestVersion() != null) {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Update: ✘ v" + uc.getLatestVersion()
|
||||
+ " available on Modrinth!", NamedTextColor.YELLOW)));
|
||||
} else {
|
||||
sender.sendMessage(msg(Component.text(
|
||||
"Update: not yet checked", NamedTextColor.GRAY)));
|
||||
}
|
||||
}
|
||||
sender.sendMessage(Component.empty());
|
||||
}
|
||||
|
||||
private void handleHelp(CommandSender sender) {
|
||||
sender.sendMessage(Component.empty());
|
||||
sender.sendMessage(msg(Component.text("━━ AntiSeedCracker Commands ━━", NamedTextColor.GOLD)));
|
||||
if (sender.hasPermission(PERM_STATUS)) {
|
||||
sender.sendMessage(helpLine("/asc status", "Show which protection layers are active"));
|
||||
sender.sendMessage(helpLine("/asc stats", "Show audit log event counts"));
|
||||
sender.sendMessage(helpLine("/asc info", "Show version, platform, and update status"));
|
||||
}
|
||||
if (sender.hasPermission(PERM_MANAGE)) {
|
||||
sender.sendMessage(helpLine("/asc reload", "Reload config and restart all protection tasks"));
|
||||
sender.sendMessage(helpLine("/asc toggle <feature> [true|false]",
|
||||
"Flip (or set) one feature live, persisted to config.yml"));
|
||||
}
|
||||
sender.sendMessage(Component.empty());
|
||||
}
|
||||
|
||||
private static Component helpLine(String usage, String description) {
|
||||
return Component.text(" " + usage + " ", NamedTextColor.WHITE)
|
||||
.append(Component.text("— " + description, NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private static Component msg(Component body) {
|
||||
return PREFIX.append(body);
|
||||
}
|
||||
|
||||
private static Component statusLine(String label, boolean enabled) {
|
||||
Component status = enabled
|
||||
? Component.text("✔ enabled", NamedTextColor.GREEN)
|
||||
: Component.text("✘ disabled", NamedTextColor.RED);
|
||||
return Component.text(" ")
|
||||
.append(Component.text(label, NamedTextColor.GRAY))
|
||||
.append(Component.text(": ", NamedTextColor.DARK_GRAY))
|
||||
.append(status);
|
||||
}
|
||||
}
|
||||
+86
-10
@@ -1,17 +1,31 @@
|
||||
package me.legendcraft.antiseedcracker.config;
|
||||
package me.sunsetrqsv.antiseedcracker.config;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class PluginConfig {
|
||||
|
||||
/**
|
||||
* Default structures the recon monitor watches for. Deliberately excludes common
|
||||
* structures (villages, mineshafts, ruined portals) that ordinary exploration
|
||||
* triggers constantly — those would drown the signal in false positives. Matches
|
||||
* the exact structure list SeedCrackerX is documented to use for bit-accumulation:
|
||||
* shipwrecks, desert/jungle temples, swamp huts, pillager outposts, ocean
|
||||
* monuments, igloos, buried treasure, End Cities, and ancient cities.
|
||||
*/
|
||||
private static final List<String> DEFAULT_TRACKED_STRUCTURES = Collections.unmodifiableList(Arrays.asList(
|
||||
"shipwreck", "shipwreck_beached", "desert_pyramid", "jungle_pyramid",
|
||||
"swamp_hut", "pillager_outpost", "monument", "igloo", "buried_treasure",
|
||||
"end_city", "ancient_city"));
|
||||
|
||||
private final boolean interceptLogin;
|
||||
private final boolean interceptRespawn;
|
||||
|
||||
private final boolean seedRotationEnabled;
|
||||
private final long seedRotationIntervalTicks;
|
||||
private final long seedRotationIntervalSeconds;
|
||||
|
||||
private final boolean endSpikesEnabled;
|
||||
private final List<String> endSpikeWorlds;
|
||||
@@ -24,7 +38,15 @@ public final class PluginConfig {
|
||||
private final boolean spoofLocateEnabled;
|
||||
private final int spoofLocateMaxOffset;
|
||||
|
||||
private final boolean pluginApiProtectionEnabled;
|
||||
private final boolean seedIntegrityMonitorEnabled;
|
||||
|
||||
private final boolean slimeChunkObfuscationEnabled;
|
||||
private final List<String> slimeChunkObfuscationWorlds;
|
||||
|
||||
private final boolean structureReconMonitorEnabled;
|
||||
private final int structureReconThreshold;
|
||||
private final int structureReconWindowSeconds;
|
||||
private final List<String> structureReconTrackedStructures;
|
||||
|
||||
private final boolean eyeOfEnderEnabled;
|
||||
private final List<String> eyeOfEnderWorlds;
|
||||
@@ -42,13 +64,20 @@ public final class PluginConfig {
|
||||
|
||||
private final List<String> extraBlockedCommands;
|
||||
|
||||
private final boolean commandProbingMonitorEnabled;
|
||||
private final int commandProbingThreshold;
|
||||
private final int commandProbingWindowSeconds;
|
||||
|
||||
private final String messageSeedBlockedPlayer;
|
||||
private final String messageSeedBlockedConsole;
|
||||
|
||||
public PluginConfig(FileConfiguration cfg) {
|
||||
this.interceptLogin = cfg.getBoolean("seed_obfuscation.intercept_login", true);
|
||||
this.interceptRespawn = cfg.getBoolean("seed_obfuscation.intercept_respawn", true);
|
||||
|
||||
this.seedRotationEnabled = cfg.getBoolean("seed_rotation.enabled", true);
|
||||
long rawSecs = Math.max(30L, cfg.getLong("seed_rotation.interval_seconds", 60L));
|
||||
this.seedRotationIntervalTicks = rawSecs * 20L;
|
||||
this.seedRotationIntervalSeconds = Math.max(30L,
|
||||
cfg.getLong("seed_rotation.interval_seconds", 60L));
|
||||
|
||||
this.endSpikesEnabled = cfg.getBoolean("structure_protection.end_spikes.enabled", true);
|
||||
this.endSpikeWorlds = Collections.unmodifiableList(
|
||||
@@ -64,7 +93,20 @@ public final class PluginConfig {
|
||||
this.spoofLocateMaxOffset = Math.max(100,
|
||||
cfg.getInt("structure_protection.spoof_locate_command.max_offset", 2000));
|
||||
|
||||
this.pluginApiProtectionEnabled = cfg.getBoolean("plugin_api_protection.enabled", true);
|
||||
this.seedIntegrityMonitorEnabled = cfg.getBoolean("seed_integrity_monitor.enabled", true);
|
||||
|
||||
this.slimeChunkObfuscationEnabled = cfg.getBoolean("slime_chunk_obfuscation.enabled", false);
|
||||
this.slimeChunkObfuscationWorlds = Collections.unmodifiableList(
|
||||
cfg.getStringList("slime_chunk_obfuscation.worlds"));
|
||||
|
||||
this.structureReconMonitorEnabled = cfg.getBoolean("structure_recon_monitor.enabled", true);
|
||||
this.structureReconThreshold = Math.max(2,
|
||||
cfg.getInt("structure_recon_monitor.threshold", 6));
|
||||
this.structureReconWindowSeconds = Math.max(30,
|
||||
cfg.getInt("structure_recon_monitor.window_seconds", 900));
|
||||
List<String> configuredStructures = cfg.getStringList("structure_recon_monitor.tracked_structures");
|
||||
this.structureReconTrackedStructures = Collections.unmodifiableList(
|
||||
configuredStructures.isEmpty() ? DEFAULT_TRACKED_STRUCTURES : configuredStructures);
|
||||
|
||||
this.eyeOfEnderEnabled = cfg.getBoolean("eye_of_ender_protection.enabled", true);
|
||||
this.eyeOfEnderWorlds = Collections.unmodifiableList(
|
||||
@@ -87,14 +129,33 @@ public final class PluginConfig {
|
||||
cfg.getBoolean("treasure_map_protection.enabled", true);
|
||||
|
||||
this.extraBlockedCommands = Collections.unmodifiableList(
|
||||
cfg.getStringList("extra_blocked_commands"));
|
||||
cfg.getStringList("extra_blocked_commands").stream()
|
||||
.map(String::toLowerCase)
|
||||
.collect(java.util.stream.Collectors.toList()));
|
||||
|
||||
this.commandProbingMonitorEnabled = cfg.getBoolean("command_probing_monitor.enabled", true);
|
||||
this.commandProbingThreshold = Math.max(2,
|
||||
cfg.getInt("command_probing_monitor.threshold", 5));
|
||||
this.commandProbingWindowSeconds = Math.max(5,
|
||||
cfg.getInt("command_probing_monitor.window_seconds", 30));
|
||||
|
||||
this.messageSeedBlockedPlayer = colorize(cfg.getString(
|
||||
"messages.seed_blocked_player",
|
||||
"&c[AntiSeedCracker] Access to world seed information is restricted."));
|
||||
this.messageSeedBlockedConsole = colorize(cfg.getString(
|
||||
"messages.seed_blocked_console",
|
||||
"&c[AntiSeedCracker] Seed access is restricted. The real seed is never exposed."));
|
||||
}
|
||||
|
||||
private static String colorize(String s) {
|
||||
return s == null ? "" : s.replace('&', '§');
|
||||
}
|
||||
|
||||
public boolean isInterceptLogin() { return interceptLogin; }
|
||||
public boolean isInterceptRespawn() { return interceptRespawn; }
|
||||
|
||||
public boolean isSeedRotationEnabled() { return seedRotationEnabled; }
|
||||
public long getSeedRotationIntervalTicks() { return seedRotationIntervalTicks; }
|
||||
public boolean isSeedRotationEnabled() { return seedRotationEnabled; }
|
||||
public long getSeedRotationIntervalSeconds() { return seedRotationIntervalSeconds; }
|
||||
|
||||
public boolean isEndSpikesEnabled() { return endSpikesEnabled; }
|
||||
public List<String> getEndSpikeWorlds() { return endSpikeWorlds; }
|
||||
@@ -107,7 +168,15 @@ public final class PluginConfig {
|
||||
public boolean isSpoofLocateEnabled() { return spoofLocateEnabled; }
|
||||
public int getSpoofLocateMaxOffset() { return spoofLocateMaxOffset; }
|
||||
|
||||
public boolean isPluginApiProtectionEnabled() { return pluginApiProtectionEnabled; }
|
||||
public boolean isSeedIntegrityMonitorEnabled() { return seedIntegrityMonitorEnabled; }
|
||||
|
||||
public boolean isSlimeChunkObfuscationEnabled() { return slimeChunkObfuscationEnabled; }
|
||||
public List<String> getSlimeChunkObfuscationWorlds() { return slimeChunkObfuscationWorlds; }
|
||||
|
||||
public boolean isStructureReconMonitorEnabled() { return structureReconMonitorEnabled; }
|
||||
public int getStructureReconThreshold() { return structureReconThreshold; }
|
||||
public int getStructureReconWindowSeconds() { return structureReconWindowSeconds; }
|
||||
public List<String> getStructureReconTrackedStructures() { return structureReconTrackedStructures; }
|
||||
|
||||
public boolean isEyeOfEnderEnabled() { return eyeOfEnderEnabled; }
|
||||
public List<String> getEyeOfEnderWorlds() { return eyeOfEnderWorlds; }
|
||||
@@ -124,4 +193,11 @@ public final class PluginConfig {
|
||||
public boolean isTreasureMapProtectionEnabled() { return treasureMapProtectionEnabled; }
|
||||
|
||||
public List<String> getExtraBlockedCommands() { return extraBlockedCommands; }
|
||||
|
||||
public boolean isCommandProbingMonitorEnabled() { return commandProbingMonitorEnabled; }
|
||||
public int getCommandProbingThreshold() { return commandProbingThreshold; }
|
||||
public int getCommandProbingWindowSeconds() { return commandProbingWindowSeconds; }
|
||||
|
||||
public String getMessageSeedBlockedPlayer() { return messageSeedBlockedPlayer; }
|
||||
public String getMessageSeedBlockedConsole() { return messageSeedBlockedConsole; }
|
||||
}
|
||||
+44
-19
@@ -1,4 +1,4 @@
|
||||
package me.legendcraft.antiseedcracker.database;
|
||||
package me.sunsetrqsv.antiseedcracker.database;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -26,6 +26,9 @@ public final class DatabaseManager {
|
||||
private Thread writerThread;
|
||||
private volatile boolean running = false;
|
||||
|
||||
private BufferedWriter currentWriter;
|
||||
private String currentWriterDate;
|
||||
|
||||
public DatabaseManager(File dataFolder, Logger log) {
|
||||
this.logDir = dataFolder.toPath().resolve("logs");
|
||||
this.log = log;
|
||||
@@ -59,7 +62,7 @@ public final class DatabaseManager {
|
||||
boolean offered = writeQueue.offer(
|
||||
() -> doAppend(type, playerUuid, playerName, worldName, detail, ts, date));
|
||||
if (!offered) {
|
||||
log.fine("[ASC-Log] Write queue full — event dropped: " + type);
|
||||
log.fine("[ASC-Log] Write queue full - event dropped: " + type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,18 +119,22 @@ public final class DatabaseManager {
|
||||
private void startWriterThread() {
|
||||
running = true;
|
||||
writerThread = new Thread(() -> {
|
||||
while (running || !writeQueue.isEmpty()) {
|
||||
try {
|
||||
Runnable task = writeQueue.take();
|
||||
task.run();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Runnable remaining;
|
||||
while ((remaining = writeQueue.poll()) != null) {
|
||||
remaining.run();
|
||||
try {
|
||||
while (running || !writeQueue.isEmpty()) {
|
||||
try {
|
||||
Runnable task = writeQueue.take();
|
||||
task.run();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Runnable remaining;
|
||||
while ((remaining = writeQueue.poll()) != null) {
|
||||
remaining.run();
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
closeCurrentWriter();
|
||||
}
|
||||
}, "ASC-AuditLog-Writer");
|
||||
writerThread.setDaemon(true);
|
||||
@@ -137,20 +144,36 @@ public final class DatabaseManager {
|
||||
private void doAppend(EventType type,
|
||||
String uuid, String name, String world, String detail,
|
||||
long ts, String date) {
|
||||
Path file = logDir.resolve("events-" + date + ".log");
|
||||
String line = ts + SEP
|
||||
+ type.name() + SEP
|
||||
+ sanitise(uuid) + SEP
|
||||
+ sanitise(name) + SEP
|
||||
+ sanitise(world) + SEP
|
||||
+ sanitise(detail);
|
||||
try (BufferedWriter bw = Files.newBufferedWriter(
|
||||
file, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
|
||||
bw.write(line);
|
||||
bw.newLine();
|
||||
try {
|
||||
if (currentWriter == null || !date.equals(currentWriterDate)) {
|
||||
closeCurrentWriter();
|
||||
Path file = logDir.resolve("events-" + date + ".log");
|
||||
currentWriter = Files.newBufferedWriter(file, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
currentWriterDate = date;
|
||||
}
|
||||
currentWriter.write(line);
|
||||
currentWriter.newLine();
|
||||
currentWriter.flush();
|
||||
} catch (IOException e) {
|
||||
log.warning("[ASC-Log] Write failed: " + e.getMessage());
|
||||
closeCurrentWriter();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeCurrentWriter() {
|
||||
if (currentWriter != null) {
|
||||
try {
|
||||
currentWriter.close();
|
||||
} catch (IOException ignored) { }
|
||||
currentWriter = null;
|
||||
currentWriterDate = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +189,8 @@ public final class DatabaseManager {
|
||||
TREASURE_MAP_SCRAMBLED,
|
||||
SEED_PACKET_SPOOFED,
|
||||
END_SPIKE_PROTECTED,
|
||||
END_CITY_PROTECTED
|
||||
END_CITY_PROTECTED,
|
||||
STRUCTURE_RECON_FLAGGED,
|
||||
COMMAND_PROBING_FLAGGED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package me.sunsetrqsv.antiseedcracker.listeners;
|
||||
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.database.DatabaseManager;
|
||||
import me.sunsetrqsv.antiseedcracker.util.CommandUtil;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.server.RemoteServerCommandEvent;
|
||||
import org.bukkit.event.server.ServerCommandEvent;
|
||||
import org.bukkit.event.server.TabCompleteEvent;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class CommandProtectionListener implements Listener {
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
private final Map<UUID, Deque<Long>> probingTimestamps = new ConcurrentHashMap<>();
|
||||
|
||||
public CommandProtectionListener(AntiSeedCrackerPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
|
||||
String raw = event.getMessage().trim();
|
||||
if (!isBlocked(raw)) return;
|
||||
|
||||
Player player = event.getPlayer();
|
||||
event.setCancelled(true);
|
||||
player.sendMessage(plugin.getPluginConfig().getMessageSeedBlockedPlayer());
|
||||
plugin.getLogger().warning(
|
||||
"[AntiSeedCracker] Seed command blocked for player "
|
||||
+ player.getName() + ": " + raw);
|
||||
|
||||
DatabaseManager db = plugin.getDatabaseManager();
|
||||
if (db != null && plugin.getPluginConfig().isDatabaseLogEvents()) {
|
||||
db.logEvent(
|
||||
DatabaseManager.EventType.SEED_COMMAND_BLOCKED,
|
||||
player.getUniqueId().toString(),
|
||||
player.getName(),
|
||||
player.getWorld().getName(),
|
||||
raw);
|
||||
}
|
||||
|
||||
recordProbingAttempt(player);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void onServerCommand(ServerCommandEvent event) {
|
||||
String raw = event.getCommand().trim();
|
||||
if (!isBlocked(raw)) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
event.getSender().sendMessage(plugin.getPluginConfig().getMessageSeedBlockedConsole());
|
||||
plugin.getLogger().warning(
|
||||
"[AntiSeedCracker] Console seed command blocked: " + raw);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void onRemoteServerCommand(RemoteServerCommandEvent event) {
|
||||
String raw = event.getCommand().trim();
|
||||
if (!isBlocked(raw)) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
event.getSender().sendMessage(plugin.getPluginConfig().getMessageSeedBlockedConsole());
|
||||
plugin.getLogger().warning(
|
||||
"[AntiSeedCracker] RCON seed command blocked: " + raw);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onTabComplete(TabCompleteEvent event) {
|
||||
java.util.List<String> extra = plugin.getPluginConfig().getExtraBlockedCommands();
|
||||
event.getCompletions().removeIf(c -> {
|
||||
String lower = c.toLowerCase();
|
||||
if (lower.startsWith("/")) lower = lower.substring(1);
|
||||
int colon = lower.indexOf(':');
|
||||
if (colon >= 0) lower = lower.substring(colon + 1);
|
||||
return lower.equals("seed")
|
||||
|| lower.equals("getseed")
|
||||
|| lower.equals("worldseed")
|
||||
|| extra.contains(lower);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the literal command AND, if it's an {@code /execute ... run ...} chain,
|
||||
* the command it ultimately dispatches to — see {@link CommandUtil} for why the
|
||||
* unwrap is required (otherwise {@code /execute run seed} sails right past this).
|
||||
*/
|
||||
private boolean isBlocked(String raw) {
|
||||
String normalized = CommandUtil.normalizeRoot(raw);
|
||||
if (isSeedCommandRoot(normalized) || isExtraBlockedRoot(normalized)) return true;
|
||||
|
||||
if (CommandUtil.startsWithWord(normalized, "execute")) {
|
||||
String effective = CommandUtil.resolveExecuteTarget(normalized);
|
||||
if (effective != null) {
|
||||
return isSeedCommandRoot(effective) || isExtraBlockedRoot(effective);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isSeedCommandRoot(String normalized) {
|
||||
return normalized.equals("seed")
|
||||
|| normalized.startsWith("seed ")
|
||||
|| normalized.equals("getseed")
|
||||
|| normalized.startsWith("getseed ")
|
||||
|| normalized.equals("worldseed")
|
||||
|| normalized.startsWith("worldseed ");
|
||||
}
|
||||
|
||||
private boolean isExtraBlockedRoot(String normalized) {
|
||||
String root = normalized.split("\\s+", 2)[0];
|
||||
return plugin.getPluginConfig().getExtraBlockedCommands().contains(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs (never blocks — matching {@link StructureReconMonitor}'s non-punishing
|
||||
* design) when a player racks up several blocked seed-command attempts in a short
|
||||
* window: a pattern consistent with a script/macro trying many command variants
|
||||
* back to back to probe for a bypass, rather than one curious manual /seed.
|
||||
*/
|
||||
private void recordProbingAttempt(Player player) {
|
||||
if (!plugin.getPluginConfig().isCommandProbingMonitorEnabled()) return;
|
||||
|
||||
int windowSeconds = plugin.getPluginConfig().getCommandProbingWindowSeconds();
|
||||
int threshold = plugin.getPluginConfig().getCommandProbingThreshold();
|
||||
long now = System.currentTimeMillis();
|
||||
long windowStart = now - (windowSeconds * 1000L);
|
||||
|
||||
Deque<Long> timestamps = probingTimestamps.computeIfAbsent(
|
||||
player.getUniqueId(), k -> new ArrayDeque<>());
|
||||
boolean flagged;
|
||||
synchronized (timestamps) {
|
||||
timestamps.addLast(now);
|
||||
while (!timestamps.isEmpty() && timestamps.peekFirst() < windowStart) {
|
||||
timestamps.pollFirst();
|
||||
}
|
||||
flagged = timestamps.size() >= threshold;
|
||||
if (flagged) timestamps.clear();
|
||||
}
|
||||
if (!flagged) return;
|
||||
|
||||
plugin.getLogger().warning("[AntiSeedCracker] Command-probing pattern detected for "
|
||||
+ player.getName() + " - triggered " + threshold + "+ blocked seed commands "
|
||||
+ "within " + windowSeconds + "s. This does not block the player (per design); "
|
||||
+ "review manually if bypass-scripting is suspected.");
|
||||
|
||||
DatabaseManager db = plugin.getDatabaseManager();
|
||||
if (db != null && plugin.getPluginConfig().isDatabaseLogEvents()) {
|
||||
db.logEvent(DatabaseManager.EventType.COMMAND_PROBING_FLAGGED,
|
||||
player.getUniqueId().toString(),
|
||||
player.getName(),
|
||||
player.getWorld().getName(),
|
||||
"Rapid repeated blocked-command attempts");
|
||||
}
|
||||
}
|
||||
|
||||
public void removePlayer(UUID uuid) {
|
||||
probingTimestamps.remove(uuid);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
HandlerList.unregisterAll(this);
|
||||
}
|
||||
}
|
||||
+35
-19
@@ -1,7 +1,7 @@
|
||||
package me.legendcraft.antiseedcracker.listeners;
|
||||
package me.sunsetrqsv.antiseedcracker.listeners;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -16,6 +16,7 @@ import org.bukkit.generator.structure.GeneratedStructure;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
@@ -48,14 +49,25 @@ public final class EndCityProtector implements Listener {
|
||||
Collection<GeneratedStructure> structures =
|
||||
chunk.getStructures(Structure.END_CITY);
|
||||
|
||||
if (structures.isEmpty()) return;
|
||||
|
||||
if (plugin.getPluginConfig().isEndCitiesModifyWorld()) {
|
||||
replaceGlassInChunk(chunk);
|
||||
if (structures.isEmpty()) {
|
||||
// Nothing in this chunk ever needs checking again - structures don't
|
||||
// appear after generation, so this is permanently safe to skip.
|
||||
chunk.getPersistentDataContainer()
|
||||
.set(modifiedKey, PersistentDataType.BYTE, (byte) 1);
|
||||
return;
|
||||
}
|
||||
|
||||
chunk.getPersistentDataContainer()
|
||||
.set(modifiedKey, PersistentDataType.BYTE, (byte) 1);
|
||||
if (plugin.getPluginConfig().isEndCitiesModifyWorld()) {
|
||||
for (GeneratedStructure structure : structures) {
|
||||
replaceGlassInBounds(chunk, structure.getBoundingBox());
|
||||
}
|
||||
chunk.getPersistentDataContainer()
|
||||
.set(modifiedKey, PersistentDataType.BYTE, (byte) 1);
|
||||
}
|
||||
// else: an End City is here but modify_world is off - leave unmarked so a
|
||||
// future load re-checks this chunk in case modify_world gets enabled later.
|
||||
// Marking it "done" here without having done anything would permanently
|
||||
// skip this chunk even after the admin turns modify_world on.
|
||||
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[AntiSeedCracker] EndCityProtector scan error"
|
||||
@@ -64,17 +76,21 @@ public final class EndCityProtector implements Listener {
|
||||
});
|
||||
}
|
||||
|
||||
private void replaceGlassInChunk(Chunk chunk) {
|
||||
World world = chunk.getWorld();
|
||||
int minY = world.getMinHeight();
|
||||
int maxY = world.getMaxHeight();
|
||||
int baseX = chunk.getX() << 4;
|
||||
int baseZ = chunk.getZ() << 4;
|
||||
private void replaceGlassInBounds(Chunk chunk, BoundingBox bounds) {
|
||||
World world = chunk.getWorld();
|
||||
int minY = Math.max(world.getMinHeight(), (int) Math.floor(bounds.getMinY()));
|
||||
int maxY = Math.min(world.getMaxHeight(), (int) Math.ceil(bounds.getMaxY()));
|
||||
int baseX = chunk.getX() << 4;
|
||||
int baseZ = chunk.getZ() << 4;
|
||||
int minX = Math.max(baseX, (int) Math.floor(bounds.getMinX()));
|
||||
int maxX = Math.min(baseX + 15, (int) Math.ceil(bounds.getMaxX()));
|
||||
int minZ = Math.max(baseZ, (int) Math.floor(bounds.getMinZ()));
|
||||
int maxZ = Math.min(baseZ + 15, (int) Math.ceil(bounds.getMaxZ()));
|
||||
|
||||
for (int dx = 0; dx < 16; dx++) {
|
||||
for (int dz = 0; dz < 16; dz++) {
|
||||
for (int y = minY; y < maxY; y++) {
|
||||
Block block = world.getBlockAt(baseX + dx, y, baseZ + dz);
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
Block block = world.getBlockAt(x, y, z);
|
||||
if (block.getType() == Material.MAGENTA_STAINED_GLASS) {
|
||||
block.setType(Material.PURPUR_BLOCK, false);
|
||||
}
|
||||
+23
-6
@@ -1,7 +1,7 @@
|
||||
package me.legendcraft.antiseedcracker.listeners;
|
||||
package me.sunsetrqsv.antiseedcracker.listeners;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
@@ -20,6 +20,9 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class EndSpikeProtector implements Listener {
|
||||
|
||||
@@ -31,7 +34,7 @@ public final class EndSpikeProtector implements Listener {
|
||||
static {
|
||||
PILLAR_ANGLES = new double[10];
|
||||
for (int i = 0; i < 10; i++) {
|
||||
PILLAR_ANGLES[i] = 2.0 * (-Math.PI + Math.PI * 0.1 * i);
|
||||
PILLAR_ANGLES[i] = i * (2.0 * Math.PI / 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +43,10 @@ public final class EndSpikeProtector implements Listener {
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
private final NamespacedKey modifiedKey;
|
||||
|
||||
/** Worlds currently running a respawn-cycle poll loop, to avoid starting one per crystal
|
||||
* (all 4 respawn crystals fire {@link EntityPlaceEvent} independently). */
|
||||
private final Set<UUID> pollingWorlds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public EndSpikeProtector(AntiSeedCrackerPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
this.modifiedKey = new NamespacedKey(plugin, "asc_spike_modified");
|
||||
@@ -62,11 +69,16 @@ public final class EndSpikeProtector implements Listener {
|
||||
if (event.getEntityType() != EntityType.END_CRYSTAL) return;
|
||||
World world = event.getEntity().getWorld();
|
||||
if (!shouldProcess(world)) return;
|
||||
if (!plugin.getPluginConfig().isEndSpikesModifyWorld()) return;
|
||||
|
||||
Block placedOn = event.getBlock();
|
||||
if (placedOn == null || placedOn.getType() != Material.BEDROCK) return;
|
||||
if (Math.abs(placedOn.getX()) > 3 || Math.abs(placedOn.getZ()) > 3) return;
|
||||
|
||||
// All 4 respawn crystals fire this event independently within the same tick or two;
|
||||
// without this guard each one would start its own redundant 300-attempt poll loop.
|
||||
if (!pollingWorlds.add(world.getUID())) return;
|
||||
|
||||
world.getPersistentDataContainer().set(modifiedKey, PersistentDataType.BOOLEAN, false);
|
||||
schedulePollForRespawnEnd(world, 0);
|
||||
}
|
||||
@@ -78,13 +90,16 @@ public final class EndSpikeProtector implements Listener {
|
||||
}
|
||||
|
||||
private void scheduleModification(World world) {
|
||||
// Checked before touching the PDC at all: if this flag were set while modify_world
|
||||
// was off, turning modify_world on later would find "already modified" already true
|
||||
// and permanently skip the shuffle for this world.
|
||||
if (!plugin.getPluginConfig().isEndSpikesModifyWorld()) return;
|
||||
|
||||
Boolean alreadyModified = world.getPersistentDataContainer()
|
||||
.getOrDefault(modifiedKey, PersistentDataType.BOOLEAN, false);
|
||||
if (alreadyModified) return;
|
||||
world.getPersistentDataContainer().set(modifiedKey, PersistentDataType.BOOLEAN, true);
|
||||
|
||||
if (!plugin.getPluginConfig().isEndSpikesModifyWorld()) return;
|
||||
|
||||
int[] heights = Arrays.copyOf(VANILLA_HEIGHTS, VANILLA_HEIGHTS.length);
|
||||
for (int i = heights.length - 1; i > 0; i--) {
|
||||
int j = RNG.nextInt(i + 1);
|
||||
@@ -121,6 +136,7 @@ public final class EndSpikeProtector implements Listener {
|
||||
|
||||
private void schedulePollForRespawnEnd(World world, int attemptCount) {
|
||||
if (attemptCount > 300) {
|
||||
pollingWorlds.remove(world.getUID());
|
||||
scheduleModification(world);
|
||||
return;
|
||||
}
|
||||
@@ -133,6 +149,7 @@ public final class EndSpikeProtector implements Listener {
|
||||
if (summoning) {
|
||||
schedulePollForRespawnEnd(world, attemptCount + 1);
|
||||
} else {
|
||||
pollingWorlds.remove(world.getUID());
|
||||
scheduleModification(world);
|
||||
}
|
||||
}, 20L);
|
||||
+17
-4
@@ -1,8 +1,8 @@
|
||||
package me.legendcraft.antiseedcracker.listeners;
|
||||
package me.sunsetrqsv.antiseedcracker.listeners;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.database.DatabaseManager;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.database.DatabaseManager;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.EnderSignal;
|
||||
@@ -39,6 +39,7 @@ public final class EyeOfEnderProtector implements Listener {
|
||||
UUID shooterUUID = shooterPlayer != null ? shooterPlayer.getUniqueId() : null;
|
||||
|
||||
final int[] fakeTarget;
|
||||
final int targetY;
|
||||
if (shooterUUID != null) {
|
||||
int[] stored = plugin.getSeedManager().getFakeStronghold(shooterUUID);
|
||||
if (stored != null) {
|
||||
@@ -50,6 +51,7 @@ public final class EyeOfEnderProtector implements Listener {
|
||||
plugin.getPluginConfig().getFakeStrongholdMaxDist()
|
||||
);
|
||||
}
|
||||
targetY = plugin.getSeedManager().getFakeStrongholdY(shooterUUID, eye.getLocation().getBlockY());
|
||||
} else {
|
||||
int min = plugin.getPluginConfig().getFakeStrongholdMinDist();
|
||||
int max = plugin.getPluginConfig().getFakeStrongholdMaxDist();
|
||||
@@ -59,6 +61,9 @@ public final class EyeOfEnderProtector implements Listener {
|
||||
(int) Math.round(Math.cos(angle) * dist),
|
||||
(int) Math.round(Math.sin(angle) * dist)
|
||||
};
|
||||
int minY = world.getMinHeight() + 8;
|
||||
int maxY = Math.max(minY + 1, Math.min(world.getMaxHeight() - 8, minY + 72));
|
||||
targetY = minY + RNG.nextInt(maxY - minY);
|
||||
}
|
||||
|
||||
final int targetX = fakeTarget[0];
|
||||
@@ -90,6 +95,13 @@ public final class EyeOfEnderProtector implements Listener {
|
||||
jz * speed
|
||||
));
|
||||
|
||||
// Critical: vanilla EnderSignal re-accelerates toward its target location every
|
||||
// tick on its own, independent of whatever velocity we just set. That target was
|
||||
// already set to the REAL stronghold before this event ever fired (the entity is
|
||||
// constructed with it), so without this call the eye would curve back toward the
|
||||
// true bearing within a second or two regardless of the jitter above.
|
||||
eye.setTargetLocation(new Location(world, targetX + 0.5, targetY, targetZ + 0.5));
|
||||
|
||||
DatabaseManager db = plugin.getDatabaseManager();
|
||||
if (db != null && plugin.getPluginConfig().isDatabaseLogEvents()
|
||||
&& shooterPlayer != null) {
|
||||
@@ -120,6 +132,7 @@ public final class EyeOfEnderProtector implements Listener {
|
||||
eye.getVelocity().getY(),
|
||||
(ndx * sin + ndz * cos) * speed
|
||||
));
|
||||
eye.setTargetLocation(new Location(world, targetX + 0.5, targetY, targetZ + 0.5));
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
}
|
||||
+59
-24
@@ -1,6 +1,7 @@
|
||||
package me.legendcraft.antiseedcracker.listeners;
|
||||
package me.sunsetrqsv.antiseedcracker.listeners;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.util.CommandUtil;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -13,7 +14,6 @@ import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class PlayerSessionListener implements Listener {
|
||||
@@ -22,8 +22,6 @@ public final class PlayerSessionListener implements Listener {
|
||||
Pattern.compile("^/?(?:minecraft:)?locate(?:\\s+(\\w+))?(?:\\s+([\\w:]+))?\\s*$",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final SecureRandom RNG = new SecureRandom();
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
|
||||
public PlayerSessionListener(AntiSeedCrackerPlugin plugin) {
|
||||
@@ -38,33 +36,52 @@ public final class PlayerSessionListener implements Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
plugin.getSeedManager().removePlayer(event.getPlayer().getUniqueId());
|
||||
if (plugin.getStructureReconMonitor() != null) {
|
||||
plugin.getStructureReconMonitor().removePlayer(event.getPlayer().getUniqueId());
|
||||
}
|
||||
if (plugin.getCommandProtectionListener() != null) {
|
||||
plugin.getCommandProtectionListener().removePlayer(event.getPlayer().getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onWorldUnload(WorldUnloadEvent event) {
|
||||
plugin.getSeedManager().forgetWorld(event.getWorld().getUID());
|
||||
plugin.getSeedIntegrityMonitor().forgetWorld(event.getWorld().getUID());
|
||||
if (plugin.getStructureReconMonitor() != null) {
|
||||
plugin.getStructureReconMonitor().forgetWorld(event.getWorld().getUID());
|
||||
}
|
||||
}
|
||||
|
||||
public void initPlayer(Player player) {
|
||||
plugin.getSeedManager().assignSeed(player);
|
||||
plugin.getSeedManager().assignFakeStronghold(
|
||||
player,
|
||||
plugin.getPluginConfig().getFakeStrongholdMinDist(),
|
||||
plugin.getPluginConfig().getFakeStrongholdMaxDist()
|
||||
);
|
||||
|
||||
// Only assign on first sight of this player. initPlayer() also re-runs for every
|
||||
// already-online player on /asc reload (and /asc toggle, which reloads internally) -
|
||||
// unconditionally reassigning here would silently move an online player's fake
|
||||
// stronghold on every unrelated config change, defeating the very answer-consistency
|
||||
// /locate and Eye of Ender redirection depend on to not tip off a suspicious player.
|
||||
if (!plugin.getSeedManager().hasFakeStronghold(player.getUniqueId())) {
|
||||
plugin.getSeedManager().assignFakeStronghold(
|
||||
player,
|
||||
plugin.getPluginConfig().getFakeStrongholdMinDist(),
|
||||
plugin.getPluginConfig().getFakeStrongholdMaxDist()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
|
||||
if (!plugin.getPluginConfig().isSpoofLocateEnabled()) return;
|
||||
|
||||
String message = event.getMessage().trim();
|
||||
if (!LOCATE_PATTERN.matcher(message).matches()) return;
|
||||
String raw = event.getMessage().trim();
|
||||
String normalized = CommandUtil.normalizeRoot(raw);
|
||||
String locateCommand = resolveLocateCommand(normalized);
|
||||
if (locateCommand == null) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
|
||||
Player player = event.getPlayer();
|
||||
String structureArg = extractStructureArg(message);
|
||||
String structureArg = extractStructureArg(locateCommand);
|
||||
int[] fakeCoords = buildFakeLocateCoords(player, structureArg);
|
||||
int fakeX = fakeCoords[0];
|
||||
int fakeZ = fakeCoords[1];
|
||||
@@ -82,6 +99,25 @@ public final class PlayerSessionListener implements Listener {
|
||||
.append(Component.text(" blocks away)", NamedTextColor.WHITE)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized {@code locate ...} command to spoof, checking both the
|
||||
* literal command and — since vanilla's {@code /execute} re-dispatches its trailing
|
||||
* command without re-firing this event — any {@code /execute ... run locate ...}
|
||||
* wrapping. Returns {@code null} if this isn't a locate command at all.
|
||||
*/
|
||||
private String resolveLocateCommand(String normalized) {
|
||||
if (LOCATE_PATTERN.matcher("/" + normalized).matches()) {
|
||||
return normalized;
|
||||
}
|
||||
if (CommandUtil.startsWithWord(normalized, "execute")) {
|
||||
String effective = CommandUtil.resolveExecuteTarget(normalized);
|
||||
if (effective != null && LOCATE_PATTERN.matcher("/" + effective).matches()) {
|
||||
return effective;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractStructureArg(String command) {
|
||||
String[] tokens = command.replaceFirst("^/", "").split("\\s+");
|
||||
int startIdx = 1;
|
||||
@@ -94,19 +130,18 @@ public final class PlayerSessionListener implements Listener {
|
||||
}
|
||||
|
||||
private int[] buildFakeLocateCoords(Player player, String structureArg) {
|
||||
if (structureArg.contains("stronghold")) {
|
||||
String key = structureArg.contains(":")
|
||||
? structureArg.substring(structureArg.indexOf(':') + 1)
|
||||
: structureArg;
|
||||
|
||||
if (key.equals("stronghold")) {
|
||||
int[] fakeStronghold = plugin.getSeedManager().getFakeStronghold(player.getUniqueId());
|
||||
if (fakeStronghold != null) return fakeStronghold;
|
||||
}
|
||||
int maxOffset = plugin.getPluginConfig().getSpoofLocateMaxOffset();
|
||||
int minOffset = maxOffset / 4;
|
||||
double angle = RNG.nextDouble() * 2.0 * Math.PI;
|
||||
int dist = minOffset + RNG.nextInt(Math.max(1, maxOffset - minOffset + 1));
|
||||
int baseX = (int) player.getLocation().getX();
|
||||
int baseZ = (int) player.getLocation().getZ();
|
||||
int x = ((baseX + (int) Math.round(Math.cos(angle) * dist)) >> 4) << 4;
|
||||
int z = ((baseZ + (int) Math.round(Math.sin(angle) * dist)) >> 4) << 4;
|
||||
return new int[]{x, z};
|
||||
int maxOffset = plugin.getPluginConfig().getSpoofLocateMaxOffset();
|
||||
int minOffset = maxOffset / 4;
|
||||
return plugin.getSeedManager()
|
||||
.getOrAssignFakeStructureLocation(player, key, minOffset, maxOffset);
|
||||
}
|
||||
|
||||
private String formatStructureName(String id) {
|
||||
@@ -0,0 +1,32 @@
|
||||
package me.sunsetrqsv.antiseedcracker.listeners;
|
||||
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||
|
||||
public final class SlimeChunkObfuscator implements Listener {
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
|
||||
public SlimeChunkObfuscator(AntiSeedCrackerPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onSlimeSpawn(CreatureSpawnEvent event) {
|
||||
if (event.getEntityType() != EntityType.SLIME) return;
|
||||
if (event.getSpawnReason() != CreatureSpawnEvent.SpawnReason.NATURAL) return;
|
||||
if (!plugin.getPluginConfig().getSlimeChunkObfuscationWorlds()
|
||||
.contains(event.getEntity().getWorld().getName())) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
HandlerList.unregisterAll(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package me.sunsetrqsv.antiseedcracker.listeners;
|
||||
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.database.DatabaseManager;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.world.ChunkUnloadEvent;
|
||||
import org.bukkit.generator.structure.GeneratedStructure;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collection;
|
||||
import java.util.Deque;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class StructureReconMonitor implements Listener {
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
private final Set<Structure> trackedStructures;
|
||||
|
||||
private final Map<UUID, Set<Long>> visitedChunks = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Deque<Long>> visitTimestamps = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Map<Long, Boolean>> chunkQualifiesCache = new ConcurrentHashMap<>();
|
||||
|
||||
public StructureReconMonitor(AntiSeedCrackerPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
this.trackedStructures = resolveTrackedStructures(
|
||||
plugin, plugin.getPluginConfig().getStructureReconTrackedStructures());
|
||||
}
|
||||
|
||||
private static Set<Structure> resolveTrackedStructures(AntiSeedCrackerPlugin plugin, List<String> names) {
|
||||
Set<Structure> set = new HashSet<>();
|
||||
for (String name : names) {
|
||||
String key = name.trim().toLowerCase(Locale.ROOT);
|
||||
if (key.isEmpty()) continue;
|
||||
if (key.contains(":")) key = key.substring(key.indexOf(':') + 1);
|
||||
Structure structure = Registry.STRUCTURE.get(NamespacedKey.minecraft(key));
|
||||
if (structure == null) {
|
||||
plugin.getLogger().warning("[AntiSeedCracker] structure_recon_monitor: unknown "
|
||||
+ "structure '" + name + "' in tracked_structures, ignoring.");
|
||||
continue;
|
||||
}
|
||||
set.add(structure);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
if (!plugin.getPluginConfig().isStructureReconMonitorEnabled()) return;
|
||||
|
||||
int fromChunkX = event.getFrom().getBlockX() >> 4;
|
||||
int fromChunkZ = event.getFrom().getBlockZ() >> 4;
|
||||
int toChunkX = event.getTo().getBlockX() >> 4;
|
||||
int toChunkZ = event.getTo().getBlockZ() >> 4;
|
||||
if (fromChunkX == toChunkX && fromChunkZ == toChunkZ) return;
|
||||
|
||||
UUID worldUid = event.getTo().getWorld().getUID();
|
||||
long chunkKey = packChunkKey(toChunkX, toChunkZ);
|
||||
|
||||
Map<Long, Boolean> worldCache = chunkQualifiesCache.computeIfAbsent(
|
||||
worldUid, k -> new ConcurrentHashMap<>());
|
||||
Boolean qualifies = worldCache.get(chunkKey);
|
||||
if (qualifies == null) {
|
||||
Chunk chunk = event.getTo().getChunk();
|
||||
Collection<GeneratedStructure> structures;
|
||||
try {
|
||||
structures = chunk.getStructures();
|
||||
} catch (Exception e) {
|
||||
return;
|
||||
}
|
||||
qualifies = false;
|
||||
for (GeneratedStructure s : structures) {
|
||||
if (trackedStructures.contains(s.getStructure())) {
|
||||
qualifies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
worldCache.put(chunkKey, qualifies);
|
||||
}
|
||||
if (!qualifies) return;
|
||||
|
||||
Player player = event.getPlayer();
|
||||
UUID uuid = player.getUniqueId();
|
||||
|
||||
Set<Long> visited = visitedChunks.computeIfAbsent(uuid, k -> ConcurrentHashMap.newKeySet());
|
||||
if (!visited.add(chunkKey)) return;
|
||||
|
||||
int windowSeconds = plugin.getPluginConfig().getStructureReconWindowSeconds();
|
||||
int threshold = plugin.getPluginConfig().getStructureReconThreshold();
|
||||
long now = System.currentTimeMillis();
|
||||
long windowStart = now - (windowSeconds * 1000L);
|
||||
|
||||
Deque<Long> timestamps = visitTimestamps.computeIfAbsent(uuid, k -> new ArrayDeque<>());
|
||||
synchronized (timestamps) {
|
||||
timestamps.addLast(now);
|
||||
while (!timestamps.isEmpty() && timestamps.peekFirst() < windowStart) {
|
||||
timestamps.pollFirst();
|
||||
}
|
||||
if (timestamps.size() >= threshold) {
|
||||
timestamps.clear();
|
||||
flagPlayer(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evicts the chunk-qualifies cache entry when its chunk unloads. Without this the
|
||||
* cache — one boolean per chunk any player has ever passed through — grows without
|
||||
* bound for the entire server uptime; tying it to chunk lifetime instead bounds it
|
||||
* to "currently loaded chunks," which is what actually matters for lookup cost.
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onChunkUnload(ChunkUnloadEvent event) {
|
||||
Map<Long, Boolean> worldCache = chunkQualifiesCache.get(event.getWorld().getUID());
|
||||
if (worldCache == null) return;
|
||||
worldCache.remove(packChunkKey(event.getChunk().getX(), event.getChunk().getZ()));
|
||||
}
|
||||
|
||||
private static long packChunkKey(int chunkX, int chunkZ) {
|
||||
return (((long) chunkX) << 32) ^ (chunkZ & 0xFFFFFFFFL);
|
||||
}
|
||||
|
||||
private void flagPlayer(Player player) {
|
||||
plugin.getLogger().warning("[AntiSeedCracker] Structure reconnaissance pattern detected for "
|
||||
+ player.getName() + " - visited multiple seed-relevant structures in a short "
|
||||
+ "window. This does not block the player (per design); review manually if "
|
||||
+ "seed-cracking tool use is suspected.");
|
||||
|
||||
DatabaseManager db = plugin.getDatabaseManager();
|
||||
if (db != null && plugin.getPluginConfig().isDatabaseLogEvents()) {
|
||||
db.logEvent(DatabaseManager.EventType.STRUCTURE_RECON_FLAGGED,
|
||||
player.getUniqueId().toString(),
|
||||
player.getName(),
|
||||
player.getWorld().getName(),
|
||||
"Rapid multi-structure visitation pattern");
|
||||
}
|
||||
}
|
||||
|
||||
public void removePlayer(UUID uuid) {
|
||||
visitedChunks.remove(uuid);
|
||||
visitTimestamps.remove(uuid);
|
||||
}
|
||||
|
||||
public void forgetWorld(UUID worldUid) {
|
||||
chunkQualifiesCache.remove(worldUid);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
HandlerList.unregisterAll(this);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
package me.legendcraft.antiseedcracker.packets;
|
||||
package me.sunsetrqsv.antiseedcracker.packets;
|
||||
|
||||
import com.github.retrooper.packetevents.event.PacketListenerAbstract;
|
||||
import com.github.retrooper.packetevents.event.PacketListenerPriority;
|
||||
import com.github.retrooper.packetevents.event.PacketSendEvent;
|
||||
import com.github.retrooper.packetevents.protocol.packettype.PacketType;
|
||||
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerJoinGame;
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class LoginPacketInterceptor extends PacketListenerAbstract {
|
||||
+37
-11
@@ -1,4 +1,4 @@
|
||||
package me.legendcraft.antiseedcracker.packets;
|
||||
package me.sunsetrqsv.antiseedcracker.packets;
|
||||
|
||||
import com.github.retrooper.packetevents.event.PacketListenerAbstract;
|
||||
import com.github.retrooper.packetevents.event.PacketListenerPriority;
|
||||
@@ -7,17 +7,14 @@ import com.github.retrooper.packetevents.protocol.item.mapdecoration.MapDecorati
|
||||
import com.github.retrooper.packetevents.protocol.packettype.PacketType;
|
||||
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMapData;
|
||||
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMapData.MapDecoration;
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.database.DatabaseManager;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.database.DatabaseManager;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
|
||||
public final class MapSeedInterceptor extends PacketListenerAbstract {
|
||||
|
||||
private static final SecureRandom RNG = new SecureRandom();
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
|
||||
public MapSeedInterceptor(AntiSeedCrackerPlugin plugin) {
|
||||
@@ -36,11 +33,14 @@ public final class MapSeedInterceptor extends PacketListenerAbstract {
|
||||
List<MapDecoration> decorations = wrapper.getDecorations();
|
||||
if (decorations == null || decorations.isEmpty()) return;
|
||||
|
||||
int mapId = wrapper.getMapId();
|
||||
boolean modified = false;
|
||||
for (MapDecoration deco : decorations) {
|
||||
if (isStructureDecoration(deco.getType())) {
|
||||
deco.setX((byte) (RNG.nextInt(256) - 128));
|
||||
deco.setY((byte) (RNG.nextInt(256) - 128));
|
||||
byte origX = deco.getX();
|
||||
byte origY = deco.getY();
|
||||
deco.setX(fakeCoordinate(mapId, deco.getType(), origX, origY, false));
|
||||
deco.setY(fakeCoordinate(mapId, deco.getType(), origX, origY, true));
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
@@ -68,21 +68,47 @@ public final class MapSeedInterceptor extends PacketListenerAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches against the real vanilla map decoration type IDs (verified against
|
||||
* {@code org.bukkit.map.MapCursor.Type}: no "treasure", "pillager", or "bastion" type
|
||||
* exists at all — those three never matched anything. Buried treasure maps actually use
|
||||
* {@code TARGET_X}, which was previously missing here, so buried treasure locations
|
||||
* (one of this plugin's own named SeedCrackerX signals) were never actually scrambled.
|
||||
*/
|
||||
private static boolean isStructureDecoration(MapDecorationType type) {
|
||||
try {
|
||||
String name = type.getName().toString().toUpperCase();
|
||||
return name.contains("MANSION")
|
||||
|| name.contains("MONUMENT")
|
||||
|| name.contains("TARGET_X")
|
||||
|| name.contains("RED_X")
|
||||
|| name.contains("TREASURE")
|
||||
|| name.contains("TRIAL_CHAMBERS")
|
||||
|| name.contains("VILLAGE")
|
||||
|| name.contains("PILLAGER")
|
||||
|| name.contains("BASTION")
|
||||
|| name.contains("JUNGLE_TEMPLE")
|
||||
|| name.contains("SWAMP_HUT");
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a fake coordinate deterministically from the map ID, icon type, and real
|
||||
* position instead of drawing fresh randomness per packet. MAP_DATA resends the full
|
||||
* decoration list on every update while a player views the map; a fresh random value
|
||||
* each time made the icon visibly jitter/teleport around the map every tick, which is
|
||||
* a far more obvious "something is spoofing this" tell than a fixed wrong position.
|
||||
*/
|
||||
private static byte fakeCoordinate(int mapId, MapDecorationType type, byte origX, byte origY, boolean axisY) {
|
||||
long h = mapId;
|
||||
h = h * 31 + type.getName().toString().hashCode();
|
||||
h = h * 31 + origX;
|
||||
h = h * 31 + origY;
|
||||
h = h * 31 + (axisY ? 1 : 0);
|
||||
h ^= (h >>> 33);
|
||||
h *= 0xff51afd7ed558ccdL;
|
||||
h ^= (h >>> 33);
|
||||
h *= 0xc4ceb9fe1a85ec53L;
|
||||
h ^= (h >>> 33);
|
||||
return (byte) h;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
package me.legendcraft.antiseedcracker.packets;
|
||||
package me.sunsetrqsv.antiseedcracker.packets;
|
||||
|
||||
import com.github.retrooper.packetevents.event.PacketListenerAbstract;
|
||||
import com.github.retrooper.packetevents.event.PacketListenerPriority;
|
||||
import com.github.retrooper.packetevents.event.PacketSendEvent;
|
||||
import com.github.retrooper.packetevents.protocol.packettype.PacketType;
|
||||
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerRespawn;
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class RespawnPacketInterceptor extends PacketListenerAbstract {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package me.legendcraft.antiseedcracker.scheduler;
|
||||
package me.sunsetrqsv.antiseedcracker.scheduler;
|
||||
|
||||
public interface CancelableTask {
|
||||
void cancel();
|
||||
+7
-14
@@ -1,6 +1,7 @@
|
||||
package me.legendcraft.antiseedcracker.scheduler;
|
||||
package me.sunsetrqsv.antiseedcracker.scheduler;
|
||||
|
||||
import me.legendcraft.antiseedcracker.util.PlatformUtil;
|
||||
import io.papermc.paper.threadedregions.scheduler.ScheduledTask;
|
||||
import me.sunsetrqsv.antiseedcracker.util.PlatformUtil;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Entity;
|
||||
@@ -15,13 +16,9 @@ public final class FoliaSchedulerUtil {
|
||||
public static CancelableTask scheduleAsyncRepeating(Plugin plugin, Runnable task,
|
||||
long initialDelayMs, long periodMs) {
|
||||
if (PlatformUtil.IS_PAPER) {
|
||||
Object handle = plugin.getServer().getAsyncScheduler()
|
||||
ScheduledTask handle = plugin.getServer().getAsyncScheduler()
|
||||
.runAtFixedRate(plugin, t -> task.run(), initialDelayMs, periodMs, TimeUnit.MILLISECONDS);
|
||||
return () -> {
|
||||
try {
|
||||
handle.getClass().getMethod("cancel").invoke(handle);
|
||||
} catch (Exception ignored) {}
|
||||
};
|
||||
return handle::cancel;
|
||||
}
|
||||
long delayTicks = Math.max(1L, initialDelayMs / 50L);
|
||||
long periodTicks = Math.max(1L, periodMs / 50L);
|
||||
@@ -113,13 +110,9 @@ public final class FoliaSchedulerUtil {
|
||||
long initialDelayTicks,
|
||||
long periodTicks) {
|
||||
if (PlatformUtil.IS_PAPER) {
|
||||
Object handle = plugin.getServer().getGlobalRegionScheduler()
|
||||
ScheduledTask handle = plugin.getServer().getGlobalRegionScheduler()
|
||||
.runAtFixedRate(plugin, t -> task.run(), initialDelayTicks, periodTicks);
|
||||
return () -> {
|
||||
try {
|
||||
handle.getClass().getMethod("cancel").invoke(handle);
|
||||
} catch (Exception ignored) {}
|
||||
};
|
||||
return handle::cancel;
|
||||
}
|
||||
org.bukkit.scheduler.BukkitTask bt = plugin.getServer().getScheduler()
|
||||
.runTaskTimer(plugin, task, initialDelayTicks, periodTicks);
|
||||
@@ -0,0 +1,37 @@
|
||||
package me.sunsetrqsv.antiseedcracker.seed;
|
||||
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class SeedIntegrityMonitor {
|
||||
|
||||
private final Map<UUID, Long> firstObservedSeed = new ConcurrentHashMap<>();
|
||||
private final Logger logger;
|
||||
|
||||
public SeedIntegrityMonitor(Logger logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void check(Iterable<World> worlds) {
|
||||
for (World world : worlds) {
|
||||
long current = world.getSeed();
|
||||
Long baseline = firstObservedSeed.putIfAbsent(world.getUID(), current);
|
||||
if (baseline != null && baseline != current) {
|
||||
logger.warning("[AntiSeedCracker] Seed integrity check FAILED for world '"
|
||||
+ world.getName() + "' - the generation seed changed after the server "
|
||||
+ "started. This plugin never modifies it; another plugin or external "
|
||||
+ "tool is altering world generation state. Investigate immediately, as "
|
||||
+ "this can corrupt newly generated chunks.");
|
||||
firstObservedSeed.put(world.getUID(), current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void forgetWorld(UUID worldUID) {
|
||||
firstObservedSeed.remove(worldUID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package me.sunsetrqsv.antiseedcracker.seed;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class SeedManager {
|
||||
|
||||
private final ConcurrentHashMap<UUID, Long> playerSeeds = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<UUID, long[]> fakeStrongholds = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<UUID, ConcurrentHashMap<String, int[]>> fakeStructureLocations =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private final SecureRandom rng = new SecureRandom();
|
||||
|
||||
public long assignSeed(Player player) {
|
||||
long seed = generateSeed();
|
||||
playerSeeds.put(player.getUniqueId(), seed);
|
||||
return seed;
|
||||
}
|
||||
|
||||
public long getFakeSeed(Player player) {
|
||||
return playerSeeds.computeIfAbsent(player.getUniqueId(), k -> generateSeed());
|
||||
}
|
||||
|
||||
public void removePlayer(UUID uuid) {
|
||||
playerSeeds.remove(uuid);
|
||||
fakeStrongholds.remove(uuid);
|
||||
fakeStructureLocations.remove(uuid);
|
||||
}
|
||||
|
||||
public int rotateAll() {
|
||||
int count = 0;
|
||||
for (UUID uuid : playerSeeds.keySet()) {
|
||||
playerSeeds.put(uuid, generateSeed());
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public void ensureAllPlayersHaveSeeds(Collection<? extends Player> players) {
|
||||
for (Player player : players) {
|
||||
playerSeeds.computeIfAbsent(player.getUniqueId(), k -> generateSeed());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a fake stronghold (x, y, z) to the player, replacing any previous one.
|
||||
* The y is a plausible stronghold-band height so {@link org.bukkit.entity.EnderSignal}
|
||||
* can be given a full 3D target location, not just a horizontal bearing.
|
||||
*/
|
||||
public int[] assignFakeStronghold(Player player, int minDist, int maxDist) {
|
||||
double angle = rng.nextDouble() * 2.0 * Math.PI;
|
||||
int dist = minDist + rng.nextInt(Math.max(1, maxDist - minDist + 1));
|
||||
int x = (int) Math.round(Math.cos(angle) * dist);
|
||||
int z = (int) Math.round(Math.sin(angle) * dist);
|
||||
|
||||
int minY = player.getWorld().getMinHeight() + 8;
|
||||
int maxY = Math.max(minY + 1, Math.min(player.getWorld().getMaxHeight() - 8, minY + 72));
|
||||
int y = minY + rng.nextInt(maxY - minY);
|
||||
|
||||
fakeStrongholds.put(player.getUniqueId(), new long[]{x, y, z});
|
||||
return new int[]{x, z};
|
||||
}
|
||||
|
||||
/** Horizontal (x, z) of the player's fake stronghold, or {@code null} if none assigned yet. */
|
||||
public int[] getFakeStronghold(UUID uuid) {
|
||||
long[] packed = fakeStrongholds.get(uuid);
|
||||
if (packed == null) return null;
|
||||
return new int[]{(int) packed[0], (int) packed[2]};
|
||||
}
|
||||
|
||||
/**
|
||||
* Y of the player's fake stronghold, for continuous Eye of Ender homing (see
|
||||
* {@code EyeOfEnderProtector}). Returns {@code fallback} if none has been assigned yet.
|
||||
*/
|
||||
public int getFakeStrongholdY(UUID uuid, int fallback) {
|
||||
long[] packed = fakeStrongholds.get(uuid);
|
||||
return packed != null ? (int) packed[1] : fallback;
|
||||
}
|
||||
|
||||
/** True if this player already has a fake stronghold assigned. */
|
||||
public boolean hasFakeStronghold(UUID uuid) {
|
||||
return fakeStrongholds.containsKey(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable fake location for the given player + structure key, generating
|
||||
* and caching one on first use. Without this, repeated {@code /locate} calls for the
|
||||
* same non-stronghold structure would each roll a brand-new random answer — an
|
||||
* inconsistency a suspicious player could notice simply by re-running the command
|
||||
* in place, immediately tipping them off that the result is fabricated.
|
||||
*/
|
||||
public int[] getOrAssignFakeStructureLocation(Player player, String structureKey,
|
||||
int minOffset, int maxOffset) {
|
||||
ConcurrentHashMap<String, int[]> perPlayer = fakeStructureLocations
|
||||
.computeIfAbsent(player.getUniqueId(), k -> new ConcurrentHashMap<>());
|
||||
return perPlayer.computeIfAbsent(structureKey, k -> {
|
||||
double angle = rng.nextDouble() * 2.0 * Math.PI;
|
||||
int dist = minOffset + rng.nextInt(Math.max(1, maxOffset - minOffset + 1));
|
||||
int baseX = (int) player.getLocation().getX();
|
||||
int baseZ = (int) player.getLocation().getZ();
|
||||
int x = ((baseX + (int) Math.round(Math.cos(angle) * dist)) >> 4) << 4;
|
||||
int z = ((baseZ + (int) Math.round(Math.sin(angle) * dist)) >> 4) << 4;
|
||||
return new int[]{x, z};
|
||||
});
|
||||
}
|
||||
|
||||
public int trackedCount() {
|
||||
return playerSeeds.size();
|
||||
}
|
||||
|
||||
private long generateSeed() {
|
||||
return rng.nextLong();
|
||||
}
|
||||
}
|
||||
+7
-16
@@ -1,15 +1,12 @@
|
||||
package me.legendcraft.antiseedcracker.tasks;
|
||||
package me.sunsetrqsv.antiseedcracker.tasks;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.scheduler.CancelableTask;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.util.List;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.CancelableTask;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
|
||||
public final class SeedBroadcastTask implements Runnable {
|
||||
|
||||
public static final long PERIOD_MS = 1_000L;
|
||||
public static final long PERIOD_MS = 15_000L;
|
||||
|
||||
private final AntiSeedCrackerPlugin plugin;
|
||||
private volatile CancelableTask handle;
|
||||
@@ -35,14 +32,8 @@ public final class SeedBroadcastTask implements Runnable {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (plugin.getPluginConfig().isPluginApiProtectionEnabled()
|
||||
&& plugin.getWorldSeedInterceptor().isAvailable()) {
|
||||
|
||||
List<World> worlds = plugin.getServer().getWorlds();
|
||||
for (World world : worlds) {
|
||||
long fakeSeed = plugin.getSeedManager().assignWorldFakeSeed(world);
|
||||
plugin.getWorldSeedInterceptor().patchWorld(world, fakeSeed);
|
||||
}
|
||||
if (plugin.getPluginConfig().isSeedIntegrityMonitorEnabled()) {
|
||||
plugin.getSeedIntegrityMonitor().check(plugin.getServer().getWorlds());
|
||||
}
|
||||
|
||||
plugin.getSeedManager().ensureAllPlayersHaveSeeds(
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
package me.legendcraft.antiseedcracker.tasks;
|
||||
package me.sunsetrqsv.antiseedcracker.tasks;
|
||||
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.scheduler.CancelableTask;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.CancelableTask;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
|
||||
public final class SeedRotationTask implements Runnable {
|
||||
|
||||
@@ -17,7 +17,7 @@ public final class SeedRotationTask implements Runnable {
|
||||
if (handle != null) {
|
||||
handle.cancel();
|
||||
}
|
||||
long periodMs = (plugin.getPluginConfig().getSeedRotationIntervalTicks() / 20L) * 1000L;
|
||||
long periodMs = plugin.getPluginConfig().getSeedRotationIntervalSeconds() * 1000L;
|
||||
handle = FoliaSchedulerUtil.scheduleAsyncRepeating(plugin, this, periodMs, periodMs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package me.sunsetrqsv.antiseedcracker.util;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Shared command-string normalisation used by every command-blocking listener.
|
||||
*
|
||||
* <p>Also unwraps {@code /execute ... run <command>} chains. Vanilla's {@code /execute}
|
||||
* re-dispatches its trailing command internally without re-firing
|
||||
* {@code PlayerCommandPreprocessEvent}, so a naive literal-prefix check on the typed
|
||||
* message lets {@code /execute run seed} (or deeper nesting, e.g.
|
||||
* {@code /execute as @s run execute run seed}) sail straight past a blocker that only
|
||||
* looks at the outer command. Every blocking listener must check the resolved target
|
||||
* returned here, not just the raw typed message.</p>
|
||||
*/
|
||||
public final class CommandUtil {
|
||||
|
||||
private static final int MAX_EXECUTE_DEPTH = 16;
|
||||
|
||||
private CommandUtil() {}
|
||||
|
||||
/**
|
||||
* Strips a leading slash and, if present, a leading {@code namespace:} prefix on the
|
||||
* root command token (not a colon anywhere later in the string — e.g. the
|
||||
* {@code minecraft:stronghold} argument of {@code /locate structure minecraft:stronghold}
|
||||
* must survive intact), then lower-cases the result.
|
||||
*/
|
||||
public static String normalizeRoot(String raw) {
|
||||
if (raw == null) return "";
|
||||
String s = raw.trim();
|
||||
if (s.startsWith("/")) s = s.substring(1);
|
||||
|
||||
int firstSpace = s.indexOf(' ');
|
||||
int colon = s.indexOf(':');
|
||||
if (colon >= 0 && (firstSpace < 0 || colon < firstSpace)) {
|
||||
s = s.substring(colon + 1);
|
||||
}
|
||||
return s.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** True if {@code normalized} is exactly {@code word} or starts with {@code word} followed by whitespace. */
|
||||
public static boolean startsWithWord(String normalized, String word) {
|
||||
if (!normalized.startsWith(word)) return false;
|
||||
return normalized.length() == word.length()
|
||||
|| Character.isWhitespace(normalized.charAt(word.length()));
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@code normalized} is an {@code execute} invocation with a trailing {@code run}
|
||||
* clause, recursively resolves and returns the ultimate command it dispatches to
|
||||
* (normalized the same way). Returns {@code null} if the input isn't an execute
|
||||
* chain at all, or an execute chain with no resolvable {@code run} clause (e.g. a
|
||||
* bare/invalid {@code /execute}).
|
||||
*/
|
||||
public static String resolveExecuteTarget(String normalized) {
|
||||
String current = normalized;
|
||||
for (int depth = 0; depth < MAX_EXECUTE_DEPTH; depth++) {
|
||||
if (!startsWithWord(current, "execute")) {
|
||||
return depth == 0 ? null : current;
|
||||
}
|
||||
int runIdx = findTopLevelRun(current);
|
||||
if (runIdx < 0) {
|
||||
return depth == 0 ? null : current;
|
||||
}
|
||||
current = normalizeRoot(current.substring(runIdx + 3));
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of the top-level, standalone {@code run} keyword that starts the
|
||||
* command executed by an {@code /execute} chain — i.e. not one that happens to
|
||||
* appear as part of a selector argument such as {@code @e[name=run]}. Bracket/brace
|
||||
* depth is tracked so occurrences inside {@code [...]} or {@code {...}} are ignored.
|
||||
*/
|
||||
private static int findTopLevelRun(String s) {
|
||||
int depth = 0;
|
||||
int n = s.length();
|
||||
for (int i = 0; i < n; i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '[' || c == '{') {
|
||||
depth++;
|
||||
} else if (c == ']' || c == '}') {
|
||||
if (depth > 0) depth--;
|
||||
} else if (depth == 0 && c == 'r' && s.regionMatches(i, "run", 0, 3)) {
|
||||
boolean leftBoundary = (i == 0) || Character.isWhitespace(s.charAt(i - 1));
|
||||
boolean rightBoundary = (i + 3 == n) || Character.isWhitespace(s.charAt(i + 3));
|
||||
if (leftBoundary && rightBoundary) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package me.legendcraft.antiseedcracker.util;
|
||||
package me.sunsetrqsv.antiseedcracker.util;
|
||||
|
||||
public final class PlatformUtil {
|
||||
|
||||
+10
-17
@@ -1,9 +1,9 @@
|
||||
package me.legendcraft.antiseedcracker.util;
|
||||
package me.sunsetrqsv.antiseedcracker.util;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonParser;
|
||||
import me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.legendcraft.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
import me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin;
|
||||
import me.sunsetrqsv.antiseedcracker.scheduler.FoliaSchedulerUtil;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -36,7 +36,7 @@ public final class UpdateChecker {
|
||||
public void checkAsync() {
|
||||
if (projectId == null || projectId.isBlank()
|
||||
|| projectId.equalsIgnoreCase(UNSET_PLACEHOLDER)) {
|
||||
log.info("[AntiSeedCracker] Update checker skipped — set "
|
||||
log.info("[AntiSeedCracker] Update checker skipped - set "
|
||||
+ "'update_checker.modrinth_id' in config.yml after publishing.");
|
||||
return;
|
||||
}
|
||||
@@ -85,13 +85,12 @@ public final class UpdateChecker {
|
||||
log.info("[AntiSeedCracker] Running latest version (v" + currentVersion + ").");
|
||||
} else {
|
||||
upToDate = false;
|
||||
log.warning("╔════════════════════════════════════════════╗");
|
||||
log.warning("║ AntiSeedCracker — Update Available! ║");
|
||||
log.warning("║ Current : v" + pad(currentVersion, 30) + "║");
|
||||
log.warning("║ Latest : v" + pad(latestVersion, 30) + "║");
|
||||
log.warning("║ https://modrinth.com/plugin/"
|
||||
+ pad(projectId, 14) + "║");
|
||||
log.warning("╚════════════════════════════════════════════╝");
|
||||
log.warning("================================================");
|
||||
log.warning(" AntiSeedCracker - Update Available!");
|
||||
log.warning(" Current : v" + currentVersion);
|
||||
log.warning(" Latest : v" + latestVersion);
|
||||
log.warning(" https://modrinth.com/plugin/" + projectId);
|
||||
log.warning("================================================");
|
||||
}
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
@@ -100,10 +99,4 @@ public final class UpdateChecker {
|
||||
log.fine("[AntiSeedCracker] Update check failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String pad(String s, int width) {
|
||||
if (s == null) s = "unknown";
|
||||
return s.length() >= width ? s.substring(0, width)
|
||||
: s + " ".repeat(width - s.length());
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,19 @@ structure_protection:
|
||||
enabled: true
|
||||
max_offset: 2000
|
||||
|
||||
plugin_api_protection:
|
||||
seed_integrity_monitor:
|
||||
enabled: true
|
||||
|
||||
slime_chunk_obfuscation:
|
||||
enabled: false
|
||||
worlds: []
|
||||
|
||||
structure_recon_monitor:
|
||||
enabled: true
|
||||
threshold: 6
|
||||
window_seconds: 900
|
||||
tracked_structures: []
|
||||
|
||||
eye_of_ender_protection:
|
||||
enabled: true
|
||||
worlds:
|
||||
@@ -46,7 +56,16 @@ database:
|
||||
treasure_map_protection:
|
||||
enabled: true
|
||||
|
||||
messages:
|
||||
seed_blocked_player: "&c[AntiSeedCracker] Access to world seed information is restricted."
|
||||
seed_blocked_console: "&c[AntiSeedCracker] Seed access is restricted. The real seed is never exposed."
|
||||
|
||||
extra_blocked_commands:
|
||||
- "seedcracker"
|
||||
- "seedfind"
|
||||
- "worldseedinfo"
|
||||
|
||||
command_probing_monitor:
|
||||
enabled: true
|
||||
threshold: 5
|
||||
window_seconds: 30
|
||||
@@ -1,23 +1,33 @@
|
||||
name: AntiSeedCracker
|
||||
version: '3.1.0'
|
||||
main: me.legendcraft.antiseedcracker.AntiSeedCrackerPlugin
|
||||
version: '3.5.0.2'
|
||||
main: me.sunsetrqsv.antiseedcracker.AntiSeedCrackerPlugin
|
||||
api-version: '26.1'
|
||||
folia-supported: true
|
||||
author: SunsetRQ7_
|
||||
description: >
|
||||
Professional world seed protection plugin for Paper, Purpur, Folia, and Spigot 26.1.2+.
|
||||
Blocks every known seed-cracking technique: packet-level spoofing, structure
|
||||
triangulation, Eye of Ender tracking, /locate exploitation, treasure maps,
|
||||
End spike fingerprinting, End City fingerprinting, and plugin-API interception.
|
||||
Professional world seed protection plugin for Paper, Purpur, and Folia 26.1.2+.
|
||||
Blocks every known seed-cracking leak vector: packet-level hashed-seed spoofing,
|
||||
/seed and /locate command blocking (including /execute-wrapped bypass attempts),
|
||||
Eye of Ender triangulation, treasure/explorer map scrambling, End spike and End
|
||||
City fingerprinting, slime-chunk sampling, and non-punishing detection of
|
||||
structure-reconnaissance and command-probing patterns used by seed-cracking tools.
|
||||
|
||||
commands:
|
||||
asc:
|
||||
aliases: [antiseedcracker]
|
||||
description: AntiSeedCracker administration command.
|
||||
usage: /asc <reload|status|stats|info>
|
||||
permission: antiseedcracker.admin
|
||||
usage: /asc <reload|toggle|status|stats|info|help>
|
||||
|
||||
permissions:
|
||||
antiseedcracker.admin:
|
||||
description: Access to AntiSeedCracker administration commands.
|
||||
description: Full access to all AntiSeedCracker administration commands.
|
||||
default: op
|
||||
children:
|
||||
antiseedcracker.admin.status: true
|
||||
antiseedcracker.admin.manage: true
|
||||
antiseedcracker.admin.status:
|
||||
description: View-only access — /asc status, /asc stats, /asc info, /asc help.
|
||||
default: op
|
||||
antiseedcracker.admin.manage:
|
||||
description: Access to /asc reload and /asc toggle (changes live config).
|
||||
default: op
|
||||
Reference in new issue
Block a user