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
| Piece | What it is for |
|---|---|
GCModule | The 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). |
ModuleContext | What 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 + SerializableData | How a value is stored. |
| Events | PlayerPostLoadEvent, PlayerPreSaveEvent, PlayerUnloadEvent (see below). |
GrayCoreApi | registerModule, 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/(seemodules/kits), list it inpaper-dist/pom.xmland name the class inMETA-INF/services/eu.grayroot.graycore.common.module.GCModule. The core finds it at startup withServiceLoader, so the class needs a public no-argument constructor. The module ends up ingraycore-paper.jar; the server owner turns it on or off withenabledin itsconfig.yml. - In another plugin that depends on GrayCore: call
GrayCorePaperApi.getInstance().registerModule(new RpgModule());from itsonEnable.
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
enabledinconfig.ymldecides whether the module runs. The file is written once, with the module'senabledByDefault(), and then belongs to the server owner: GrayCore never overwrites it. A value that is nottrueorfalsekeeps the module off and is logged.- Defaults shipped in your jar. Put
modules/<id>/config.yml(settings only,enabledis added for you) andmodules/<id>/locale/en.ymlin 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 aConfigSectionwith the same getters, pluskeys()andcontains(...).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 reloadre-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 everythingenable()did (commands, listeners, tasks) and must not throw. It is also called after a failedenable(), 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 areBLOCKED. - 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).