Imported from CodeW4VE/WorldEaterNotifier (
AGENTS.md). Install upstream withnpx skills add CodeW4VE/WorldEaterNotifier. Copyright stays with the author.
WorldEaterNotifier
Fabric mod (Minecraft 1.21.11, server-side only) that monitors world eaters, trenchers, and bedrock breakers and sends Discord notifications — via webhook or a JDA bot — with per-event ping control when a machine starts, gets stuck/obstructed, resumes, is stopped, or the server shuts down.
Working with Claude Code? See CLAUDE.md. It points to this file for context and to
PLAN.md(local-only, gitignored) for the current task tracker.
Tech Stack
- Language: Java 21
- Loader/API: Fabric Loader 0.18.1, Fabric API 0.141.4+1.21.11
- Build: Gradle + Fabric Loom 1.14.10
- Mappings: Yarn 1.21.11+build.4
- Mixin:
ExplosionMixintargetsExplosionImpl.destroyBlocks - Dependencies:
- Fabric API + Java stdlib (
java.net.http.HttpClientfor webhooks, Gson for config and webhook JSON payloads). - JDA 5.2.1 (bot mode) — shaded into the output jar via the
shadeconfiguration andremapJar(seebuild.gradle), withopus-javaexcluded. No separate mod install is required for bot mode.
- Fabric API + Java stdlib (
- Java package:
com.example.worldeaternotifier(note: Maven group iscom.worldeaternotifier— they intentionally differ).
Build & Run
./gradlew clean build # -> build/libs/worldeaternotifier-<version>.jar (JDA shaded in)
There are no automated tests. Verify behavior by loading the jar on a dev server and exercising commands in-game and in Discord (see "Verifying changes" below).
Project Structure
src/main/java/com/example/worldeaternotifier/
├── WorldEaterNotifierMod.java # ModInitializer entrypoint; loads config, wires managers, registers events/commands
├── common/
│ ├── BaseMachineDefinition.java # Immutable record: name, inclusive AABB coords, dimension
│ ├── BaseMachineInstance.java # Runtime state: active, lastActivityTick, stuckAlertSent, detectionType
│ ├── MachineManager.java # Generic per-type manager (ConcurrentHashMap<String, BaseMachineInstance> + ModConfig)
│ ├── MachineRegistry.java # The 3 MachineManager instances (WORLD_EATER/TRENCHER/BEDROCK_BREAKER), keyed by type string
│ ├── MachineCommand.java # Generic Brigadier command tree, parameterized per machine type
│ ├── DiscordNotifier.java # Outbound notifications (webhook via HttpClient, or delegate to bot)
│ ├── ExplosionBlockCallback.java # Fabric event carrying the destroyed-block list to listeners
│ └── PermissionManager.java # Op / whitelist authorization gate for in-game commands
├── config/
│ └── ModConfig.java # Gson JSON config (load/save under config/worldeaternotifier.json)
├── bot/
│ └── DiscordBotManager.java # JDA lifecycle, pending buffer, slash commands, buttons/selects
├── monitor/
│ └── MonitorCheckHandler.java # Per-tick + explosion-callback detection logic
└── mixin/
└── ExplosionMixin.java # Captures pre/post block state around explosions
Architecture
Three machine types
WorldEater, Trencher and BedrockBreaker are all instances of the same generic classes,
not separate hierarchies: MachineManager (in-memory ConcurrentHashMap<String, BaseMachineInstance> + a ModConfig reference, parameterized by a saved-list accessor
and a settings accessor) and MachineCommand (one Brigadier tree, parameterized by
machine type, display name, and which optional settings/args apply — hasDetectionTypeArg
for Trencher's quarry-like/2-way create arg, hasMinTntCount, hasMinBlocksBroken).
MachineRegistry holds the three MachineManager instances (WORLD_EATER, TRENCHER,
BEDROCK_BREAKER) keyed by the type string used everywhere else (Discord, config,
notifications). WorldEaterNotifierMod constructs the three MachineCommand instances
and registers them. All three share BaseMachineDefinition, BaseMachineInstance, and
ModConfig.MachineSettings (one settings shape for all types — a couple of fields go
unused per type, e.g. BedrockBreaker ignores minTntCount).
| Type | Detection | Config settings key |
|---|---|---|
| WorldEater | Lit TNT entity count in AABB | worldEaterSettings |
| Trencher | Blocks destroyed by explosion in AABB (quarry-like), or TNT count (2-way) |
trencherSettings |
| BedrockBreaker | Explosion-block detection (same as quarry-like trencher) | bedrockBreakerSettings |
Detection mechanism
- TNT-based (WorldEater, and
2-wayTrencher): every second (CHECK_INTERVAL_TICKS = 20),MonitorCheckHandler.onWorldTickscans each active machine's AABB withworld.getEntitiesByType(EntityType.TNT, box, ...). If the count ≥minTntCount,instance.updateLastActivityTick(currentTick). - Block-break-based (
quarry-likeTrencher, BedrockBreaker):ExplosionMixinhooksExplosionImpl.destroyBlocks— captures block states at HEAD, and at TAIL filters to blocks that were non-air/non-TNT and are now air, firingExplosionBlockCallback.EVENTwith the destroyed positions.MonitorCheckHandler.onExplosionBlocksDestroyedcounts those inside each active machine's AABB; if ≥minBlocksBroken, updates activity tick. - Stuck detection:
checkStuck()runs each tick per active machine. IfcurrentTick - lastActivityTick > stopTimeout * 20and no stuck alert was sent yet, it sends a "stuck" notification and marks the flag. When activity resumes,updateLastActivityTickclears the flag and sends a "resumed" notification.
State persistence
- Config at
config/worldeaternotifier.json(Gson, pretty-printed). - Machine definitions + last active state persist under
worldEaters,trenchers,bedrockBreakersarrays.ModConfig.load()backfills nulls and clamps invalid numeric settings to defaults. - On server start (
onInitialize), machines load inactive; they must be/started. - On
SERVER_STOPPING, active machines get a shutdown notification, are stopped, config is saved withactive: false, and the bot is shut down.
Notification modes (config.notificationMode)
| Mode | Delivery | Configured with |
|---|---|---|
webhook (default) |
HTTPS POST to a Discord webhook URL | setWebhookUrl, setPingRoleId |
bot |
JDA bot: "Toggle Ping" button + slash commands | setBotToken, setGuildId, setChannelId, setPingRoleId |
DiscordNotifiermethods:sendStart,sendStuck,sendResumed,sendManuallyStopped,sendServerShutdown. Ping prefix built bybuildMentionIfAllowedfrompingRoleId+ per-event toggles.- Start guard:
executeStart()callsisDeliveryConfigured()and refuses to start if the current mode's requirements are unmet (webhook → non-blankwebhookUrl; bot →botToken+guildId+channelId). - Dynamic command visibility: settings subcommands use Brigadier
.requires()predicates onnotificationModeso only the relevant ones are tab-completable per mode.
Bot mode (DiscordBotManager)
Singleton using JDA 5.2.1 with the GUILD_MEMBERS intent.
- Startup:
JDABuilder.createDefault(token).build()returns immediately; notifications queue (max 50) until the WebSocket isCONNECTED, flushed onReadyEvent. - Shutdown/restart:
jda.shutdown()on server stop or mode switch;setBotTokentriggersrestart(token). Guild/channel are read from config at send time — no restart needed for those. - Buttons: a single "Toggle Ping" button (
wen:toggle:<type>:<name>) lets a Discord user self-add/removepingRoleId; reply is ephemeral. Shown on start messages only whenshowSubscriptionButtonis true. - Slash commands:
/config subscription-button|ping-role|channel|pings|member-discord-role(Administrator only), and/worldeater|/trencher|/bedrockbreaker start|stop|list(gated by Administrator ORmemberDiscordRole).start/stopautocomplete existing names, and the autocomplete handler itself is access-gated (see invariants below). /config pingsflow: select machine type → embed of current settings → select a setting → True/False buttons, looping back to the picker.- Clearing
memberDiscordRole:/config member-discord-roletakes an optional role option — omit it to clear the field (falls back to admin-only access). In-game,settings setMemberDiscordRole none|clear|0clears it the same way. - Role-deletion resilience: a
RoleDeleteEventlistener watches for the configuredmemberDiscordRoleorpingRoleIdbeing deleted in Discord. If either is deleted, the corresponding config field is cleared and saved automatically, and an in-game broadcast (⚠ ...) explains what happened — this avoids silent, permanent lockouts from a stale role reference.
Configurable messages & pings
- Each machine type has a
messagesblock (MessageTemplates:start,stuck,resumed,manualStop,shutdown) with{type}/{name}placeholders. Resolved byDiscordNotifier.templatesFor(machineType). - Each type has
PingSettings(enabled,onStart,onStop,onStuck,onResumed,onShutdown), edited via in-gamediscordPingscommands or/config pings. All persist to JSON.
Authorization & security invariants
Keep these intact — they were established by a security hardening pass; regressing them re-introduces known vulnerabilities.
- In-game command gate (
PermissionManager): op = permission level GAMEMASTERS/2+; non-op players must be in the sharedwhitelist.create/start/stop/list/delete/settings show/discordPings/setStopTimeout/setMinTntCount/setMinBlocksBroken: op OR whitelisted — these only tune detection behavior for a machine the whitelisted player is already allowed to operate; none of them touch secrets or delivery config.- Secret-, delivery-, or bot-behavior-mutating settings are op-only:
setWebhookUrl,setBotToken,setGuildId,setChannelId,setNotificationMode,setMemberDiscordRole,setPingRoleId,showSubscriptionButton. Enforced via.requires(... && PermissionManager.isOp(s))(composed with the mode predicate where present).showSubscriptionButtonis included here even though it isn't a secret, because it's a global Discord-message behavior toggle (affects every notification, for every user), not a per-machine tuning knob — same tier assetNotificationMode. whitelist add/remove: op-only.
- Secret masking:
settings showmasks both the bot token and the webhook URL (maskToken). Do not print either in cleartext. - Webhook URL validation (anti-SSRF):
setWebhookUrlaccepts onlyhttpsURLs whose host isdiscord.com/discordapp.com(or a subdomain). Reject anything else. - Mention safety: outbound messages restrict mentions to roles only — webhook payloads
include
allowed_mentions: {parse: ["roles"]}(built with Gson, not string concat), and JDA sends usesetAllowedMentions(EnumSet.of(Message.MentionType.ROLE)). This prevents@everyone/@here/user-mention abuse from templates or names. - Discord interaction handlers that mutate settings (
/config pingsbuttons/selects) re-checkPermission.ADMINISTRATOR; don't rely on the entry point being ephemeral. - Discord autocomplete is access-gated:
onCommandAutoCompleteInteractioncallshasAccess(member)(admin ORmemberDiscordRole) before returning machine-name suggestions forstart/stop, and replies with an empty list otherwise — a user who can't run the command can't enumerate machine names through autocomplete either. memberDiscordRole/pingRoleIddon't go stale silently: deleting either role in Discord auto-clears the corresponding config field (via aRoleDeleteEventlistener) and broadcasts an in-game warning, instead of leaving a dangling ID that silently denies everyone. Both fields also support an explicit clear path (see "Bot mode" above).
A full in-game + Discord permission audit (root gate, per-subcommand gating, autocomplete,
memberDiscordRole end-to-end) found no open items — see PLAN.md for the detailed
matrices. Separately, some lower-priority hardening from the original security pass is
still open: syncCommandTree advertises the full command tree to every client
(info-disclosure only — server-side .requires() still blocks execution), secrets are
stored in plaintext at rest, and ModConfig mutation/save() isn't locked across the
server tick thread and JDA callback threads.
In-game commands
/worldeater, /trencher, /bedrockbreaker share this structure (trencher adds a
<type> arg on create; TNT/blocks settings differ per type):
<command> create <name> [<type>] <x1> <y1> <z1> <x2> <y2> <z2>
<command> start|stop|delete <name>
<command> list
<command> settings show
<command> settings setWebhookUrl <url> # webhook mode, op-only
<command> settings setBotToken|setGuildId|setChannelId <v> # bot mode, op-only
<command> settings setMemberDiscordRole <roleId|none|clear|0> # bot mode, op-only; keyword clears it
<command> settings setNotificationMode <webhook|bot> # op-only
<command> settings setPingRoleId <roleId> # op-only
<command> settings setStopTimeout <seconds>
<command> settings setMinTntCount <count> # /worldeater (+ /trencher for 2-way)
<command> settings setMinBlocksBroken <count> # /trencher, /bedrockbreaker
<command> settings showSubscriptionButton <bool> # bot mode, op-only
<command> settings discordPings show|enable|onStart|onStop|onStuck|onResumed|onShutdown
<command> settings whitelist list|add|remove # add/remove op-only
The whitelist is shared across all three commands.
Key Conventions
- Managers are
MachineManagerinstances held inMachineRegistry(WORLD_EATER,TRENCHER,BEDROCK_BREAKER), each holding aModConfigreference. - Commands are
MachineCommandinstances, one per type, constructed inWorldEaterNotifierModand registered viaregister(dispatcher, registryAccess, environment). - Machine types are strings:
"WorldEater","Trencher","BedrockBreaker"— used asMachineRegistrykeys and passed through toDiscordNotifier/BaseMachineInstance. - Adding a machine type = a new
MachineManager/MachineCommandpair wired intoMachineRegistry+WorldEaterNotifierMod, a settings section inModConfig, and detection logic inMonitorCheckHandler. - No dependency injection — manual wiring throughout,
MachineRegistryis the one shared registry. - Coordinates form an inclusive AABB using
Math.min/maxof the two corners. - Server-side only (
"environment": "server"infabric.mod.json). - When architecture or conventions change, update this file; when task status changes,
update
PLAN.md.
Commit and comment rules for AI agents
- Never add AI/session/model attribution to a commit. No
Co-Authored-By: Claude(or any other assistant) trailer, noClaude-Session:/session-link trailer, no mention of the model or tool used anywhere in the subject or body. Commit messages describe the change, not who or what wrote it. If a commit template or tool default appends this automatically, strip it before committing. - No unrequested comments. Don't add class/method doc comments that just restate the
name (
/** Manages the thing */aboveclass ThingManager), and don't leave prose explaining a design decision, a session's reasoning, or "why this file exists" in the code. If a decision needs explaining, put it in the commit message orPLAN.md, not a comment block. A comment is only worth adding for a genuinely non-obvious runtime constraint (an API quirk, a platform version gotcha, a workaround for a specific bug) — one or two lines, not a paragraph.