Writing a plugin against the cloud¶
What a Paper or Velocity plugin can ask the cloud, with the same calls on both.
Here is the whole of a plugin that reads the network. Three files, and the
only Spawnery-specific parts are the compileOnly line, the dependency on the
agent, and the calls themselves.
dependencies {
compileOnly("cloud.spawnery:spawnery-api:0.34.0")
}
name: SlotBoard
version: '1.0.0'
main: com.example.slotboard.SlotBoardPlugin
api-version: '26.3'
dependencies:
server:
SpawneryAgent:
load: BEFORE
required: true
package com.example.slotboard;
import cloud.spawnery.agent.api.ServerInfo;
import cloud.spawnery.agent.api.Spawnery;
import cloud.spawnery.agent.api.SpawneryApi;
import org.bukkit.plugin.java.JavaPlugin;
public final class SlotBoardPlugin extends JavaPlugin {
@Override
public void onEnable() {
if (!Spawnery.isAvailable()) {
getLogger().warning("no Spawnery agent on this server");
return;
}
SpawneryApi api = Spawnery.api();
for (ServerInfo s : api.servers()) {
getLogger().info(s.name() + " has " + s.freeSlots() + " free slots");
}
}
}
The name to depend on is the agent plugin's, and it differs by platform.
On Paper it is SpawneryAgent; on Velocity the plugin id is spawnery, so
the same dependency is an annotation argument:
@Plugin(id = "slotboard", name = "SlotBoard", version = "1.0.0",
dependencies = @Dependency(id = "spawnery"))
Either way it is not optional. Spawnery.api() throws
SpawneryUnavailableException if you call it before the agent has enabled,
and the message says which of the two causes it was: the agent is missing, or
it has not finished enabling.
Depending on it¶
compileOnly, always. The classes are loaded from the running agent
plugin, and a plugin that bundles its own copy puts a second
cloud.spawnery.agent.api.SpawneryApi on the server: a different type with
the same name, so the cast at your first call fails with a message about two
classes that look identical.
It is on Maven Central, so nothing has to be configured to resolve it. The version is the one the agent inside the game images carries: the same number, because they are built from the same source, and a plugin compiled against one runs against the other.
Compile against the oldest version you mean to support, not the newest.
Methods are added to SpawneryApi and components are added to the value
records; a plugin built against 0.2.20 runs on a later agent, while one built
against a later agent and run on 0.2.20 meets a NoSuchMethodError at the
first call the older jar does not have.
What it can see¶
Everything is scoped to the pod's own namespace, which is one Network. There
is no call that reaches another network. That limit is structural: the agent's
own credentials are a pod-bound ServiceAccount token, so there is nothing to
widen.
A backend's mirror leaves out the private servers of an on-demand group, and
the group itself. A plugin on a proxy sees them; one on a backend does not,
because routing lives on the proxies and a backend would otherwise carry an
entry for servers nobody sends anyone to.
Private servers says what that
changes for connect.
Reads need no permission, and there is no way to require one: Bukkit and Velocity attach permissions to a player or the console, never to a plugin, so a plugin calling this API presents no identity to check. A plugin already reads the platform's own player list; what this adds is the rest of the network. The boundary is who may install a plugin on the server, which is the same person who may create a pod in the namespace.
/cloud is different and does check a
permission, because a command has somebody running it.
What is a value and what is a moment¶
ServerInfo, ProxyInfo, Group and CloudPlayer are records describing
what the operator last said. They do not update. Ask again for a newer one.
proxies() and proxy(name) read the network's proxies the way servers()
and server(name) read its servers, and ServerInfo.held() says whether an
admin took a server's retirement back.
Reads never block, never time out, and throw nothing: the operator keeps a
mirror current inside the agent, so servers() is a lookup in a local map.
How current the mirror is depends on what changed. A proxy is sent a fresh picture when a server is created, changes phase, gets its pod, is placed on a node, becomes unschedulable or goes away, at most once a second per namespace. Player counts and announcements are not part of that: they arrive with the 30 second resync, and a backend's mirror is refreshed by the resync alone.
CloudPlayer.server() is empty for a player the proxy has and no backend does
(during login, and between one backend and the next). That is ordinary, and it
is exactly the player a drain is about.
A player who changed proxies¶
On a proxy group with spec.update.transfer, a leaving proxy sends its players
to another proxy of the group. Every plugin on both proxies sees an ordinary
quit and an ordinary login, so a plugin that tells friends someone went offline,
or greets a player who joins the network, does both for a player who never
left. Two reads tell those apart, answered locally like every other read:
leavingByTransfer(uuid)is true on the old proxy once it has sent the player away, up to and including every listener of theirDisconnectEvent.arrivedByTransfer(uuid)is true on the new proxy for a player whose transfer cookie the agent verified. It is known fromLoginEventon, for a listener that runs after the agent's, and stays true until they disconnect.
Both are false on a server. arrivedByTransfer does not trust the handshake:
any client can say it was transferred, and only the signed cookie counts, so a
capacity or maintenance check may rely on it. leavingByTransfer says the
player was sent, not that they arrived. A plugin that keeps a party or a
session alive for them should still let it lapse when they do not turn up on
another proxy within a few seconds.
Agents older than 0.19.0 do not have either method, and calling one there
throws a LinkageError.
Version skew¶
ServerPhase and Group.Kind both carry UNKNOWN, and the operator is free
to publish a value your copy of this jar predates. Handle it. A switch that
throws on an unrecognised phase breaks on an operator upgrade that had nothing
to do with your plugin. Read a phase with ServerPhase.fromWire, which never
throws, rather than valueOf, which does.
The value records gain components as the operator learns to say more, and
ServerInfo has gained several. Read them through their accessors, which is
what they are for; a plugin that constructs a ServerInfo of its own (in a test
double, say) is the one thing that has to be rebuilt when they do.
What a plugin can do covers the calls that change something: moving a player, retiring a server, boosting a group, starting and stopping a private server, closing a door, describing a round, and reading the event feed. Every type and method is in the Javadoc.