Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,39 @@ class MyPluginIT
- Server directory access (`server().directory()`, `server().pluginDataDirectory(name)`) for seeding files
while the server is stopped

### Full-login bots

By default, `bots().join(...)` and `bots().builder()...build()` spawn a synthetic player through a fast
internal path that fires `PlayerJoinEvent` but skips the login handshake. For tests that need a *real* login,
call `.fullLogin()` on the builder:

```java
final PlayerHandle bot = framework.bots().builder()
.withName("realbot")
.atSpawn(framework.worlds().main())
.withLocale("en_us") // optional client locale (LK-12), sent during the configuration phase
.fullLogin()
.build();
```

A full-login bot connects over a real loopback TCP connection and drives the entire vanilla login pipeline
(handshake → login → the 1.20.2+ configuration phase → play), so it behaves like a genuine client. This differs
from the default spawn in three ways:

- **Real login events fire.** `AsyncPlayerPreLoginEvent`, `PlayerLoginEvent`, and `PlayerJoinEvent` all fire, in
each platform's own order. This is what makes permission plugins such as LuckPerms (which load a user at
pre-login and inject their `Permissible` at login) behave correctly for the bot.
- **The offline UUID is server-derived.** The server derives the bot's UUID from its name
(`UUID.nameUUIDFromBytes("OfflinePlayer:<name>")`); any UUID passed to the builder is ignored under
`fullLogin()`. The returned handle carries the server-assigned UUID.
- **The join can be denied.** Because it is a genuine login, a full-login bot is subject to the whitelist, bans,
the max-player limit, and plugin denials. A denial is surfaced as a `BotJoinDeniedException` (carrying the
server's kick reason); a login that does not complete in time throws a `BotJoinTimeoutException`. The call
blocks until the bot has fully joined (or is denied/times out).

Full-login joins require the server to run in offline mode with proxy forwarding off (both are the provisioner's
defaults).

## World and Plugin Provisioning

`prepare-server` supports custom worlds and plugins in plugin configuration:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,22 @@ final class AgentMainThreadExecutor
}

/**
* Submits the callable to the Bukkit main thread and waits for completion.
* Returns the configured maximum wait, in seconds, for a scheduled synchronous server operation.
*
* <p>Reused by long-running orchestration (such as awaiting a full-login {@code PlayerJoinEvent}) so a
* single, configurable bound governs how long the agent blocks before reporting a timeout to the client.
*
* @return
* Positive timeout in seconds.
*/
long syncOperationTimeoutSeconds()
{
return syncOperationTimeoutSeconds;
}

/**
* Submits the callable to the Bukkit main thread and waits for completion, bounded by the configured
* sync-operation timeout.
*
* @param callable
* Operation to execute synchronously on the server thread.
Expand All @@ -80,14 +95,41 @@ final class AgentMainThreadExecutor
<T> T callOnMainThread(Callable<T> callable)
throws Exception
{
return callOnMainThread(callable, syncOperationTimeoutSeconds);
}

/**
* Submits the callable to the Bukkit main thread and waits for completion, bounded by an explicit timeout.
*
* <p>Used where a caller must stay inside an outer deadline (such as the full-login listener cleanup)
* instead of drawing a fresh full sync-operation budget.
*
* @param callable
* Operation to execute synchronously on the server thread.
* @param timeoutSeconds
* Maximum time to wait for the operation, in seconds; must be positive.
* @param <T>
* Callable return type.
* @return
* Result returned by the callable.
* @throws Exception
* Propagates the callable's own failure (unwrapped from {@link ExecutionException}); throws
* {@link AgentProtocolException} with {@link AgentErrorCode#TIMEOUT} when the operation exceeds the
* given timeout, or {@link AgentErrorCode#INTERRUPTED} when the waiting thread is interrupted.
*/
<T> T callOnMainThread(Callable<T> callable, long timeoutSeconds)
throws Exception
{
if (timeoutSeconds <= 0L)
throw new IllegalArgumentException("timeoutSeconds must be > 0 but was " + timeoutSeconds + ".");
if (Bukkit.isPrimaryThread())
return callable.call();

final Future<T> future = Bukkit.getScheduler().callSyncMethod(plugin, callable);
final Throwable callableFailure;
try
{
return future.get(syncOperationTimeoutSeconds, TimeUnit.SECONDS);
return future.get(timeoutSeconds, TimeUnit.SECONDS);
}
catch (ExecutionException exception)
{
Expand All @@ -101,7 +143,7 @@ <T> T callOnMainThread(Callable<T> callable)
future.cancel(true);
throw new AgentProtocolException(
AgentErrorCode.TIMEOUT,
"Server operation did not complete within %d seconds.".formatted(syncOperationTimeoutSeconds),
"Server operation did not complete within %d seconds.".formatted(timeoutSeconds),
exception
);
}
Expand Down
Loading