Skip to main content

Writing a module

A module is a feature of GrayCore that can be switched on and off (kits and warps are modules, in modules/; the RPG pieces will be). It can live inside GrayCore (a sub-module of modules/) or in another plugin that depends on it: both go through the same API. The classes a module may rely on are the ones marked @StableApi (see The stable API).

The pieces​

PieceWhat it is for
GCModuleThe lifecycle of the feature: init(context), enable(), disable(), optional reload(), dependencies() and adminPermissions() (the permission nodes its commands check, given to the admin group when it is created).
ModuleContextWhat the module is given: its folder, its config.yml and its own texts (locale).
DataKey<T>A named, typed slot in the players' profiles (rpg.stats).
@GCData + SerializableDataHow a value is stored.
EventsPlayerPostLoadEvent, PlayerPreSaveEvent, PlayerUnloadEvent (see below).
GrayCoreApiregisterModule, registerDataKey, getPlayerData...

A module​

public class RpgModule implements GCModule {

public static final DataKey<RpgStats> STATS = DataKey.of("rpg.stats", RpgStats.class);

private final GrayCoreApi api = GrayCorePaperApi.getInstance();
private final RpgListener listener = new RpgListener();

private ModuleContext context;

@Override public String id() { return "rpg"; } // folder name: modules/rpg/
@Override public Set<String> dependencies() { return Set.of(); } // ids of modules that must run first

@Override
public void init(ModuleContext context) { // once, before enable(): folder, config, texts
this.context = context;
}

@Override
public void enable() {
this.api.registerDataKey(STATS); // before anything reads or writes it
Bukkit.getPluginManager().registerEvents(this.listener, plugin);
}

@Override
public void disable() { // undo everything enable() did
HandlerList.unregisterAll(this.listener);
}
}

There are two ways to ship a module:

  • Inside GrayCore: add a Maven sub-module under modules/ (see modules/kits), list it in paper-dist/pom.xml and name the class in META-INF/services/eu.grayroot.graycore.common.module.GCModule. The core finds it at startup with ServiceLoader, so the class needs a public no-argument constructor. The module ends up in graycore-paper.jar; the server owner turns it on or off with enabled in its config.yml.
  • In another plugin that depends on GrayCore: call GrayCorePaperApi.getInstance().registerModule(new RpgModule()); from its onEnable.

Administrators can also use /gcadmin modules, /gcadmin modules enable|disable <id>.

The module's folder​

Each module owns a folder in GrayCore's data folder, created on first run:

plugins/GrayCorePaper/modules/rpg/
config.yml enabled: true, plus the module's own settings
locale/en.yml the module's own texts
  • enabled in config.yml decides whether the module runs. The file is written once, with the module's enabledByDefault(), and then belongs to the server owner: GrayCore never overwrites it. A value that is not true or false keeps the module off and is logged.
  • Defaults shipped in your jar. Put modules/<id>/config.yml (settings only, enabled is added for you) and modules/<id>/locale/en.yml in your resources: they are copied on first run, and the bundled texts are used for any key the server owner removed. A module with no texts gets an empty file.
  • Read them through the context: context.config().getString("playerDataKey", "kit"), context.locale().
  • What a config can hold. Plain values (getString, getInt, getLong, getDouble, getBoolean), lists of texts (getStringList) and sections. section("limits") gives a ConfigSection with the same getters, plus keys() and contains(...). bind("reward", Reward.class) reads a whole section into an object (a class with a no-argument constructor and public fields or setters); the class's own values are the defaults, and an unknown setting or a wrong type is an error naming the file and the section. A missing setting gets your default, a wrong one is an error.
  • /gcadmin reload re-reads every module's config and texts, starts a module that was switched on in its file and stops one that was switched off. A file that is broken at that moment keeps the previous settings.
  • Module ids are folder names: lowercase letters, digits, - and _ (32 characters at most).

Rules a module must follow:

  • disable() undoes everything enable() did (commands, listeners, tasks) and must not throw. It is also called after a failed enable(), so it has to cope with a half set-up module.
  • A failing module does not take the others down (a folder or config that cannot be prepared included): it is marked FAILED, and the modules that need it are BLOCKED.
  • Disabling never deletes data. What a module stored stays in the profiles, untouched, until the module runs again.

Player data​

public static final DataKey<RpgStats> STATS = DataKey.of("rpg.stats", RpgStats.class);

GCPlayer player = ...; // from PlayerPostLoadEvent, or api.getPlayer(uuid)
Optional<RpgStats> stats = player.find(STATS); // never creates anything
RpgStats mine = player.getOrCreate(STATS, RpgStats::new); // created, and stored from the next save
mine.level++; // mutate the object, it is what gets stored

The value class is annotated and has a stored form:

@GCData(identifier = "RPG_STATS", type = RpgStatsData.class, version = 1)
public class RpgStats { public int level; }

public class RpgStatsData implements SerializableData<RpgStats> {
public int level;
public RpgStatsData() {} // for deserialization
public RpgStatsData(RpgStats domain) { this.level = domain.level; }
@Override public RpgStats toDomain(UUID playerId) { var s = new RpgStats(); s.level = this.level; return s; }
}
  • Key ids are lowercase letters, digits, ., -, _. Prefix them with your module name. They are written in the profiles: never rename one once data exists. Two different types cannot share an id (registration fails).
  • Evolving the stored form. Adding a field that defaults to "off" needs nothing. If the meaning of a field changes, raise @GCData(version): a server running older code then leaves data of the newer version untouched instead of misreading it, which makes a rollback safe.
  • Do not store big, fast-growing or frequently queried data in a profile: the whole profile is written when anything in it changed. Incremental counters belong in the vault (GCVaultBucket), which are atomic in the database.
  • A profile that did not change since its last successful write is not written again.

Events​

All GrayCore events are fired synchronously on the main thread: use the Bukkit API directly, and do not block (no database or network call).

  • PlayerPostLoadEvent: the profile is loaded and the player is in the world. Set up per-player state here.
  • PlayerPreSaveEvent: the profile is about to be serialized (autosave and quit). Last chance to put state into the data that gets stored.
  • PlayerUnloadEvent: the profile is leaving this server's memory (the final save is queued). Clean up per-player state; do not change the profile.

When a player switches servers, their profile may be loaded a fraction of a second after they appear (the previous server is still saving it). They cannot act until it is there, and PlayerPostLoadEvent is fired once it is.

Offering an API to other plugins​

A module that other code may want to use (a list of kits, the cooldown of a player...) publishes an interface and registers it while it runs:

// in the module: an interface, and its implementation (see modules/kits: KitsApi, KitsService)
@Override public void enable() { api.registerService(KitsApi.class, this.service); }
@Override public void disable() { api.unregisterService(KitsApi.class, this.service); }

Another plugin finds it with GrayCorePaperApi.getInstance().getService(KitsApi.class) (an Optional) or, the Bukkit way, Bukkit.getServicesManager().load(KitsApi.class) (null). Both are empty while the module is switched off or not installed, so the caller must handle that. The interface lives next to the module's code in graycore-paper.jar: depend on that jar with provided scope. Built in: KitsApi (eu.grayroot.graycore.modules.kits) and WarpsApi (eu.grayroot.graycore.modules.warps).