MCME Wiki

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:

  1. If any configured external deny handler says yes, the action is refused (this is checked first, before the bypass permission).
  2. The action is allowed if the player has thegaffer.ignoreprotection, the world is listed in unprotectedworlds, or an external allow handler says yes.
  3. 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)

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