TheGaffer is MCME's build-protection plugin for the build map. The map is read-only by default: nobody may place or break a block unless TheGaffer allows it. Staff open a job, a bounded square area in one world where invited builders may build while the job runs; when the job ends, the area locks again. Since 3.0.0 TheGaffer also keeps jobs across restarts, counts what each builder places and breaks, groups jobs into projects, and ships a small Velocity proxy plugin so a builder can join a job running on any server. Other build tools, MCME-Architect in particular, ask TheGaffer whether a player may build at a spot. The code is on GitHub at MCME/TheGaffer; this page describes release 3.0.1 on master.
At a glance
| Repository | MCME/TheGaffer (GPL-3.0) |
| Release branch | master; work goes into development |
| Version described | 3.0.1 (tag v3.0.1) |
| Platform | Paper 26.2 backends (api-version: 1.19 in plugin.yml, built against paper-api 26.2.build.126-stable) and a Velocity 4 proxy (velocity-api 4.2.1-SNAPSHOT) |
| Build | Maven, three modules; build with JDK 25; bytecode targets Java 17 (core, Paper) and 21 (Velocity) |
| Depends on | Soft only: DiscordSRV, MCME-Connect, dynmap (all absent-safe) |
| Artifacts | TheGaffer-3.0.1.jar (backends), thegaffer-velocity-3.0.1.jar (proxy); Maven group com.mcmiddleearth |
How the code is organised
Three Maven modules under the parent com.mcmiddleearth:thegaffer-parent:
| Module | Artifact | What it holds |
|---|---|---|
thegaffer-core |
thegaffer-core |
Only the wire protocol the two sides share: Channels (mcme:gaffer), Subchannel, the message records (JobCreateMessage, JobDeleteMessage, JobSyncMessage), and the Permission enum. Shaded into both jars |
thegaffer-paper |
TheGaffer |
The backend plugin. Main class com.mcmiddleearth.thegaffer.TheGaffer |
thegaffer-velocity |
thegaffer-velocity |
The proxy plugin, id thegaffer, name TheGaffer-Proxy. Main class VelocityGafferPlugin |
Packages in thegaffer-paper (all under com.mcmiddleearth.thegaffer):
| Package | Job |
|---|---|
| (root) | TheGaffer (start-up, config, the static protection API), GafferResponses |
commands |
JobCommand (/job), JobCreationDialog (/createjob, a Paper Dialog), JobChatCommand, ProjectCommand; AdminCommands holds /jobadmin (a chat conversation) and the /job admin one-liners |
listeners |
ProtectionListener (place, break, hanging, interact), StatsListener, PlayerListener, JobEventListener (announcements), JobChatListener, CraftingListener |
storage |
Job, JobDatabase (running jobs, persistence), Project, ProjectDatabase, JobStats, JobStatsStorage, JobWarp |
utilities |
ProtectionUtil (the protection decision), StatsManager, StatsFeed, JobBorderManager, CleanupUtil, PluginMessenger, Msg (Adventure text helpers) |
events |
Bukkit events other plugins can listen to (see APIs and extension points) |
ext |
ExternalProtectionHandler, the config-driven hook for other plugins' allow/deny methods |
integrations |
JobDiscordAnnouncer (DiscordSRV) and JobMapIntegration (dynmap), each isolated so the plugin works without them |
How protection decides. Every place, break and protected interaction goes through ProtectionUtil.getBuildProtection(player, location), which returns a BuildProtection value with a message:
- If any configured external deny handler says yes, the action is refused (this is checked first, before the bypass permission).
- The action is allowed if the player has
thegaffer.ignoreprotection, the world is listed inunprotectedworlds, or an external allow handler says yes. - Otherwise the player must be working in a running job, in the job's world, inside its area, and the job must not be paused. Each failure has its own refusal (no job, wrong world, not in a job, out of bounds, paused).
Storage. Jobs, projects and statistics are YAML files in plugins/TheGaffer/: jobs/<name>.yml (written atomically), projects/<name>.yml, stats/<job>-<endTime>.yml for finished jobs, and stats/active/ for live snapshots that survive a crash. Jobs are held in memory with a dirty flag, flushed every 60 seconds (the file write off the main thread) and synchronously on disable. Identity is by UUID; the scoreboard glow teams are the only thing keyed by name, because Bukkit's team API is name-based.
Building and testing
export JAVA_HOME="/path/to/jdk-25"
mvn -B package # both jars
mvn -B test # the test suite (200 tests in 3.0.1)
- JDK 25 is required even though the output targets Java 17 and 21: paper-api 26.2 ships Java 25 class files, and an older
javacfails withcannot access org.bukkit.*. The poms use-source/-targetrather than--releaseso a newer compiler can read the newer API. - Both jars must shade
thegaffer-core. Paper and Velocity each load one jar and know nothing of the Maven reactor. Without the shade the plugin enables and then throwsNoClassDefFoundErrorthe first time a job starts. - Tests: JUnit 5 with MockBukkit (
mockbukkit-v26.24.116.1, which must match the paper-api build). Notable classes:ProtectionApiContractTest(pins the reflective API),JobPersistenceTest,ProtectionUtilTest, theStatsManager*Testfamily,JoinRoutingTestandVelocityPluginDescriptorTeston the proxy side. MockBukkit cannot render a Dialog, reach Discord, or readItemStacks back from YAML, so those paths are checked in game. - Version bumps touch all four poms and the
@Plugin(version = ...)annotation onVelocityGafferPlugin; the proxy'svelocity-plugin.jsonis generated from that annotation, andVelocityPluginDescriptorTestfails the build when the two disagree. - CI:
.github/workflows/build.yml("Build & Test") runsmvn -B -ntp clean verifyon pushes tomasteranddevelopmentand on pull requests, with JDK 25 (Temurin). On av*tag it checks the tag against the pom version and against a## [X.Y.Z] — YYYY-MM-DDsection inCHANGELOG.md, deploys the parent pom,thegaffer-core,TheGafferandthegaffer-velocityto the MCME Maven repository, and creates the GitHub release "TheGaffer X.Y.Z" with both jars and a SHA-256 for each. A manual run publishes whatever version the branch is at (that is how a-SNAPSHOTreaches the snapshots repository). A separate manual workflow,publish-release.yml, uploads the jars of an existing GitHub release.
Configuration
plugins/TheGaffer/config.yml (the bundled file is written on first start; the plugin never adds new keys to an existing file):
| Key | Default | Meaning |
|---|---|---|
general.debug |
false |
Verbose logging |
unprotectedworlds |
a list with one world | Worlds with no build protection at all |
jobDescription |
true |
Ask for a description when a job is created, and show it in announcements |
enableFlight |
true |
Restore flight when the plugin puts a player back into Survival on leaving a job area |
discord.channel |
job-general |
DiscordSRV channel name for job posts |
discord.emoji |
ringmcme |
Emoji decorating Discord posts; remove for none |
allowRolePing |
[Jobber] |
The only Discord roles a job announcement may ping, matched by exact role name (case-insensitive). Empty or removed means no ping |
glowing.enabled / workerColor / helperColor |
true / DARK_PURPLE / AQUA |
Member glow colours (per job, chosen at creation) |
showJobBorder |
true |
Allow the client-side outline of the job area (/job border) |
stats.activeIdleThresholdSeconds |
60 |
Largest gap between two block events that still counts as active build time |
proxyAnnouncesJobs |
false |
Set true on every backend when the proxy runs thegaffer-velocity. The backend then announces a job only to its own players and leaves the network-wide announcement to the proxy; otherwise every player sees it twice |
externalProtectionHandlers.<Plugin>.allow / .deny |
commented out | Let another plugin allow or deny building (see below) |
A key missing from an existing file falls back to the code default, which differs from the bundled file for two keys: jobDescription (code default false) and glowing.workerColor (LIGHT_PURPLE). The console command /job reloadConfig rereads the file.
Commands and permissions
| Command | Aliases | What it does | Permission |
|---|---|---|---|
/createjob |
/job create, /job start |
Opens the job creation Dialog: name, radius, and optionally description, kit, private, glow, Discord announcement, project. Duplicate names get a number (Gate_2) |
thegaffer.create |
/job check |
Lists running jobs (network-wide when the proxy plugin is installed) with join buttons | thegaffer.join |
|
/job join [job] |
Joins the only running job, or the named one; names are case-sensitive | thegaffer.join |
|
/job leave, /job mine |
Leaves your job; shows your job, role and whether it is paused | thegaffer.join |
|
/job info <job>, /job who <job>, /job warpto <job>, /job archive |
Job details, members, teleport to the job's warp, archived jobs | thegaffer.join |
|
/job border |
Toggles the client-side outline of the job area | thegaffer.join |
|
/job stats <job\|player>, /job leaderboard [placed\|broke\|active\|time] |
/job top |
Statistics; the leaderboard counts finished jobs only | thegaffer.join |
/job stats export [json] |
Writes a CSV export, or the leaderboard.json feed |
thegaffer.create |
|
/job stop <job>, /job pause <job>, /job unpause <job> |
Ends, suspends or resumes a job | thegaffer.create |
|
/job manage [job] |
Opens the management panel | thegaffer.create |
|
/job transfer <helper> |
Hands your job to one of its helpers | thegaffer.create, and job owner or thegaffer.project.admin |
|
/job listen, /job prep, /job debug |
Warnings when someone edits outside a job; store/restore your inventory; debug info | thegaffer.create |
|
/job teleportall, /job teleport <player> |
Summons all online workers, or one, to you | owner or helper of the running job | |
/jobadmin |
/job admin |
Guided admin conversation | thegaffer.create |
/job admin <job> <action> [player\|number] [confirm] |
One-liner admin actions: addhelper, removehelper, promote, demote, inviteworker, uninviteworker, kickworker, banworker, unbanworker, listworkers, setwarp, setradius, teleport, teleportall, clearworkerinven. teleportall and clearworkerinven need a trailing confirm |
thegaffer.create |
|
/jobchat [message] |
/jc |
Toggles job chat, or sends one message to your job | thegaffer.join, and being in a job |
/project create <name> |
/pj |
Creates a project; you become its lead | thegaffer.project.create or thegaffer.project.admin |
/project list [active\|completed\|archived], info <name> |
/pj |
Lists projects (active by default); totals, builders and jobs | none |
/project setdescription, setgoal, addmanager, removemanager, complete, archive, reopen, attach, detach, announce, export |
/pj |
Edits and status changes; attach moves a job and its statistics into the project | project lead or manager, or thegaffer.project.admin |
/project setlead, delete |
/pj |
Changes the lead; deletes the project | project lead or thegaffer.project.admin |
/job reloadConfig |
Rereads config.yml (console only) |
console | |
| Permission | Default | Grants | |
| --- | --- | --- | |
thegaffer.join |
true | Joining and using jobs, job chat, statistics. Must also be granted on the proxy: a bare Velocity proxy grants players nothing, so /job check and cross-server /job join fail there without it |
|
thegaffer.create |
op | Creating, running and administering jobs | |
thegaffer.ignoreprotection |
op | Building anywhere (still subject to external deny handlers) | |
thegaffer.project.create |
op | Creating and leading projects | |
thegaffer.project.admin |
op | Managing any project regardless of lead or managers; transferring any job |
APIs and extension points
The reflective protection API
Three public static methods on the main class com.mcmiddleearth.thegaffer.TheGaffer are a contract with other plugins, which find them by name through reflection rather than compiling against TheGaffer:
| Method | Returns | Meaning |
|---|---|---|
hasBuildPermission(Player, Location) |
boolean |
true when the protection decision above allows building there |
getBuildProtectionMessage(Player, Location) |
String |
The reason for a refusal, to show the player |
recordExternalBuild(Player, Location, boolean place) |
void |
Counts a block that another plugin placed (true) or broke (false) in code, as if a vanilla block event had happened. Does nothing if the player is not working a job or the spot is outside it. Call it on the main thread; an async call corrupts the live statistics |
ProtectionApiContractTest fails the build if any of the three changes name, parameters, return type, or stops being public static. The consumer side matters as much: MCME-Architect looks the methods up with getMethod(...) and fails open, so if TheGaffer is missing or a signature drifts, Architect allows building. Change the two repositories together.
A sketch of the caller's side, as Architect's TheGafferUtil does it:
Plugin gaffer = Bukkit.getPluginManager().getPlugin("TheGaffer");
if (gaffer != null) {
Method m = gaffer.getClass().getMethod("hasBuildPermission", Player.class, Location.class);
boolean allowed = (boolean) m.invoke(null, player, location);
}
External protection handlers
The reverse direction is configured, not coded. Under externalProtectionHandlers in config.yml, name a plugin and a method on that plugin's main class: a public static boolean name(Player, Location). An allow method returning true lets the player build; a deny method returning true refuses, and deny handlers are checked before everything else. A handler whose plugin is missing or disabled counts as "no opinion". The bundled config has none configured.
externalProtectionHandlers:
SomePlugin:
allow: canBuildHere
deny: mustNotBuildHere
Bukkit events
Other plugins can listen to these (package com.mcmiddleearth.thegaffer.events). They are informational; none is cancellable.
| Event | Fired when |
|---|---|
JobStartEvent, JobEndEvent (both extend JobEvent) |
A job starts or ends. Getters for the Job, its owner, name, warp, project and open state |
JobProtectionBlockPlaceEvent, JobProtectionBlockBreakEvent, JobProtectionHangingPlaceEvent, JobProtectionHangingBreakEvent, JobProtectionInteractEvent (extend JobProtectionEvent) |
After TheGaffer has decided on a protected action; isBlocked() says whether it refused. StatsListener counts blocks from these |
Statistics feed
/job stats export json writes leaderboard.json, a machine-readable feed (built by StatsFeed) meant for the website and infographics. /job stats export and /project export write CSV.
Adding an optional integration
Bukkit registers a listener class as a whole, so a class that names an optional plugin's types fails with NoClassDefFoundError (an Error, not caught by catch (Exception)) when that plugin is absent, and every handler in it is lost. Keep optional types in a nested class that loads only after an isPluginEnabled check, and catch Throwable at the boundary, as JobDiscordAnnouncer and JobMapIntegration do.
How it talks to other plugins and systems
flowchart LR
AR[MCME-Architect] -- "reflection: hasBuildPermission,<br/>getBuildProtectionMessage,<br/>recordExternalBuild" --> TG[TheGaffer backend]
TG -- "mcme:gaffer<br/>JOB_CREATED / JOB_DELETED / JOB_SYNC" --> TV[thegaffer-velocity on the proxy]
TV -- "moves player, runs /job join" --> TG2[TheGaffer on another backend]
TG -- "BungeeCord channel 'Message' to ALL<br/>(only without the proxy plugin)" --> PX[Velocity proxy]
TG -- "embeds" --> DS[DiscordSRV]
TG -- "marker set thegaffer.jobs" --> DM[dynmap / LiveAtlas]
| System | Direction | How | Notes |
|---|---|---|---|
| MCME-Architect | is called by | Reflection on the three static methods above | Architect checks build permission before its tools act, shows the refusal message, and reports blocks it sets in code through recordExternalBuild(..., true) so they count in job statistics. No Maven or plugin.yml link either way |
| thegaffer-velocity (proxy) | both | Plugin messages on mcme:gaffer, a UTF subchannel name followed by the message |
JOB_CREATED (a job started: the proxy registers it and announces it network-wide with a button that transfers the player), JOB_DELETED (a job ended), JOB_SYNC (sent a tick after a player joins a backend: restates running jobs silently, so the proxy's in-memory list survives restarts). A plugin message needs a player connection, so a backend with nobody online cannot update the proxy. The protocol is not versioned: run the same version on both sides |
| Velocity proxy, legacy relay | writes | The proxy's built-in BungeeCord channel, subchannel Message, target ALL |
Used for the job-start text only when proxyAnnouncesJobs is false and MCME-Connect is installed (its presence means "on a network"). The text is legacy formatted, so it carries no clickable button |
| DiscordSRV | writes | Plugin API, isolated in JobDiscordAnnouncer |
Job start and end announcements as embeds in discord.channel, pinging only roles in allowRolePing |
| dynmap (shown in LiveAtlas) | writes | Marker API, isolated in JobMapIntegration |
One marker per running job in the marker set thegaffer.jobs ("Jobs") |
| Other protection plugins | reads | externalProtectionHandlers (reflection) |
Optional allow/deny hooks; none configured by default |
The proxy plugin also greets a player on their first server connection with the running jobs, and intercepts /job check and /job join <name>. JoinRouting, a pure function with unit tests, makes the proxy step in only when the named job is on a different backend; local, unknown, differently-cased or ambiguous names fall through to the backend.
Further reading
- Readme and CHANGELOG on
master - Setting up, Contributing, Maven repository
- Architect, MCME-Connect
- For builders and job leaders: Working on jobs, Running jobs