mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
feat: add Adventure messages and asynchronous command completion
This commit is contained in:
1 parent
bbb4b5a831
commit
1185ae2b2e
34 files changed
+1480
-76
No files matched your search
@@ -5,6 +5,7 @@
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [架构与边界](architecture.md) | 模块职责、会话状态、线程模型、转服、目录所有权 |
|
||||
| [消息与命令补全](messages-and-completion.md) | 富文本、模板、异步参数补全及协议边界 |
|
||||
| [插件开发](plugins.md) | 生命周期、玩家/服务器视图、选服、命令与统一类型化事件 API |
|
||||
| [玩家操作与部署完善设计](player-operations.md) | 消息、主动断开、服务器列表配置与验收边界 |
|
||||
| [运行配置](operations.md) | 认证、接入限制、超时、启动和部署边界 |
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 消息构造与命令补全
|
||||
|
||||
## 目标
|
||||
|
||||
插件用统一组件表达消息,用简单回调执行命令和补全。代理负责协议 5 编码、连接身份、线程切换和有界资源。消息模板、权限、业务参数与候选过滤由插件决定。
|
||||
|
||||
## 富文本
|
||||
|
||||
API 导出 Adventure Component 与 MiniMessage。`Players.sendMessage`、`Players.disconnect`、`CommandInvocation.reply`、`AccessDecision.deny` 和 `PlacementDecision.reject` 接受 Component;String 重载表示纯文本,绝不自动解释标签。固定组件可提前构造并复用。
|
||||
|
||||
```java
|
||||
var message = Component.text("返回主城", NamedTextColor.GREEN)
|
||||
.hoverEvent(Component.text("点击返回主城"))
|
||||
.clickEvent(ClickEvent.runCommand("/spawn"));
|
||||
context.players().sendMessage(player.identity(), message);
|
||||
|
||||
var greeting = MiniMessage.miniMessage().deserialize(
|
||||
"<green>欢迎 <player>!</green>",
|
||||
Placeholder.unparsed("player", player.username()));
|
||||
invocation.reply(greeting);
|
||||
```
|
||||
|
||||
动态字符串通过 `Placeholder.unparsed` 插入;组件通过 `Placeholder.component` 插入。插件显式解析模板,解析与组件构造放在插件工作线程。普通后端消息仍沿用转发路径。
|
||||
|
||||
1.7.10 支持文字、翻译组件、16 色与文字样式、打开网址、执行命令、填入命令和悬浮文字。RGB 映射到最近的传统颜色;不支持的组件、样式或事件明确拒绝。当前不提供物品/实体悬浮适配。LOGIN/PLAY 使用相同 JSON 编码;踢出界面不保证聊天窗口的交互能力。
|
||||
|
||||
树深度最多 32、节点最多 256,编码后的 JSON 最多 32,767 UTF-8 字节;悬浮文本和翻译参数也计入树限制。纯文本快捷入口最多 1,024 个 Unicode 码点。`Players` 在调用线程验证、编码,再将编码结果提交玩家 EventLoop;不在消息写入时重复解析模板。既有背压、最多 64 条未完成消息和连接代次检查继续生效。
|
||||
|
||||
## 插件命令
|
||||
|
||||
```java
|
||||
context.commands().register("server", invocation -> {
|
||||
invocation.reply(Component.text("当前后端:")
|
||||
.append(Component.text(invocation.player().currentServer().orElse("未连接"),
|
||||
NamedTextColor.YELLOW)));
|
||||
}, completion -> {
|
||||
String args = completion.arguments();
|
||||
String prefix = args.substring(args.lastIndexOf(' ') + 1);
|
||||
return CompletableFuture.completedFuture(context.servers().all().stream()
|
||||
.map(ServerView::name)
|
||||
.filter(name -> name.startsWith(prefix))
|
||||
.toList());
|
||||
});
|
||||
```
|
||||
|
||||
两参数 `register(name, handler)` 仍适合无需参数补全的命令。三参数版本增加 `CommandCompleter`,返回 `CompletionStage<List<String>>`,可接数据库或其他异步查询。`CommandCompletion` 提供玩家快照、无斜杠的根命令名和根命令后去掉一个分隔字符的参数原文;末尾空格保留。`/server ` 会以空参数调用补全器。
|
||||
|
||||
候选是客户端用于替换最后一个词的字符串;前缀过滤由插件处理。最多返回 100 项,去重,忽略空值、空字符串、含空白或超过 100 个 UTF-16 单元的候选,并限制总字节数。插件负责命令执行的权限检查;补全器应只返回允许当前玩家看到的候选。根命令名本身公开可补全,不提供隐式权限系统。
|
||||
|
||||
## 线程与协议归属
|
||||
|
||||
- 插件执行与补全回调在有界命令工作池运行,补全等待最多 1 秒,全宿主最多 128 个未决请求。异常、超时、卸载或过载得到空候选;关闭注册或玩家连接会尽力取消异步任务。
|
||||
- `/prefix` 请求转给后端,返回时加入匹配的代理根命令并去重。已注册代理命令的参数只由代理插件补全,即使没有补全器也不泄漏到后端。未知命令和普通玩家名补全转给后端;正常返回包保持原始字节。
|
||||
- 1.7.10 没有补全请求 ID。每连接同时处理一个请求,额外排队最多 8 个;超出队列限制关闭连接,避免无限积压。按请求顺序回答,协议本身无法识别客户端已经编辑了哪一个输入框状态。
|
||||
- 后端 1 秒未回复时,根补全先返回代理候选,其他请求返回空候选。保留一个迟到回复标记;在收到并丢弃该回复前,不再向该后端发补全请求,以免把旧回复归给新请求。插件补全仍可用。转服切换后使用新的状态。
|
||||
- 切换后端、断线和主动踢出关闭旧补全状态,释放暂存帧并取消插件等待。普通 PLAY 帧只检查包 ID,不解析、复制或派发补全回调。
|
||||
|
||||
## 验收
|
||||
|
||||
组件与协议测试覆盖颜色、点击、悬浮、翻译参数、转义、尺寸/深度限制和不支持的能力。补全测试覆盖根合并、参数末尾空格、后端透传、异步完成、超时、迟到回复隔离及关闭清理;真实 TCP 测试覆盖发行路径使用的会话和插件宿主连线。构建与测试结果记录在工作交付中,客户端视觉交互单独标明是否完成实测。
|
||||
|
||||
本机额外用 Java 8 加载 Uranium 实际组件解析器,读取当前发行包生成的富文本 JSON;正文、点击命令 /spawn 和悬浮正文均匹配。所用 Uranium JAR SHA-256 为 B16747D08BAD4B7C67DB8F1F41C9C31066FEAAAC22B527775F604BDFA885BAD2。该检查不启动客户端,不证明鼠标交互或渲染效果已实测。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 玩家操作与部署完善设计
|
||||
|
||||
状态:首批实现与本地自动化验收已完成。设计基线为 `ea78523`;真实在线认证和目标整合包验收仍待完成。
|
||||
状态:本文保留首批玩家操作的设计基线。当前消息模型见[富文本与命令补全](messages-and-completion.md),真实在线认证与当前整合包结果见[验收记录](../benchmarks/results/2026-09-26-prism-pack.md)。
|
||||
|
||||
## 目标与边界
|
||||
|
||||
|
||||
+4
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
`Players.sendMessage(identity, text)` 和 `disconnect(identity, reason)` 均绑定完整的 `PlayerIdentity`,可从任意线程调用;通过插件上下文获取的 stage 在 I/O 线程外完成。参数错误立即报告,插件停用后拒绝继续提交。
|
||||
|
||||
- 消息是纯文本,最多 1024 个 Unicode 码点。返回 `MessageResult.SENT` 表示网络写入完成;连接消失或正在关闭返回 `NOT_CONNECTED`;登录、初次 Forge 握手未就绪或转服交接期间返回 `NOT_READY`;不可写或该连接已有 64 条未完成消息返回 `BACKPRESSURED`。网络写失败以异常完成。
|
||||
- 主动断开要求非空白原因,最多 1024 个 Unicode 码点。核心按实际 LOGIN/PLAY 阶段发包,最长等待 5 秒后清理连接、未决转服和候选后端。返回 `DisconnectResult.DISCONNECTED` 表示连接清理完成,原因文本仅尽力交付;身份已失效返回 `NOT_CONNECTED`。重复断开合并处理,取消调用方等待不撤销已提交的断开。
|
||||
- 消息支持 Adventure Component;String 入口为纯文本,最多 1024 个 Unicode 码点。返回 `MessageResult.SENT` 表示网络写入完成;连接消失或正在关闭返回 `NOT_CONNECTED`;登录、初次 Forge 握手未就绪或转服交接期间返回 `NOT_READY`;不可写或该连接已有 64 条未完成消息返回 `BACKPRESSURED`。网络写失败以异常完成。
|
||||
- 主动断开接受非空白的 Component 原因;String 入口最多 1024 个 Unicode 码点。核心按实际 LOGIN/PLAY 阶段发包,最长等待 5 秒后清理连接、未决转服和候选后端。返回 `DisconnectResult.DISCONNECTED` 表示连接清理完成,原因文本仅尽力交付;身份已失效返回 `NOT_CONNECTED`。重复断开合并处理,取消调用方等待不撤销已提交的断开。
|
||||
- `disconnect` 也支持准入或初始选服回调中的已识别玩家,此时玩家还未出现在 `online()` 中。
|
||||
|
||||
```java
|
||||
@@ -128,3 +128,5 @@ context.events().subscribe(PlayerDisconnectedEvent.class, event ->
|
||||
统一事件模型让准入决策和会话通知共享同一类型、监听器、订阅表与派发实现,同时保留各类事件所需的运行策略。准入阶段执行有序且失败关闭的结果聚合;生命周期通知异步 FIFO 尽力交付并与会话处理隔离。没有 PLAY 包事件。目前没有事件 API 的专门基准,不能据此宣称性能收益或无开销。
|
||||
|
||||
源码入口:[`PluginContext`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/PluginContext.java)、[`Events`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/event/Events.java)、[`AccessDecision`](../proxy-plugin-api/src/main/java/dev/strataproxy/api/AccessDecision.java)。
|
||||
|
||||
富文本构造、MiniMessage 和异步命令补全示例见[消息与命令补全](messages-and-completion.md)。
|
||||
@@ -102,3 +102,11 @@ PR 的 `.gitea/workflows/verify.yml` 独立运行 `gradlew check`,不使用 Ma
|
||||
将 `-Window` 改为 `16` 可测每连接最多 16 个在途往返的情形。
|
||||
|
||||
原 stop-and-wait 小样本见 `benchmarks/results/proxy-session-benchmark-smoke-2026-09-25.md`;Window 参数的小样本和 Window=1/16 重复测量见 `benchmarks/results/2026-09-25-proxy-session-window.md`。这些是环回网络上的合成帧对照,不能代表 Forge 整合包、真实后端或跨主机部署的性能。
|
||||
|
||||
## 富文本与命令补全验证(2026-09-27)
|
||||
|
||||
实现设计见[消息与命令补全](messages-and-completion.md)。本地 `gradlew check` 通过:217 项测试(API 7、核心 197、DNS 6、Agent 7),失败、错误、跳过均为 0;发行包帮助、版本、配置校验通过。
|
||||
|
||||
新增验证包含组件构造与旧版 JSON 字段、模板变量作为文字插入、消息深度/节点/字节限制;根命令与后端候选合并、参数补全、原始后端回复透传;超时、迟到回复隔离、队列上限、转服代次切换、插件注销/关闭与取消回调线程。真实 TCP 测试同时核对富文本命令回复和补全,转服测试核对切换前后的补全上下文。
|
||||
|
||||
此外用 Java 8 加载本机 Uranium 的实际组件解析器读取当前发行包生成的 JSON,核对正文、点击命令和悬浮文字通过。未启动真实客户端进行视觉/鼠标交互验收;没有测量本次改动的吞吐收益。最终远端 CI 按对应 PR 检查项记录。
|
||||
@@ -4,6 +4,7 @@ netty = "4.2.2.Final"
|
||||
jackson = "2.20.0"
|
||||
slf4j = "2.0.18"
|
||||
logback = "1.6.3"
|
||||
adventure = "5.2.0"
|
||||
|
||||
[libraries]
|
||||
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
|
||||
@@ -16,3 +17,7 @@ jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", ver
|
||||
jackson-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" }
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" }
|
||||
adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" }
|
||||
adventure-gson = { module = "net.kyori:adventure-text-serializer-gson", version.ref = "adventure" }
|
||||
adventure-plain = { module = "net.kyori:adventure-text-serializer-plain", version.ref = "adventure" }
|
||||
+3
-3
@@ -202,10 +202,10 @@ class AgentDiscoveryPluginTest {
|
||||
return new Players() {
|
||||
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
|
||||
@Override public List<PlayerView> online() { return List.of(); }
|
||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.MessageResult> sendMessage(PlayerIdentity identity, String message) {
|
||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.MessageResult> sendMessage(PlayerIdentity identity, net.kyori.adventure.text.Component message) {
|
||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
|
||||
@Override public java.util.concurrent.CompletionStage<dev.strataproxy.api.DisconnectResult> disconnect(PlayerIdentity identity, net.kyori.adventure.text.Component reason) {
|
||||
return CompletableFuture.failedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
@Override public java.util.concurrent.CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
|
||||
@@ -215,7 +215,7 @@ class AgentDiscoveryPluginTest {
|
||||
}
|
||||
@Override public Servers servers() { return servers; }
|
||||
@Override public dev.strataproxy.api.Commands commands() {
|
||||
return (name, handler) -> { throw new UnsupportedOperationException(); };
|
||||
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
|
||||
}
|
||||
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
|
||||
@Override public org.slf4j.Logger logger() { return org.slf4j.LoggerFactory.getLogger("agent-test"); }
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ class DnsDiscoveryPluginTest {
|
||||
}
|
||||
|
||||
@Override public dev.strataproxy.api.Commands commands() {
|
||||
return (name, handler) -> { throw new UnsupportedOperationException(); };
|
||||
return (name, handler, completer) -> { throw new UnsupportedOperationException(); };
|
||||
}
|
||||
@Override public dev.strataproxy.api.event.Events events() { throw new UnsupportedOperationException(); }
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ dependencies {
|
||||
implementation(libs.findLibrary("netty-resolver-dns").get())
|
||||
implementation(libs.findLibrary("jackson-databind").get())
|
||||
implementation(libs.findLibrary("jackson-yaml").get())
|
||||
implementation(libs.findLibrary("adventure-gson").get())
|
||||
implementation(libs.findLibrary("adventure-plain").get())
|
||||
runtimeOnly(libs.findLibrary("logback-classic").get())
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ public final class ProxyMain {
|
||||
listener.setEvents(plugins, eventTimeout);
|
||||
listener.setPlacement(plugins::placeInitial);
|
||||
listener.setCommandDispatcher(plugins::dispatchCommand);
|
||||
listener.setCommandCompletion(plugins::commandNames, plugins::completeCommand);
|
||||
var serverChannel = listener.start().toCompletableFuture().join();
|
||||
LOGGER.info("Listening on {}", serverChannel.localAddress());
|
||||
var shutdown = new Thread(() -> {
|
||||
|
||||
@@ -13,6 +13,8 @@ import dev.strataproxy.api.event.PlayerDisconnectedEvent;
|
||||
import dev.strataproxy.api.event.ServerConnectedEvent;
|
||||
import dev.strataproxy.api.InitialPlacementHandler;
|
||||
import dev.strataproxy.api.CommandHandler;
|
||||
import dev.strataproxy.api.CommandCompleter;
|
||||
import dev.strataproxy.api.CommandCompletion;
|
||||
import dev.strataproxy.api.CommandInvocation;
|
||||
import dev.strataproxy.api.CommandRegistration;
|
||||
import dev.strataproxy.api.Commands;
|
||||
@@ -27,6 +29,7 @@ import dev.strataproxy.api.ServerRegistration;
|
||||
import dev.strataproxy.api.ServerView;
|
||||
import dev.strataproxy.api.Servers;
|
||||
import dev.strataproxy.api.TransferResult;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import dev.strataproxy.core.backend.BackendCatalog;
|
||||
import dev.strataproxy.core.backend.BackendId;
|
||||
import dev.strataproxy.core.backend.BackendOwner;
|
||||
@@ -71,6 +74,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
@@ -80,6 +84,12 @@ import java.util.jar.JarFile;
|
||||
public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PluginHost.class);
|
||||
private static final AtomicLong NEXT_HOST_GENERATION = new AtomicLong();
|
||||
private static final int MAX_PENDING_COMMAND_COMPLETIONS = 128;
|
||||
private static final int MAX_COMPLETION_RESULTS = 100;
|
||||
// Leave room for bounded protocol encoding overhead beyond suggestion UTF-8 bodies.
|
||||
private static final int MAX_COMPLETION_BYTES = 32_000;
|
||||
private static final int MAX_COMPLETION_CANDIDATES_SCANNED = 4096;
|
||||
private static final Duration COMMAND_COMPLETION_TIMEOUT = Duration.ofSeconds(1);
|
||||
private static final ThreadFactory PLAYER_COMPLETION_THREADS = Thread.ofVirtual()
|
||||
.name("strataproxy-plugin-player-", 0).factory();
|
||||
private static final Executor PLAYER_COMPLETIONS = task -> PLAYER_COMPLETION_THREADS.newThread(task).start();
|
||||
@@ -90,6 +100,8 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
private final ThreadPoolExecutor callbacks;
|
||||
private final ThreadPoolExecutor commandWorkers;
|
||||
private final ConcurrentHashMap<String, RegisteredCommand> commands = new ConcurrentHashMap<>();
|
||||
private final Set<PendingCompletion> pendingCompletions = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicInteger pendingCompletionCount = new AtomicInteger();
|
||||
private final ScheduledThreadPoolExecutor timer;
|
||||
private final AsyncEventDispatcher admissionEvents;
|
||||
private final AsyncEventDispatcher notificationEvents;
|
||||
@@ -422,13 +434,168 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns up to 100 registered roots matching a case-insensitive prefix without a leading slash. */
|
||||
public List<String> commandNames(String prefix) {
|
||||
Objects.requireNonNull(prefix, "prefix");
|
||||
if (state != State.ENABLED) return List.of();
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return commands.values().stream()
|
||||
.filter(command -> command.context.active && command.name.startsWith(normalized))
|
||||
.map(command -> "/" + command.name)
|
||||
.sorted()
|
||||
.limit(MAX_COMPLETION_RESULTS)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes arguments for a known slash command. Root-only input belongs to {@link #commandNames(String)};
|
||||
* the callback receives arguments after exactly one root separator, preserving all remaining whitespace.
|
||||
*/
|
||||
public Optional<CompletionStage<List<String>>> completeCommand(PlayerView player, String input) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
Objects.requireNonNull(input, "input");
|
||||
if (state != State.ENABLED || input.length() < 2 || input.charAt(0) != '/') return Optional.empty();
|
||||
int end = 1;
|
||||
while (end < input.length() && !Character.isWhitespace(input.charAt(end))) end++;
|
||||
if (end == input.length()) return Optional.empty();
|
||||
String name = input.substring(1, end).toLowerCase(Locale.ROOT);
|
||||
RegisteredCommand registered = commands.get(name);
|
||||
if (registered == null || !registered.context.active) return Optional.empty();
|
||||
String arguments = input.substring(end + 1);
|
||||
if (registered.completer == null) {
|
||||
return Optional.of(CompletableFuture.completedFuture(List.of()));
|
||||
}
|
||||
synchronized (this) {
|
||||
if (state != State.ENABLED || commands.get(name) != registered || !registered.context.active) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!reserveCompletionSlot()) {
|
||||
return Optional.of(CompletableFuture.failedFuture(new PluginOverloadedException(
|
||||
new RejectedExecutionException("Too many pending command completions"))));
|
||||
}
|
||||
PendingCompletion pending = new PendingCompletion(registered, player, name, arguments);
|
||||
pendingCompletions.add(pending);
|
||||
registered.pendingCompletions.add(pending);
|
||||
pending.result.whenComplete((ignored, failure) -> {
|
||||
if (pending.result.isCancelled()) finishCompletion(pending, null,
|
||||
new java.util.concurrent.CancellationException("Command completion cancelled"), true);
|
||||
});
|
||||
try {
|
||||
pending.timeout = timer.schedule(() -> finishCompletion(pending, null,
|
||||
new TimeoutException("Plugin command completion timed out"), true),
|
||||
COMMAND_COMPLETION_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
|
||||
FutureTask<Void> invocation = new FutureTask<>(() -> {
|
||||
invokeCompleter(pending);
|
||||
return null;
|
||||
});
|
||||
pending.invocation = invocation;
|
||||
commandWorkers.execute(invocation);
|
||||
if (pending.finished.get()) {
|
||||
invocation.cancel(true);
|
||||
commandWorkers.remove(invocation);
|
||||
}
|
||||
} catch (RejectedExecutionException overloaded) {
|
||||
finishCompletion(pending, null, new PluginOverloadedException(overloaded), true);
|
||||
} catch (RuntimeException failure) {
|
||||
finishCompletion(pending, null, failure, true);
|
||||
}
|
||||
return Optional.of(pending.result);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean reserveCompletionSlot() {
|
||||
while (true) {
|
||||
int current = pendingCompletionCount.get();
|
||||
if (current >= MAX_PENDING_COMMAND_COMPLETIONS) return false;
|
||||
if (pendingCompletionCount.compareAndSet(current, current + 1)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void invokeCompleter(PendingCompletion pending) {
|
||||
if (pending.finished.get()) return;
|
||||
if (state != State.ENABLED || !pending.registered.context.active
|
||||
|| commands.get(pending.commandName) != pending.registered) {
|
||||
finishCompletion(pending, null, new IllegalStateException("Command is no longer registered"), true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CompletionStage<List<String>> stage = Objects.requireNonNull(
|
||||
pending.registered.completer.complete(new CommandCompletion(
|
||||
pending.player, pending.commandName, pending.arguments)),
|
||||
"Command completer returned a null stage");
|
||||
CompletableFuture<List<String>> cancellable = stage.toCompletableFuture();
|
||||
pending.pluginStage.set(cancellable);
|
||||
if (pending.finished.get()) cancellable.cancel(true);
|
||||
stage.whenCompleteAsync((suggestions, failure) -> {
|
||||
if (failure != null) finishCompletion(pending, null, failure, false);
|
||||
else {
|
||||
try {
|
||||
finishCompletion(pending, sanitizeSuggestions(
|
||||
Objects.requireNonNull(suggestions, "Command completer returned null suggestions")),
|
||||
null, false);
|
||||
} catch (Throwable invalid) {
|
||||
finishCompletion(pending, null, invalid, false);
|
||||
}
|
||||
}
|
||||
}, commandWorkers);
|
||||
} catch (Throwable failure) {
|
||||
finishCompletion(pending, null, failure, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> sanitizeSuggestions(List<String> suggestions) {
|
||||
var result = new ArrayList<String>(Math.min(suggestions.size(), MAX_COMPLETION_RESULTS));
|
||||
var unique = new HashSet<String>();
|
||||
int utf8Bytes = 0;
|
||||
int scanned = 0;
|
||||
for (String suggestion : suggestions) {
|
||||
if (scanned++ >= MAX_COMPLETION_CANDIDATES_SCANNED || result.size() >= MAX_COMPLETION_RESULTS) break;
|
||||
if (suggestion == null || suggestion.isEmpty() || suggestion.length() > 100 || containsWhitespace(suggestion)
|
||||
|| !unique.add(suggestion)) continue;
|
||||
int bytes = suggestion.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (utf8Bytes + bytes > MAX_COMPLETION_BYTES) continue;
|
||||
result.add(suggestion);
|
||||
utf8Bytes += bytes;
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static boolean containsWhitespace(String value) {
|
||||
for (int offset = 0; offset < value.length();) {
|
||||
int codePoint = value.codePointAt(offset);
|
||||
if (Character.isWhitespace(codePoint) || Character.isSpaceChar(codePoint)) return true;
|
||||
offset += Character.charCount(codePoint);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void finishCompletion(PendingCompletion pending, List<String> suggestions, Throwable failure,
|
||||
boolean cancelExecution) {
|
||||
if (!pending.finished.compareAndSet(false, true)) return;
|
||||
pendingCompletions.remove(pending);
|
||||
pending.registered.pendingCompletions.remove(pending);
|
||||
pendingCompletionCount.decrementAndGet();
|
||||
if (pending.timeout != null) pending.timeout.cancel(false);
|
||||
FutureTask<Void> invocation = pending.invocation;
|
||||
if (invocation != null && cancelExecution && !invocation.isDone()) {
|
||||
commandWorkers.remove(invocation);
|
||||
invocation.cancel(true);
|
||||
}
|
||||
CompletableFuture<List<String>> pluginStage = pending.pluginStage.get();
|
||||
if (pluginStage != null && cancelExecution && !pluginStage.isDone()) {
|
||||
PLAYER_COMPLETIONS.execute(() -> pluginStage.cancel(true));
|
||||
}
|
||||
if (failure != null) pending.result.completeExceptionally(failure);
|
||||
else pending.result.complete(Objects.requireNonNull(suggestions, "suggestions"));
|
||||
}
|
||||
|
||||
/** Claims only known root commands; caller retains and forwards every other chat frame. */
|
||||
public boolean dispatchCommand(PlayerView player, String message, Consumer<String> reply) {
|
||||
public boolean dispatchCommand(PlayerView player, String message, Consumer<Component> reply) {
|
||||
return dispatchCommand(player, message, reply, () -> true);
|
||||
}
|
||||
|
||||
/** Admission is checked only after the command name is known, preserving unknown-command passthrough. */
|
||||
public boolean dispatchCommand(PlayerView player, String message, Consumer<String> reply,
|
||||
public boolean dispatchCommand(PlayerView player, String message, Consumer<Component> reply,
|
||||
BooleanSupplier admission) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
Objects.requireNonNull(message, "message");
|
||||
@@ -441,9 +608,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
RegisteredCommand registered = commands.get(name);
|
||||
if (registered == null || !registered.context.active) return false;
|
||||
if (!admission.getAsBoolean()) return true;
|
||||
String arguments = end == message.length() ? "" : message.substring(end).stripLeading();
|
||||
String arguments = end == message.length() ? "" : message.substring(end + 1);
|
||||
CommandInvocation invocation = new CommandInvocation(player, name, arguments,
|
||||
text -> { if (registered.context.active) reply.accept(text); });
|
||||
component -> { if (registered.context.active) reply.accept(component); });
|
||||
try {
|
||||
commandWorkers.execute(() -> {
|
||||
if (state != State.ENABLED || !registered.context.active || commands.get(name) != registered) return;
|
||||
@@ -455,7 +622,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException overloaded) {
|
||||
reply.accept("Proxy command service is busy. Please try again.");
|
||||
reply.accept(Component.text("Proxy command service is busy. Please try again."));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -477,6 +644,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
request.decided().set(true);
|
||||
request.result().completeExceptionally(new IllegalStateException("Plugin host closed"));
|
||||
}
|
||||
for (PendingCompletion pending : pendingCompletions) {
|
||||
finishCompletion(pending, null, new IllegalStateException("Plugin host closed"), true);
|
||||
}
|
||||
callbacks.shutdownNow();
|
||||
commandWorkers.shutdownNow();
|
||||
timer.shutdownNow();
|
||||
@@ -618,7 +788,40 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
private record PendingPlacement(CompletableFuture<Optional<PlacementDecision>> result,
|
||||
AtomicBoolean decided) { }
|
||||
|
||||
private record RegisteredCommand(String name, PluginContextImpl context, CommandHandler handler) { }
|
||||
private final class RegisteredCommand {
|
||||
private final String name;
|
||||
private final PluginContextImpl context;
|
||||
private final CommandHandler handler;
|
||||
private final CommandCompleter completer;
|
||||
private final Set<PendingCompletion> pendingCompletions = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private RegisteredCommand(String name, PluginContextImpl context, CommandHandler handler,
|
||||
CommandCompleter completer) {
|
||||
this.name = name;
|
||||
this.context = context;
|
||||
this.handler = handler;
|
||||
this.completer = completer;
|
||||
}
|
||||
}
|
||||
|
||||
private final class PendingCompletion {
|
||||
private final RegisteredCommand registered;
|
||||
private final PlayerView player;
|
||||
private final String commandName;
|
||||
private final String arguments;
|
||||
private final CompletableFuture<List<String>> result = new CompletableFuture<>();
|
||||
private final AtomicBoolean finished = new AtomicBoolean();
|
||||
private final AtomicReference<CompletableFuture<List<String>>> pluginStage = new AtomicReference<>();
|
||||
private volatile FutureTask<Void> invocation;
|
||||
private volatile ScheduledFuture<?> timeout;
|
||||
|
||||
private PendingCompletion(RegisteredCommand registered, PlayerView player, String commandName, String arguments) {
|
||||
this.registered = registered;
|
||||
this.player = player;
|
||||
this.commandName = commandName;
|
||||
this.arguments = arguments;
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventRegistration<E extends Event<R>, R>
|
||||
implements EventSubscription, AsyncEventDispatcher.EventHandler<R> {
|
||||
@@ -689,6 +892,13 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
active = false;
|
||||
eventRegistrationOpen = false;
|
||||
revokeEventSubscriptions();
|
||||
for (RegisteredCommand command : commands.values()) {
|
||||
if (command.context == this) {
|
||||
for (PendingCompletion pending : command.pendingCompletions) {
|
||||
finishCompletion(pending, null, new IllegalStateException("Plugin unloaded"), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
commands.entrySet().removeIf(entry -> entry.getValue().context == this);
|
||||
catalog.removeOwner(owner);
|
||||
}
|
||||
@@ -734,7 +944,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
|
||||
private PluginCommands(PluginContextImpl context) { this.context = context; }
|
||||
|
||||
@Override public CommandRegistration register(String name, CommandHandler handler) {
|
||||
@Override public CommandRegistration register(String name, CommandHandler handler, CommandCompleter completer) {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(handler, "handler");
|
||||
String normalized = name.toLowerCase(Locale.ROOT);
|
||||
@@ -743,11 +953,19 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
synchronized (context) {
|
||||
context.requireActive();
|
||||
RegisteredCommand registration = new RegisteredCommand(normalized, context, handler);
|
||||
RegisteredCommand registration = new RegisteredCommand(normalized, context, handler, completer);
|
||||
if (commands.putIfAbsent(normalized, registration) != null) {
|
||||
throw new IllegalArgumentException("Command already registered: /" + normalized);
|
||||
}
|
||||
return () -> commands.remove(normalized, registration);
|
||||
return () -> {
|
||||
synchronized (PluginHost.this) {
|
||||
if (commands.remove(normalized, registration)) {
|
||||
for (PendingCompletion pending : registration.pendingCompletions) {
|
||||
finishCompletion(pending, null, new IllegalStateException("Command unregistered"), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,7 +1001,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
|
||||
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
|
||||
synchronized (context) {
|
||||
context.requireActive();
|
||||
return players.sendMessage(identity, message)
|
||||
@@ -791,7 +1009,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
|
||||
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
|
||||
synchronized (context) {
|
||||
context.requireActive();
|
||||
return players.disconnect(identity, reason)
|
||||
|
||||
+17
-6
@@ -1,15 +1,14 @@
|
||||
package dev.strataproxy.core.protocol;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufAllocator;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
/** The few protocol 5 PLAY packets a backend switch must understand or synthesize. */
|
||||
public final class Minecraft1710PlayPackets {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
public static final int CLIENT_CHAT = 0x01;
|
||||
public static final int SERVER_CHAT = 0x02;
|
||||
public static final int JOIN_GAME = 0x01;
|
||||
@@ -44,11 +43,17 @@ public final class Minecraft1710PlayPackets {
|
||||
/** Encodes a plain-text proxy reply as a protocol 5 clientbound PLAY chat frame. */
|
||||
public static ByteBuf chatReply(ByteBufAllocator allocator, String message) {
|
||||
validateText(message, true);
|
||||
String component = JSON.createObjectNode().put("text", message).toString();
|
||||
return chatReplyEncoded(allocator, MinecraftText.encode(Component.text(message)));
|
||||
}
|
||||
|
||||
/** Encodes a protocol 5 clientbound PLAY chat frame from prevalidated component JSON. */
|
||||
public static ByteBuf chatReplyEncoded(ByteBufAllocator allocator, String componentJson) {
|
||||
MinecraftText.validateEncoded(componentJson);
|
||||
if (allocator == null) throw new NullPointerException("allocator");
|
||||
ByteBuf packet = allocator.buffer();
|
||||
try {
|
||||
ProtocolVarInt.write(packet, SERVER_CHAT);
|
||||
ProtocolStrings.write(packet, component, 32767);
|
||||
ProtocolStrings.write(packet, componentJson, 32767);
|
||||
return frame(allocator, packet);
|
||||
} finally {
|
||||
packet.release();
|
||||
@@ -58,11 +63,17 @@ public final class Minecraft1710PlayPackets {
|
||||
/** Encodes an unframed protocol 5 PLAY Disconnect payload. */
|
||||
public static ByteBuf disconnect(ByteBufAllocator allocator, String reason) {
|
||||
validateText(reason, false);
|
||||
String component = JSON.createObjectNode().put("text", reason).toString();
|
||||
return disconnectEncoded(allocator, MinecraftText.encodeReason(Component.text(reason)));
|
||||
}
|
||||
|
||||
/** Encodes an unframed protocol 5 PLAY Disconnect payload from prevalidated component JSON. */
|
||||
public static ByteBuf disconnectEncoded(ByteBufAllocator allocator, String componentJson) {
|
||||
MinecraftText.validateEncoded(componentJson);
|
||||
if (allocator == null) throw new NullPointerException("allocator");
|
||||
ByteBuf packet = allocator.buffer();
|
||||
try {
|
||||
ProtocolVarInt.write(packet, SERVER_DISCONNECT);
|
||||
ProtocolStrings.write(packet, component, 32767);
|
||||
ProtocolStrings.write(packet, componentJson, 32767);
|
||||
return packet;
|
||||
} catch (RuntimeException failure) {
|
||||
packet.release();
|
||||
|
||||
+9
-4
@@ -1,14 +1,13 @@
|
||||
package dev.strataproxy.core.protocol;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufAllocator;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Protocol 5 LOGIN clientbound Disconnect (packet id 0x00). */
|
||||
public final class MinecraftLoginDisconnect {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private MinecraftLoginDisconnect() { }
|
||||
|
||||
@@ -18,11 +17,17 @@ public final class MinecraftLoginDisconnect {
|
||||
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
|
||||
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
|
||||
}
|
||||
String component = JSON.createObjectNode().put("text", reason).toString();
|
||||
return encodeJson(allocator, MinecraftText.encodeReason(Component.text(reason)));
|
||||
}
|
||||
|
||||
/** Encodes a LOGIN Disconnect payload from prevalidated component JSON. */
|
||||
public static ByteBuf encodeJson(ByteBufAllocator allocator, String componentJson) {
|
||||
Objects.requireNonNull(allocator, "allocator");
|
||||
MinecraftText.validateEncoded(componentJson);
|
||||
ByteBuf packet = allocator.buffer();
|
||||
try {
|
||||
ProtocolVarInt.write(packet, 0);
|
||||
ProtocolStrings.write(packet, component, 32767);
|
||||
ProtocolStrings.write(packet, componentJson, 32767);
|
||||
return packet;
|
||||
} catch (RuntimeException failure) {
|
||||
packet.release();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.strataproxy.core.protocol;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufAllocator;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Protocol 5 completion packets contain no request identifier. */
|
||||
public final class MinecraftTabCompletion {
|
||||
public static final int REQUEST = 0x14;
|
||||
public static final int RESPONSE = 0x3a;
|
||||
private MinecraftTabCompletion() { }
|
||||
|
||||
public static boolean matches(ByteBuf frame, int packetId) {
|
||||
int offset = frame.readerIndex();
|
||||
for (int i = 0; i < 3 && offset < frame.writerIndex(); i++) {
|
||||
if ((frame.getUnsignedByte(offset++) & 0x80) == 0) {
|
||||
return offset < frame.writerIndex() && frame.getUnsignedByte(offset) == packetId;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ByteBuf body(ByteBuf frame, int id) {
|
||||
ByteBuf input = frame.duplicate();
|
||||
if (ProtocolVarInt.read(input) != input.readableBytes() || ProtocolVarInt.read(input) != id) {
|
||||
throw new ProtocolException("invalid completion packet");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
public static String request(ByteBuf frame) {
|
||||
ByteBuf input = body(frame, REQUEST);
|
||||
String text = ProtocolStrings.read(input, 32767);
|
||||
if (input.isReadable()) throw new ProtocolException("trailing completion request bytes");
|
||||
return text;
|
||||
}
|
||||
|
||||
public static List<String> response(ByteBuf frame) {
|
||||
ByteBuf input = body(frame, RESPONSE);
|
||||
int count = ProtocolVarInt.read(input);
|
||||
if (count < 0 || count > 4096) throw new ProtocolException("too many backend completions");
|
||||
List<String> result = new ArrayList<>(Math.min(count, 100));
|
||||
for (int i = 0; i < count; i++) {
|
||||
String text = ProtocolStrings.read(input, 32767);
|
||||
if (result.size() < 100 && text.length() <= 100) result.add(text);
|
||||
}
|
||||
if (input.isReadable()) throw new ProtocolException("trailing completion response bytes");
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ByteBuf response(ByteBufAllocator allocator, List<String> suggestions) {
|
||||
ByteBuf body = allocator.buffer();
|
||||
try {
|
||||
if (suggestions.size() > 100) throw new IllegalArgumentException("too many completions");
|
||||
ProtocolVarInt.write(body, RESPONSE);
|
||||
ProtocolVarInt.write(body, suggestions.size());
|
||||
for (String suggestion : suggestions) ProtocolStrings.write(body, suggestion, 100);
|
||||
if (body.readableBytes() > 32767) throw new IllegalArgumentException("completion response too large");
|
||||
return Minecraft1710PlayPackets.frame(allocator, body);
|
||||
} finally { body.release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package dev.strataproxy.core.protocol;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.ComponentLike;
|
||||
import net.kyori.adventure.text.TextComponent;
|
||||
import net.kyori.adventure.text.TranslatableComponent;
|
||||
import net.kyori.adventure.text.TranslationArgument;
|
||||
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Validates and serializes Adventure components for Minecraft 1.7.10 JSON chat. */
|
||||
public final class MinecraftText {
|
||||
public static final int MAX_JSON_BYTES = 32_767;
|
||||
private static final int MAX_DEPTH = 32;
|
||||
private static final int MAX_NODES = 256;
|
||||
private static final GsonComponentSerializer SERIALIZER = GsonComponentSerializer.colorDownsamplingGson().toBuilder()
|
||||
.editOptions(options -> options.value(
|
||||
net.kyori.adventure.text.serializer.json.JSONOptions.EMIT_COMPACT_TEXT_COMPONENT, false))
|
||||
.build();
|
||||
|
||||
private MinecraftText() { }
|
||||
|
||||
/** Serializes a supported component tree, failing before a caller schedules a network write. */
|
||||
public static String encode(Component component) {
|
||||
Objects.requireNonNull(component, "component");
|
||||
validateAdventureNodes(component, 1, new ValidationState());
|
||||
return encodeValidated(component);
|
||||
}
|
||||
|
||||
/** Serializes a disconnect component and requires at least one non-whitespace visible character. */
|
||||
public static String encodeReason(Component component) {
|
||||
Objects.requireNonNull(component, "component");
|
||||
validateAdventureNodes(component, 1, new ValidationState());
|
||||
String plain = PlainTextComponentSerializer.plainText().serialize(component);
|
||||
if (plain.isBlank()) throw new IllegalArgumentException("disconnect reason must not be blank");
|
||||
return encodeValidated(component);
|
||||
}
|
||||
|
||||
/** Applies the shared wire-size bound to a pre-encoded component before packet allocation. */
|
||||
public static void validateEncoded(String encoded) {
|
||||
Objects.requireNonNull(encoded, "encoded");
|
||||
if (encoded.length() > MAX_JSON_BYTES) {
|
||||
throw new IllegalArgumentException("encoded component exceeds " + MAX_JSON_BYTES + " bytes");
|
||||
}
|
||||
int bytes = encoded.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (bytes > MAX_JSON_BYTES) {
|
||||
throw new IllegalArgumentException("encoded component exceeds " + MAX_JSON_BYTES + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static String encodeValidated(Component component) {
|
||||
String encoded = SERIALIZER.serialize(component);
|
||||
validateEncoded(encoded);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
private static void validateAdventureNodes(Component component, int depth, ValidationState state) {
|
||||
if (depth > MAX_DEPTH || ++state.count > MAX_NODES) {
|
||||
throw new IllegalArgumentException("component tree exceeds supported depth or node count");
|
||||
}
|
||||
if (!(component instanceof TextComponent) && !(component instanceof TranslatableComponent)) {
|
||||
throw new IllegalArgumentException("Minecraft 1.7.10 supports only text and translatable components");
|
||||
}
|
||||
if (component instanceof TextComponent text) {
|
||||
state.addText(text.content());
|
||||
} else if (component instanceof TranslatableComponent translatable) {
|
||||
state.addText(translatable.key());
|
||||
if (translatable.fallback() != null) {
|
||||
throw new IllegalArgumentException("translatable fallback text is unsupported by Minecraft 1.7.10");
|
||||
}
|
||||
for (TranslationArgument argument : translatable.arguments()) {
|
||||
Object value = argument.value();
|
||||
if (value instanceof ComponentLike child) validateAdventureNodes(child.asComponent(), depth + 1, state);
|
||||
else if (++state.count > MAX_NODES) {
|
||||
throw new IllegalArgumentException("too many translation arguments");
|
||||
}
|
||||
else if (value instanceof String string) state.addText(string);
|
||||
else if (!(value instanceof Number) && !(value instanceof Boolean)) {
|
||||
throw new IllegalArgumentException("unsupported translatable argument type: "
|
||||
+ (value == null ? "null" : value.getClass().getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
var style = component.style();
|
||||
if (style.insertion() != null || style.font() != null || style.shadowColor() != null) {
|
||||
throw new IllegalArgumentException("component style contains a field unsupported by Minecraft 1.7.10");
|
||||
}
|
||||
var click = style.clickEvent();
|
||||
if (click != null && click.action() != net.kyori.adventure.text.event.ClickEvent.Action.OPEN_URL
|
||||
&& click.action() != net.kyori.adventure.text.event.ClickEvent.Action.RUN_COMMAND
|
||||
&& click.action() != net.kyori.adventure.text.event.ClickEvent.Action.SUGGEST_COMMAND) {
|
||||
throw new IllegalArgumentException("unsupported Minecraft 1.7.10 click action: " + click.action());
|
||||
}
|
||||
if (click != null) {
|
||||
if (!(click.payload() instanceof net.kyori.adventure.text.event.ClickEvent.Payload.Text payload)) {
|
||||
throw new IllegalArgumentException("supported Minecraft 1.7.10 click actions require a text payload");
|
||||
}
|
||||
state.addText(payload.value());
|
||||
}
|
||||
var hover = style.hoverEvent();
|
||||
if (hover != null && hover.action() != net.kyori.adventure.text.event.HoverEvent.Action.SHOW_TEXT) {
|
||||
throw new IllegalArgumentException("unsupported Minecraft 1.7.10 hover action: " + hover.action());
|
||||
}
|
||||
if (hover != null) {
|
||||
if (!(hover.value() instanceof Component hoverText)) {
|
||||
throw new IllegalArgumentException("show_text hover value must be a text component");
|
||||
}
|
||||
validateAdventureNodes(hoverText, depth + 1, state);
|
||||
}
|
||||
for (Component child : component.children()) validateAdventureNodes(child, depth + 1, state);
|
||||
}
|
||||
|
||||
private static final class ValidationState {
|
||||
private int count;
|
||||
private int inputBytes;
|
||||
|
||||
private void addText(String value) {
|
||||
if (value.length() > MAX_JSON_BYTES) {
|
||||
throw new IllegalArgumentException("component text exceeds " + MAX_JSON_BYTES + " bytes");
|
||||
}
|
||||
int bytes = value.getBytes(StandardCharsets.UTF_8).length;
|
||||
inputBytes += bytes;
|
||||
if (inputBytes > MAX_JSON_BYTES) {
|
||||
throw new IllegalArgumentException("component text exceeds " + MAX_JSON_BYTES + " bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import java.security.KeyPairGenerator;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import dev.strataproxy.core.protocol.MinecraftText;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -56,7 +58,7 @@ import java.util.concurrent.ThreadFactory;
|
||||
public final class ProxySessionListener implements Players {
|
||||
@FunctionalInterface public interface CommandDispatcher {
|
||||
/** Returns true only if this proxy owns the command. */
|
||||
boolean dispatch(PlayerView player, String message, Consumer<String> reply, BooleanSupplier admission);
|
||||
boolean dispatch(PlayerView player, String message, Consumer<Component> reply, BooleanSupplier admission);
|
||||
}
|
||||
private final SocketAddress bindAddress;
|
||||
private final BackendCatalog catalog;
|
||||
@@ -77,6 +79,9 @@ public final class ProxySessionListener implements Players {
|
||||
private final AtomicInteger connectionCount = new AtomicInteger();
|
||||
private volatile Function<PlayerView, CompletionStage<Optional<PlacementDecision>>> placement;
|
||||
private volatile CommandDispatcher commandDispatcher;
|
||||
private Function<String, List<String>> commandNames;
|
||||
private java.util.function.BiFunction<PlayerView, String, Optional<CompletionStage<List<String>>>> completions;
|
||||
|
||||
private EventDispatcher events;
|
||||
private Duration eventTimeout = Duration.ofSeconds(5);
|
||||
private int maxConnections = 4096;
|
||||
@@ -177,6 +182,17 @@ public final class ProxySessionListener implements Players {
|
||||
commandDispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
|
||||
}
|
||||
|
||||
public synchronized void setCommandCompletion(Function<String, List<String>> names,
|
||||
java.util.function.BiFunction<PlayerView, String, Optional<CompletionStage<List<String>>>> handler) {
|
||||
if (started || closed || completions != null) throw new IllegalStateException("Completion must be configured before start");
|
||||
commandNames = Objects.requireNonNull(names);
|
||||
completions = Objects.requireNonNull(handler);
|
||||
}
|
||||
Function<String, List<String>> commandNames() { return commandNames; }
|
||||
Optional<CompletionStage<List<String>>> completeCommand(PlayerView player, String text) {
|
||||
return completions.apply(player, text);
|
||||
}
|
||||
|
||||
/** Bounds accepted sessions, including clients that have not completed login. */
|
||||
public synchronized void setMaxConnections(int limit) {
|
||||
if (started || closed) throw new IllegalStateException("Connection limit must be set before listener start");
|
||||
@@ -291,24 +307,24 @@ public final class ProxySessionListener implements Players {
|
||||
return session.transferTo(backendName);
|
||||
}
|
||||
|
||||
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
|
||||
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
|
||||
Objects.requireNonNull(identity, "identity");
|
||||
Session.validateMessage(message);
|
||||
String encoded = MinecraftText.encode(message);
|
||||
Session session = sessionsByPlayerId.get(identity.playerId());
|
||||
if (session == null || !session.matchesIdentity(identity)) {
|
||||
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
|
||||
}
|
||||
return session.sendMessage(message);
|
||||
return session.sendEncodedMessage(encoded);
|
||||
}
|
||||
|
||||
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
|
||||
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
|
||||
Objects.requireNonNull(identity, "identity");
|
||||
Session.validateDisconnectReason(reason);
|
||||
String encoded = MinecraftText.encodeReason(reason);
|
||||
Session session = sessionsByPlayerId.get(identity.playerId());
|
||||
if (session == null || !session.matchesIdentity(identity)) {
|
||||
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
|
||||
}
|
||||
return session.disconnect(reason);
|
||||
return session.disconnectEncoded(encoded);
|
||||
}
|
||||
|
||||
public synchronized CompletionStage<Void> close() {
|
||||
|
||||
@@ -53,6 +53,8 @@ import java.util.UUID;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import dev.strataproxy.core.protocol.MinecraftText;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
@@ -100,6 +102,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
private boolean disconnectPacketStarted;
|
||||
private boolean disconnectRelayDrained;
|
||||
private String pendingDisconnectReason;
|
||||
private TabCompletionBridge tabCompletion;
|
||||
private CompletableFuture<DisconnectResult> disconnectResult;
|
||||
private ScheduledFuture<?> disconnectDeadline;
|
||||
private int commandTokens = COMMAND_BURST;
|
||||
@@ -428,7 +431,15 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
pipeline.addLast("minecraft-frame-encoder", new SessionFrameEncoder());
|
||||
}
|
||||
|
||||
private void disconnectLogin(String reason) {
|
||||
private void disconnectLogin(String reason) { disconnectLogin(Component.text(reason)); }
|
||||
|
||||
private void disconnectLogin(Component message) {
|
||||
String reason;
|
||||
try { reason = MinecraftText.encodeReason(message); }
|
||||
catch (RuntimeException invalid) {
|
||||
LOGGER.warn("Invalid plugin login rejection message", invalid);
|
||||
reason = MinecraftText.encodeReason(Component.text("Connection rejected."));
|
||||
}
|
||||
if (frontend.eventLoop().inEventLoop()) beginDisconnect(reason);
|
||||
else requestDisconnect(reason);
|
||||
}
|
||||
@@ -460,6 +471,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
if (id == 0) { // Login Disconnect.
|
||||
loginDisconnectStarted = true;
|
||||
disconnecting = true;
|
||||
if (tabCompletion != null) tabCompletion.close();
|
||||
disconnectResult = new CompletableFuture<>();
|
||||
frontend.config().setAutoRead(false);
|
||||
resetLoginDeadline(LOGIN_DISCONNECT_DRAIN_TIMEOUT);
|
||||
@@ -556,6 +568,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
this::dispatchPlayerCommand, this::closePair));
|
||||
}
|
||||
target.pipeline().addLast("keep-alive-bridge", new KeepAliveBridge(keepAlives, false, this::closePair));
|
||||
installTabCompletion(target);
|
||||
var link = RawRelay.attach(frontend, target,
|
||||
bytes -> observation.observeFrame(false, bytes),
|
||||
bytes -> observation.observeFrame(true, bytes));
|
||||
@@ -581,6 +594,16 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private void installTabCompletion(Channel target) {
|
||||
if (owner.commandNames() == null) return;
|
||||
if (tabCompletion != null) tabCompletion.close();
|
||||
if (frontend.pipeline().get("tab-completion") != null) frontend.pipeline().remove("tab-completion");
|
||||
tabCompletion = new TabCompletionBridge(frontend, owner.commandNames(),
|
||||
text -> owner.completeCommand(view, text), this::closePair);
|
||||
frontend.pipeline().addLast("tab-completion", tabCompletion.frontendHandler());
|
||||
target.pipeline().addLast("tab-completion", tabCompletion.backendHandler());
|
||||
}
|
||||
|
||||
private boolean dispatchPlayerCommand(String message) {
|
||||
if (closed.get() || disconnecting || !published) return false;
|
||||
return owner.commandDispatcher().dispatch(view, message, this::sendCommandReply, this::admitPlayerCommand);
|
||||
@@ -601,13 +624,21 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
if (!commandNoticeSent || now - lastCommandNoticeNanos >= COMMAND_NOTICE_NANOS) {
|
||||
commandNoticeSent = true;
|
||||
lastCommandNoticeNanos = now;
|
||||
sendCommandReply("Too many proxy commands. Please slow down.");
|
||||
sendCommandReply(Component.text("Too many proxy commands. Please slow down."));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
CompletionStage<MessageResult> sendMessage(String message) {
|
||||
validateMessage(message);
|
||||
return sendMessage(Component.text(message));
|
||||
}
|
||||
|
||||
CompletionStage<MessageResult> sendMessage(Component message) {
|
||||
return sendEncodedMessage(MinecraftText.encode(message));
|
||||
}
|
||||
|
||||
CompletionStage<MessageResult> sendEncodedMessage(String message) {
|
||||
CompletableFuture<MessageResult> result = new CompletableFuture<>();
|
||||
if (closed.get() || disconnecting) {
|
||||
result.complete(MessageResult.NOT_CONNECTED);
|
||||
@@ -645,7 +676,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ByteBuf frame = Minecraft1710PlayPackets.chatReply(frontend.alloc(), message);
|
||||
ByteBuf frame = Minecraft1710PlayPackets.chatReplyEncoded(frontend.alloc(), message);
|
||||
frontend.writeAndFlush(frame).addListener(write -> {
|
||||
pendingMessages.decrementAndGet();
|
||||
if (write.isSuccess()) result.complete(MessageResult.SENT);
|
||||
@@ -683,12 +714,16 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
if (disconnecting) writeDisconnectReasonIfDrained();
|
||||
}
|
||||
|
||||
private void sendCommandReply(String message) {
|
||||
private void sendCommandReply(Component message) {
|
||||
sendMessage(message);
|
||||
}
|
||||
|
||||
CompletionStage<DisconnectResult> disconnect(String reason) {
|
||||
validateDisconnectReason(reason);
|
||||
return requestDisconnect(MinecraftText.encodeReason(Component.text(reason)));
|
||||
}
|
||||
|
||||
CompletionStage<DisconnectResult> disconnectEncoded(String reason) {
|
||||
return requestDisconnect(reason);
|
||||
}
|
||||
|
||||
@@ -722,6 +757,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
private void beginDisconnect(String reason) {
|
||||
if (closed.get() || disconnecting) return;
|
||||
disconnecting = true;
|
||||
if (tabCompletion != null) tabCompletion.close();
|
||||
loginDisconnectStarted = true;
|
||||
pendingDisconnectReason = reason;
|
||||
disconnectResult = new CompletableFuture<>();
|
||||
@@ -766,7 +802,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
try {
|
||||
ByteBuf packet;
|
||||
if (frontendPlayPhase) {
|
||||
ByteBuf payload = Minecraft1710PlayPackets.disconnect(frontend.alloc(), pendingDisconnectReason);
|
||||
ByteBuf payload = Minecraft1710PlayPackets.disconnectEncoded(frontend.alloc(), pendingDisconnectReason);
|
||||
try {
|
||||
packet = frontend.pipeline().get("minecraft-frame-encoder") == null
|
||||
? Minecraft1710PlayPackets.frame(frontend.alloc(), payload) : payload.copy();
|
||||
@@ -774,7 +810,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
payload.release();
|
||||
}
|
||||
} else {
|
||||
packet = MinecraftLoginDisconnect.encode(frontend.alloc(), pendingDisconnectReason);
|
||||
packet = MinecraftLoginDisconnect.encodeJson(frontend.alloc(), pendingDisconnectReason);
|
||||
}
|
||||
frontend.writeAndFlush(packet).addListener(ignored -> closePair());
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -1137,6 +1173,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
attempt.channel.pipeline().addLast("keep-alive-bridge",
|
||||
new KeepAliveBridge(keepAlives, false, this::closePair));
|
||||
installTransferFrameHandlers(attempt.frameState, attempt.channel);
|
||||
installTabCompletion(attempt.channel);
|
||||
next = RawRelay.attach(frontend, attempt.channel,
|
||||
bytes -> nextObservation.observeFrame(false, bytes),
|
||||
bytes -> nextObservation.observeFrame(true, bytes));
|
||||
@@ -1321,6 +1358,7 @@ final class Session extends ChannelInboundHandlerAdapter {
|
||||
void closePair() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
Runnable cleanup = () -> {
|
||||
if (tabCompletion != null) tabCompletion.close();
|
||||
if (initialLoginDeadline != null) initialLoginDeadline.cancel(false);
|
||||
if (initialPlayDeadline != null) initialPlayDeadline.cancel(false);
|
||||
if (disconnectDeadline != null) disconnectDeadline.cancel(false);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package dev.strataproxy.core.session;
|
||||
|
||||
import dev.strataproxy.core.protocol.MinecraftTabCompletion;
|
||||
import dev.strataproxy.core.relay.RawRelay;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** One backend generation, confined to the shared frontend/backend event loop. */
|
||||
final class TabCompletionBridge implements AutoCloseable {
|
||||
private final Channel frontend;
|
||||
private final Function<String, List<String>> roots;
|
||||
private final Function<String, Optional<CompletionStage<List<String>>>> complete;
|
||||
private final Runnable closeSession;
|
||||
private final ArrayDeque<Request> queued = new ArrayDeque<>();
|
||||
private Request active;
|
||||
private boolean backendReplyOutstanding;
|
||||
private boolean closed;
|
||||
private static final class Request {
|
||||
final ChannelHandlerContext context;
|
||||
final ByteBuf frame;
|
||||
final String text;
|
||||
boolean owned = true;
|
||||
Request(ChannelHandlerContext context, ByteBuf frame, String text) {
|
||||
this.context = context; this.frame = frame; this.text = text;
|
||||
}
|
||||
void release() { if (owned) { owned = false; frame.release(); } }
|
||||
void forward() { owned = false; context.fireChannelRead(frame); }
|
||||
}
|
||||
private ScheduledFuture<?> timeout;
|
||||
private CompletableFuture<?> pluginResult;
|
||||
|
||||
TabCompletionBridge(Channel frontend, Function<String, List<String>> roots,
|
||||
Function<String, Optional<CompletionStage<List<String>>>> complete,
|
||||
Runnable closeSession) {
|
||||
this.frontend = frontend;
|
||||
this.roots = roots;
|
||||
this.complete = complete;
|
||||
this.closeSession = closeSession;
|
||||
}
|
||||
|
||||
ChannelInboundHandlerAdapter frontendHandler() { return new Handler(true); }
|
||||
ChannelInboundHandlerAdapter backendHandler() { return new Handler(false); }
|
||||
|
||||
private final class Handler extends ChannelInboundHandlerAdapter {
|
||||
private final boolean fromClient;
|
||||
private Handler(boolean fromClient) { this.fromClient = fromClient; }
|
||||
@Override public void channelRead(ChannelHandlerContext ctx, Object message) {
|
||||
if (!(message instanceof ByteBuf frame)) {
|
||||
ReferenceCountUtil.release(message);
|
||||
closeSession.run();
|
||||
return;
|
||||
}
|
||||
if (!MinecraftTabCompletion.matches(frame,
|
||||
fromClient ? MinecraftTabCompletion.REQUEST : MinecraftTabCompletion.RESPONSE)) {
|
||||
ctx.fireChannelRead(frame);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (fromClient) {
|
||||
if (closed) { frame.release(); return; }
|
||||
String text = MinecraftTabCompletion.request(frame);
|
||||
if (queued.size() >= 8) {
|
||||
frame.release();
|
||||
closeSession.run();
|
||||
return;
|
||||
}
|
||||
queued.add(new Request(ctx, frame, text));
|
||||
pump();
|
||||
} else {
|
||||
try { acceptBackend(frame); } finally { frame.release(); }
|
||||
}
|
||||
} catch (RuntimeException invalid) {
|
||||
// A request not admitted to the queue is still ours.
|
||||
if (fromClient && frame.refCnt() > 0 && queued.stream().noneMatch(r -> r.frame == frame)
|
||||
&& (active == null || active.frame != frame)) frame.release();
|
||||
closeSession.run();
|
||||
} finally {
|
||||
RawRelay.continueAfterDrop(ctx.channel());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void pump() {
|
||||
if (closed || active != null || queued.isEmpty()) return;
|
||||
Request request = queued.removeFirst();
|
||||
active = request;
|
||||
boolean root = isRoot(request.text);
|
||||
Optional<CompletionStage<List<String>>> local = root ? Optional.empty() : complete.apply(request.text);
|
||||
if (local.isPresent()) {
|
||||
request.release();
|
||||
CompletableFuture<List<String>> future = local.get().toCompletableFuture();
|
||||
pluginResult = future;
|
||||
timeout = frontend.eventLoop().schedule(() -> {
|
||||
if (active != request) return;
|
||||
finish(List.of());
|
||||
future.cancel(false);
|
||||
}, 1, TimeUnit.SECONDS);
|
||||
future.whenComplete((values, error) -> {
|
||||
try {
|
||||
frontend.eventLoop().execute(() -> {
|
||||
if (!closed && active == request) finish(error == null ? values : List.of());
|
||||
});
|
||||
} catch (java.util.concurrent.RejectedExecutionException ignored) { }
|
||||
});
|
||||
} else if (backendReplyOutstanding) {
|
||||
// An expired backend request has no wire ID. Do not issue another until its reply arrives.
|
||||
request.release();
|
||||
finish(root ? roots.apply(request.text.substring(1)) : List.of());
|
||||
} else {
|
||||
backendReplyOutstanding = true;
|
||||
timeout = frontend.eventLoop().schedule(() -> {
|
||||
if (active == request) finish(root ? roots.apply(request.text.substring(1)) : List.of());
|
||||
}, 1, TimeUnit.SECONDS);
|
||||
request.forward();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isRoot(String text) {
|
||||
return text.startsWith("/") && text.chars().noneMatch(Character::isWhitespace);
|
||||
}
|
||||
|
||||
private void acceptBackend(ByteBuf frame) {
|
||||
if (closed || !backendReplyOutstanding) return;
|
||||
backendReplyOutstanding = false;
|
||||
if (active == null || pluginResult != null) return; // expired request; never deliver its reply
|
||||
if (!isRoot(active.text)) { finish(List.of(), frame); return; }
|
||||
List<String> values = MinecraftTabCompletion.response(frame);
|
||||
if (isRoot(active.text)) {
|
||||
LinkedHashSet<String> merged = new LinkedHashSet<>(roots.apply(active.text.substring(1)));
|
||||
merged.addAll(values);
|
||||
values = new ArrayList<>(merged).subList(0, Math.min(100, merged.size()));
|
||||
}
|
||||
finish(values);
|
||||
}
|
||||
|
||||
private void finish(List<String> values) {
|
||||
finish(values, null);
|
||||
}
|
||||
|
||||
private void finish(List<String> values, ByteBuf original) {
|
||||
if (timeout != null) timeout.cancel(false);
|
||||
timeout = null;
|
||||
pluginResult = null;
|
||||
active = null;
|
||||
if (!closed && frontend.isActive() && frontend.isWritable()) {
|
||||
try {
|
||||
frontend.writeAndFlush(original == null
|
||||
? MinecraftTabCompletion.response(frontend.alloc(), bounded(values)) : original.retainedDuplicate())
|
||||
.addListener(write -> { if (!write.isSuccess()) closeSession.run(); });
|
||||
} catch (RuntimeException failure) { closeSession.run(); return; }
|
||||
}
|
||||
pump();
|
||||
}
|
||||
|
||||
private static List<String> bounded(List<String> values) {
|
||||
List<String> result = new ArrayList<>();
|
||||
int bytes = 2; // packet ID and result count (at most 100)
|
||||
for (String value : values) {
|
||||
if (result.size() == 100) break;
|
||||
if (value == null || value.length() > 100) continue;
|
||||
int length = value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
||||
int framed = length + (length < 128 ? 1 : 2);
|
||||
if (bytes + framed > 32767) break;
|
||||
bytes += framed;
|
||||
result.add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (timeout != null) timeout.cancel(false);
|
||||
if (pluginResult != null) pluginResult.cancel(false);
|
||||
if (active != null) active.release();
|
||||
active = null;
|
||||
Request request;
|
||||
while ((request = queued.pollFirst()) != null) request.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package dev.strataproxy.core.plugin;
|
||||
|
||||
import dev.strataproxy.api.CommandCompletion;
|
||||
import dev.strataproxy.api.CommandRegistration;
|
||||
import dev.strataproxy.api.PlayerIdentity;
|
||||
import dev.strataproxy.api.PlayerView;
|
||||
import dev.strataproxy.api.Plugin;
|
||||
import dev.strataproxy.api.PluginContext;
|
||||
import dev.strataproxy.api.Players;
|
||||
import dev.strataproxy.api.MessageResult;
|
||||
import dev.strataproxy.api.DisconnectResult;
|
||||
import dev.strataproxy.api.TransferResult;
|
||||
import dev.strataproxy.core.backend.InMemoryBackendCatalog;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PluginHostCompletionTest {
|
||||
private static final PlayerView PLAYER = new PlayerView(
|
||||
new PlayerIdentity(UUID.randomUUID(), 1), "TestPlayer", Optional.empty());
|
||||
|
||||
@Test
|
||||
void commandRootsAndArgumentCompletionPreserveWhitespaceAndBoundSuggestions() throws Exception {
|
||||
var request = new CompletableFuture<CommandCompletion>();
|
||||
var emptyArguments = new CompletableFuture<CommandCompletion>();
|
||||
var callbackThread = new AtomicReference<Thread>();
|
||||
var candidateList = new ArrayList<String>(Arrays.asList(
|
||||
"alpha", "alpha", null, "two words", "x".repeat(101), "猫".repeat(11_000), "beta"));
|
||||
for (int i = 0; i < 120; i++) candidateList.add("item" + i);
|
||||
CompletableFuture<List<String>> suggestions = CompletableFuture.completedFuture(candidateList);
|
||||
Plugin plugin = new Plugin() {
|
||||
@Override public void onLoad(PluginContext value) {
|
||||
value.commands().register("Ping", ignored -> { }, completion -> {
|
||||
callbackThread.set(Thread.currentThread());
|
||||
if (completion.arguments().isEmpty()) emptyArguments.complete(completion);
|
||||
else request.complete(completion);
|
||||
return suggestions;
|
||||
});
|
||||
value.commands().register("alpha", ignored -> { });
|
||||
}
|
||||
};
|
||||
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
|
||||
host.load(List.of(plugin));
|
||||
host.enable();
|
||||
|
||||
assertEquals(List.of("/alpha", "/ping"), host.commandNames(""));
|
||||
assertEquals(List.of("/ping"), host.commandNames("P"));
|
||||
assertTrue(host.completeCommand(PLAYER, "/unknown arg").isEmpty());
|
||||
assertTrue(host.completeCommand(PLAYER, "/ping").isEmpty(), "root-only completion belongs to commandNames");
|
||||
|
||||
CompletionStage<List<String>> stage = host.completeCommand(PLAYER, "/PiNg first ").orElseThrow();
|
||||
assertEquals("ping", request.get(5, TimeUnit.SECONDS).commandName());
|
||||
assertEquals(" first ", request.get(5, TimeUnit.SECONDS).arguments());
|
||||
assertNotEquals(Thread.currentThread(), callbackThread.get(), "plugin completer must run on a worker");
|
||||
List<String> completed = stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(List.of("alpha", "beta"), completed.subList(0, 2));
|
||||
assertEquals(100, completed.size());
|
||||
CompletionStage<List<String>> emptyStage = host.completeCommand(PLAYER, "/ping ").orElseThrow();
|
||||
assertEquals("", emptyArguments.get(5, TimeUnit.SECONDS).arguments());
|
||||
assertEquals(100, emptyStage.toCompletableFuture().get(5, TimeUnit.SECONDS).size());
|
||||
assertEquals(List.of(), host.completeCommand(PLAYER, "/alpha ").orElseThrow()
|
||||
.toCompletableFuture().get(1, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingHostCancelsPendingCompleterAndCompletesLocalResult() throws Exception {
|
||||
var invoked = new CountDownLatch(1);
|
||||
var pluginStageCancelled = new CountDownLatch(1);
|
||||
var pluginStage = new CompletableFuture<List<String>>();
|
||||
pluginStage.whenComplete((ignored, failure) -> pluginStageCancelled.countDown());
|
||||
Plugin plugin = new Plugin() {
|
||||
@Override public void onLoad(PluginContext context) {
|
||||
context.commands().register("wait", ignored -> { }, completion -> {
|
||||
invoked.countDown();
|
||||
return pluginStage;
|
||||
});
|
||||
}
|
||||
};
|
||||
var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1));
|
||||
host.load(List.of(plugin));
|
||||
host.enable();
|
||||
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
|
||||
.toCompletableFuture();
|
||||
assertTrue(invoked.await(5, TimeUnit.SECONDS));
|
||||
|
||||
host.close();
|
||||
|
||||
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
|
||||
() -> local.get(1, TimeUnit.SECONDS));
|
||||
assertTrue(failure.getCause() instanceof IllegalStateException);
|
||||
assertTrue(pluginStageCancelled.await(1, TimeUnit.SECONDS));
|
||||
assertTrue(pluginStage.isCancelled(), "host close should cancel the plugin's unfinished stage");
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeoutCancelsPluginStageOffTheTimerThread() throws Exception {
|
||||
var invoked = new CountDownLatch(1);
|
||||
var cancellationCallback = new CountDownLatch(1);
|
||||
var callbackThread = new AtomicReference<Thread>();
|
||||
var pluginStage = new CompletableFuture<List<String>>();
|
||||
pluginStage.whenComplete((ignored, failure) -> {
|
||||
callbackThread.set(Thread.currentThread());
|
||||
cancellationCallback.countDown();
|
||||
});
|
||||
Plugin plugin = new Plugin() {
|
||||
@Override public void onLoad(PluginContext context) {
|
||||
context.commands().register("wait", ignored -> { }, completion -> {
|
||||
invoked.countDown();
|
||||
return pluginStage;
|
||||
});
|
||||
}
|
||||
};
|
||||
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
|
||||
host.load(List.of(plugin));
|
||||
host.enable();
|
||||
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
|
||||
.toCompletableFuture();
|
||||
assertTrue(invoked.await(5, TimeUnit.SECONDS));
|
||||
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
|
||||
() -> local.get(3, TimeUnit.SECONDS));
|
||||
assertTrue(failure.getCause() instanceof java.util.concurrent.TimeoutException);
|
||||
assertTrue(cancellationCallback.await(1, TimeUnit.SECONDS));
|
||||
assertTrue(pluginStage.isCancelled());
|
||||
assertNotEquals(Thread.currentThread(), callbackThread.get());
|
||||
assertTrue(callbackThread.get().getName().startsWith("strataproxy-plugin-player-"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unregisterCancelsPendingCompletionAndRemovesTheRoot() throws Exception {
|
||||
var invoked = new CountDownLatch(1);
|
||||
var cancellationCallback = new CountDownLatch(1);
|
||||
var pluginStage = new CompletableFuture<List<String>>();
|
||||
pluginStage.whenComplete((ignored, failure) -> cancellationCallback.countDown());
|
||||
var registration = new AtomicReference<CommandRegistration>();
|
||||
Plugin plugin = new Plugin() {
|
||||
@Override public void onLoad(PluginContext context) {
|
||||
registration.set(context.commands().register("wait", ignored -> { }, completion -> {
|
||||
invoked.countDown();
|
||||
return pluginStage;
|
||||
}));
|
||||
}
|
||||
};
|
||||
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
|
||||
host.load(List.of(plugin));
|
||||
host.enable();
|
||||
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/wait ").orElseThrow()
|
||||
.toCompletableFuture();
|
||||
assertTrue(invoked.await(5, TimeUnit.SECONDS));
|
||||
|
||||
registration.get().unregister();
|
||||
|
||||
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
|
||||
() -> local.get(1, TimeUnit.SECONDS));
|
||||
assertTrue(failure.getCause() instanceof IllegalStateException);
|
||||
assertTrue(cancellationCallback.await(1, TimeUnit.SECONDS));
|
||||
assertTrue(pluginStage.isCancelled());
|
||||
assertEquals(List.of(), host.commandNames("wait"));
|
||||
assertTrue(host.completeCommand(PLAYER, "/wait ").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void completerThrowAndNullStageFailPromptly() throws Exception {
|
||||
Plugin plugin = new Plugin() {
|
||||
@Override public void onLoad(PluginContext context) {
|
||||
context.commands().register("throws", ignored -> { }, completion -> {
|
||||
throw new IllegalArgumentException("bad completer");
|
||||
});
|
||||
context.commands().register("nullstage", ignored -> { }, completion -> null);
|
||||
}
|
||||
};
|
||||
try (var host = new PluginHost(new InMemoryBackendCatalog(), players(), Duration.ofSeconds(1))) {
|
||||
host.load(List.of(plugin));
|
||||
host.enable();
|
||||
for (String command : List.of("throws", "nullstage")) {
|
||||
CompletableFuture<List<String>> local = host.completeCommand(PLAYER, "/" + command + " ")
|
||||
.orElseThrow().toCompletableFuture();
|
||||
ExecutionException failure = org.junit.jupiter.api.Assertions.assertThrows(ExecutionException.class,
|
||||
() -> local.get(500, TimeUnit.MILLISECONDS));
|
||||
assertFalse(failure.getCause() instanceof java.util.concurrent.TimeoutException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Players players() {
|
||||
return new Players() {
|
||||
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
|
||||
@Override public List<PlayerView> online() { return List.of(); }
|
||||
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
|
||||
return CompletableFuture.completedFuture(TransferResult.failed("unused"));
|
||||
}
|
||||
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, net.kyori.adventure.text.Component message) {
|
||||
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
|
||||
}
|
||||
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, net.kyori.adventure.text.Component reason) {
|
||||
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import dev.strataproxy.api.ServerDefinition;
|
||||
import dev.strataproxy.api.ServerRegistration;
|
||||
import dev.strataproxy.api.TransferResult;
|
||||
import dev.strataproxy.api.TransferStatus;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.TextComponent;
|
||||
import dev.strataproxy.core.backend.BackendCatalog;
|
||||
import dev.strataproxy.core.backend.BackendHandle;
|
||||
import dev.strataproxy.core.backend.BackendId;
|
||||
@@ -537,7 +539,7 @@ class PluginHostTest {
|
||||
host.load(List.of(plugin));
|
||||
var observed = new CompletableFuture<CommandInvocation>();
|
||||
var worker = new CompletableFuture<Thread>();
|
||||
var reply = new CompletableFuture<String>();
|
||||
var reply = new CompletableFuture<Component>();
|
||||
var registration = plugin.context.commands().register("Ping", invocation -> {
|
||||
worker.complete(Thread.currentThread());
|
||||
observed.complete(invocation);
|
||||
@@ -555,7 +557,7 @@ class PluginHostTest {
|
||||
assertNotEquals(Thread.currentThread(), worker.get(5, TimeUnit.SECONDS));
|
||||
assertEquals("ping", observed.get(5, TimeUnit.SECONDS).name());
|
||||
assertEquals("hello world", observed.get(5, TimeUnit.SECONDS).arguments());
|
||||
assertEquals("pong", reply.get(5, TimeUnit.SECONDS));
|
||||
assertEquals("pong", ((TextComponent) reply.get(5, TimeUnit.SECONDS)).content());
|
||||
registration.unregister();
|
||||
registration.unregister();
|
||||
assertFalse(host.dispatchCommand(PLAYER, "/ping", text -> { }));
|
||||
@@ -616,10 +618,10 @@ class PluginHostTest {
|
||||
assertTrue(host.dispatchCommand(PLAYER, "/slow one", text -> { }));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(host.dispatchCommand(PLAYER, "/slow two", text -> { }));
|
||||
var rejectedReply = new CompletableFuture<String>();
|
||||
var rejectedReply = new CompletableFuture<Component>();
|
||||
assertTrue(host.dispatchCommand(PLAYER, "/slow three", rejectedReply::complete));
|
||||
assertEquals("Proxy command service is busy. Please try again.",
|
||||
rejectedReply.get(1, TimeUnit.SECONDS));
|
||||
((TextComponent) rejectedReply.get(1, TimeUnit.SECONDS)).content());
|
||||
assertEquals(1, calls.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
@@ -782,15 +784,15 @@ class PluginHostTest {
|
||||
@Override public java.util.concurrent.CompletionStage<TransferResult> transfer(
|
||||
PlayerIdentity identity, String backendName) { throw new AssertionError("unexpected transfer"); }
|
||||
@Override public java.util.concurrent.CompletionStage<MessageResult> sendMessage(
|
||||
PlayerIdentity identity, String message) {
|
||||
PlayerIdentity identity, Component message) {
|
||||
assertEquals(PLAYER.identity(), identity);
|
||||
assertEquals("hello", message);
|
||||
assertEquals("hello", ((TextComponent) message).content());
|
||||
return messageResult;
|
||||
}
|
||||
@Override public java.util.concurrent.CompletionStage<DisconnectResult> disconnect(
|
||||
PlayerIdentity identity, String reason) {
|
||||
PlayerIdentity identity, Component reason) {
|
||||
assertEquals(PLAYER.identity(), identity);
|
||||
assertEquals("maintenance", reason);
|
||||
assertEquals("maintenance", ((TextComponent) reason).content());
|
||||
return disconnectResult;
|
||||
}
|
||||
};
|
||||
@@ -994,11 +996,11 @@ class PluginHostTest {
|
||||
|
||||
private abstract static class TestPlayers implements Players {
|
||||
@Override public java.util.concurrent.CompletionStage<MessageResult> sendMessage(
|
||||
PlayerIdentity identity, String message) {
|
||||
PlayerIdentity identity, Component message) {
|
||||
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
|
||||
}
|
||||
@Override public java.util.concurrent.CompletionStage<DisconnectResult> disconnect(
|
||||
PlayerIdentity identity, String reason) {
|
||||
PlayerIdentity identity, Component reason) {
|
||||
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.strataproxy.core.protocol;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MinecraftTextTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void templateVariablesRemainLiteralAndPrimitiveArgumentsAreBounded() throws Exception {
|
||||
var message = net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().deserialize(
|
||||
"<green>Hello <player></green>",
|
||||
net.kyori.adventure.text.minimessage.tag.resolver.Placeholder.unparsed("player", "<red>Ada"));
|
||||
var encoded = JSON.readTree(MinecraftText.encode(message));
|
||||
assertTrue(encoded.toString().contains("<red>Ada"));
|
||||
var arguments = new java.util.ArrayList<net.kyori.adventure.text.TranslationArgument>();
|
||||
for (int i = 0; i < 300; i++) arguments.add(net.kyori.adventure.text.TranslationArgument.numeric(i));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> MinecraftText.encode(Component.translatable("key").arguments(arguments)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesLegacyTextTranslationStyleClickAndHoverJson() throws Exception {
|
||||
Component component = Component.translatable("chat.type.text", Component.text("Ada"),
|
||||
Component.text("hello"))
|
||||
.color(NamedTextColor.RED)
|
||||
.decorate(TextDecoration.BOLD)
|
||||
.clickEvent(ClickEvent.runCommand("/hello"))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("Show details")));
|
||||
|
||||
JsonNode encoded = JSON.readTree(MinecraftText.encode(component));
|
||||
|
||||
assertEquals("chat.type.text", encoded.path("translate").textValue());
|
||||
assertEquals(2, encoded.path("with").size());
|
||||
assertEquals("red", encoded.path("color").textValue());
|
||||
assertTrue(encoded.path("bold").booleanValue());
|
||||
assertEquals("run_command", encoded.path("clickEvent").path("action").textValue());
|
||||
assertEquals("/hello", encoded.path("clickEvent").path("value").textValue());
|
||||
assertEquals("show_text", encoded.path("hoverEvent").path("action").textValue());
|
||||
JsonNode hoverValue = encoded.path("hoverEvent").path("value");
|
||||
assertEquals("Show details", hoverValue.isTextual()
|
||||
? hoverValue.textValue() : hoverValue.path("text").textValue());
|
||||
assertTrue(encoded.path("hoverEvent").has("value"));
|
||||
assertTrue(!encoded.path("hoverEvent").has("contents"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsTextExtraAndAllThreeMinecraftClickActions() throws Exception {
|
||||
for (ClickEvent<?> click : new ClickEvent<?>[]{
|
||||
ClickEvent.openUrl("https://example.test"),
|
||||
ClickEvent.runCommand("/home"),
|
||||
ClickEvent.suggestCommand("/spawn")}) {
|
||||
Component component = Component.text("go").clickEvent(click)
|
||||
.append(Component.text(" now", NamedTextColor.GREEN));
|
||||
JsonNode encoded = JSON.readTree(MinecraftText.encode(component));
|
||||
assertEquals(click.action().toString().toLowerCase(), encoded.path("clickEvent").path("action").textValue());
|
||||
assertEquals(" now", encoded.path("extra").get(0).path("text").textValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsComponentKindsAndEventsWithoutMinecraft1710Support() {
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.keybind("key.jump")));
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.selector("@a")));
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
|
||||
.clickEvent(ClickEvent.copyToClipboard("x"))));
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
|
||||
.hoverEvent(HoverEvent.showItem(Key.key("minecraft:stone"), 1))));
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(Component.text("x")
|
||||
.insertion("insert")));
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(
|
||||
Component.translatable("hello").fallback("Hello")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankReasonsAndBoundsTheTreeBeforeSerialization() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> MinecraftText.encodeReason(Component.text(" \n\t")));
|
||||
|
||||
Component tooDeep = Component.text("leaf");
|
||||
for (int i = 0; i < 32; i++) tooDeep = Component.text("x").hoverEvent(HoverEvent.showText(tooDeep));
|
||||
Component deep = tooDeep;
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(deep));
|
||||
|
||||
Component[] children = new Component[256];
|
||||
for (int i = 0; i < children.length; i++) children[i] = Component.text("x");
|
||||
Component wide = Component.textOfChildren(children);
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftText.encode(wide));
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> MinecraftText.encode(Component.text("x".repeat(MinecraftText.MAX_JSON_BYTES + 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void packetEncodersRejectOversizedPreencodedJsonBeforeAllocation() {
|
||||
String tooLarge = "\"" + "x".repeat(MinecraftText.MAX_JSON_BYTES) + "\"";
|
||||
assertThrows(IllegalArgumentException.class, () -> Minecraft1710PlayPackets.chatReplyEncoded(
|
||||
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
|
||||
assertThrows(IllegalArgumentException.class, () -> Minecraft1710PlayPackets.disconnectEncoded(
|
||||
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
|
||||
assertThrows(IllegalArgumentException.class, () -> MinecraftLoginDisconnect.encodeJson(
|
||||
io.netty.buffer.UnpooledByteBufAllocator.DEFAULT, tooLarge));
|
||||
assertNotNull(MinecraftText.encode(Component.text("valid")));
|
||||
}
|
||||
}
|
||||
+45
-5
@@ -767,6 +767,19 @@ final class ProxySessionListenerTest {
|
||||
data.writeFloat(0);
|
||||
data.writeByte(0);
|
||||
writeFrame(output, position.toByteArray());
|
||||
var rootRequest = new java.io.ByteArrayInputStream(readFrame(input));
|
||||
assertEquals(0x14, readVarInt(rootRequest));
|
||||
assertEquals("/w", readString(rootRequest, 32767));
|
||||
var rootReply = new ByteArrayOutputStream();
|
||||
writeVarInt(rootReply, 0x3a); writeVarInt(rootReply, 2);
|
||||
writeString(rootReply, "/weather"); writeString(rootReply, "/where");
|
||||
writeFrame(output, rootReply.toByteArray());
|
||||
var unknownRequest = new java.io.ByteArrayInputStream(readFrame(input));
|
||||
assertEquals(0x14, readVarInt(unknownRequest));
|
||||
assertEquals("/weather c", readString(unknownRequest, 32767));
|
||||
var unknownReply = new ByteArrayOutputStream();
|
||||
writeVarInt(unknownReply, 0x3a); writeVarInt(unknownReply, 1); writeString(unknownReply, "clear");
|
||||
writeFrame(output, unknownReply.toByteArray());
|
||||
received.complete(List.of(readFrame(input), readFrame(input)));
|
||||
} catch (Throwable failure) {
|
||||
received.completeExceptionally(failure);
|
||||
@@ -782,8 +795,11 @@ final class ProxySessionListenerTest {
|
||||
@Override public void onLoad(PluginContext context) {
|
||||
context.commands().register("where", invocation -> {
|
||||
handlerThread.complete(Thread.currentThread().getName());
|
||||
invocation.reply(invocation.player().username() + ":" + invocation.arguments());
|
||||
});
|
||||
invocation.reply(net.kyori.adventure.text.Component.text(
|
||||
invocation.player().username() + ":" + invocation.arguments(),
|
||||
net.kyori.adventure.text.format.NamedTextColor.GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/where lobby")));
|
||||
}, completion -> { assertEquals("lo ", completion.arguments()); return CompletableFuture.completedFuture(List.of("lobby")); });
|
||||
context.events().subscribe(ServerConnectedEvent.class, event -> {
|
||||
assertFalse(Thread.currentThread().getName().startsWith("strataproxy-session-io"));
|
||||
notificationCount.incrementAndGet();
|
||||
@@ -809,6 +825,7 @@ final class ProxySessionListenerTest {
|
||||
listener.setPlacement(plugins::placeInitial);
|
||||
listener.setEvents(plugins, Duration.ofSeconds(5));
|
||||
listener.setCommandDispatcher(plugins::dispatchCommand);
|
||||
listener.setCommandCompletion(plugins::commandNames, plugins::completeCommand);
|
||||
int port = ((InetSocketAddress) listener.start().toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).localAddress()).getPort();
|
||||
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), port)) {
|
||||
@@ -825,8 +842,17 @@ final class ProxySessionListenerTest {
|
||||
writeFrame(output, chatPacket("/where lobby"));
|
||||
var reply = new java.io.ByteArrayInputStream(readFrame(input));
|
||||
assertEquals(2, readVarInt(reply));
|
||||
assertEquals("CommandUser:lobby",
|
||||
new ObjectMapper().readTree(readString(reply, 32767)).path("text").asText());
|
||||
var replyJson = new ObjectMapper().readTree(readString(reply, 32767));
|
||||
assertEquals("CommandUser:lobby", replyJson.path("text").asText());
|
||||
assertEquals("green", replyJson.path("color").asText());
|
||||
assertEquals("suggest_command", replyJson.path("clickEvent").path("action").asText());
|
||||
assertEquals("/where lobby", replyJson.path("clickEvent").path("value").asText());
|
||||
writeFrame(output, completionRequest("/where lo "));
|
||||
assertEquals(List.of("lobby"), completionResponse(readFrame(input)));
|
||||
writeFrame(output, completionRequest("/w"));
|
||||
assertEquals(List.of("/where", "/weather"), completionResponse(readFrame(input)));
|
||||
writeFrame(output, completionRequest("/weather c"));
|
||||
assertEquals(List.of("clear"), completionResponse(readFrame(input)));
|
||||
assertTrue(handlerThread.get(5, TimeUnit.SECONDS).startsWith("strataproxy-plugin-command-"));
|
||||
assertEquals(2, accessCalls.get());
|
||||
writeFrame(output, chatPacket("hello"));
|
||||
@@ -844,6 +870,20 @@ final class ProxySessionListenerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] completionRequest(String text) throws Exception {
|
||||
var packet = new ByteArrayOutputStream();
|
||||
writeVarInt(packet, 0x14); writeString(packet, text);
|
||||
return packet.toByteArray();
|
||||
}
|
||||
private static List<String> completionResponse(byte[] frame) throws Exception {
|
||||
var packet = new java.io.ByteArrayInputStream(frame);
|
||||
assertEquals(0x3a, readVarInt(packet));
|
||||
int count = readVarInt(packet);
|
||||
var values = new ArrayList<String>();
|
||||
for (int i = 0; i < count; i++) values.add(readString(packet, 32767));
|
||||
return values;
|
||||
}
|
||||
|
||||
private static byte[] chatPacket(String text) throws Exception {
|
||||
var packet = new ByteArrayOutputStream();
|
||||
writeVarInt(packet, 1);
|
||||
@@ -1611,7 +1651,7 @@ final class ProxySessionListenerTest {
|
||||
listener.setCommandDispatcher((player, message, reply, admission) -> {
|
||||
if (!message.equals("/ping")) return false;
|
||||
if (!admission.getAsBoolean()) return true;
|
||||
reply.accept("pong");
|
||||
reply.accept(net.kyori.adventure.text.Component.text("pong"));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -87,6 +87,9 @@ final class SessionTransferTest {
|
||||
register(catalog, "old", oldServer);
|
||||
register(catalog, "new", newServer);
|
||||
var listener = listener(catalog);
|
||||
listener.setCommandCompletion(prefix -> List.of("/test"),
|
||||
(player, text) -> Optional.of(CompletableFuture.completedFuture(
|
||||
List.of(player.currentServer().orElseThrow()))));
|
||||
try {
|
||||
int port = ((InetSocketAddress) listener.start().toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).localAddress()).getPort();
|
||||
@@ -98,6 +101,7 @@ final class SessionTransferTest {
|
||||
assertEquals(1, packetId(readFrame(input)));
|
||||
assertEquals(8, packetId(readFrame(input)));
|
||||
var player = awaitPlayer(listener);
|
||||
assertLocalCompletion(client, input, "old");
|
||||
|
||||
var session = listener.allSessions().iterator().next();
|
||||
var frontendField = Session.class.getDeclaredField("frontend");
|
||||
@@ -144,6 +148,7 @@ final class SessionTransferTest {
|
||||
assertEquals(8, packetId(readFrame(input)));
|
||||
assertArrayEquals(new byte[]{0x01, 0x55}, newReceived.get(5, TimeUnit.SECONDS));
|
||||
oldClosed.get(5, TimeUnit.SECONDS);
|
||||
assertLocalCompletion(client, input, "new");
|
||||
}
|
||||
} finally { listener.close().toCompletableFuture().get(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
@@ -1589,6 +1594,17 @@ final class SessionTransferTest {
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static void assertLocalCompletion(Socket client, DataInputStream input, String backend) throws Exception {
|
||||
var body = new ByteArrayOutputStream();
|
||||
var request = new DataOutputStream(body);
|
||||
writeVarInt(request, 0x14); writeString(request, "/test ");
|
||||
writeFrame(new DataOutputStream(client.getOutputStream()), body.toByteArray());
|
||||
var response = new ByteArrayInputStream(readFrame(input));
|
||||
assertEquals(0x3a, readVarInt(response));
|
||||
assertEquals(1, readVarInt(response));
|
||||
assertEquals(backend, new String(response.readNBytes(readVarInt(response)), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static int packetId(byte[] bytes) throws Exception {
|
||||
return readVarInt(new ByteArrayInputStream(bytes));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package dev.strataproxy.core.session;
|
||||
|
||||
import dev.strataproxy.core.protocol.Minecraft1710PlayPackets;
|
||||
import dev.strataproxy.core.protocol.MinecraftTabCompletion;
|
||||
import dev.strataproxy.core.protocol.ProtocolVarInt;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TabCompletionBridgeTest {
|
||||
private static ByteBuf request(String text) {
|
||||
ByteBuf body = Unpooled.buffer();
|
||||
body.writeByte(0x14);
|
||||
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||
ProtocolVarInt.write(body, bytes.length);
|
||||
body.writeBytes(bytes);
|
||||
try { return Minecraft1710PlayPackets.frame(UnpooledByteBufAllocatorHolder.ALLOC, body); }
|
||||
finally { body.release(); }
|
||||
}
|
||||
private static final class UnpooledByteBufAllocatorHolder {
|
||||
static final io.netty.buffer.ByteBufAllocator ALLOC = io.netty.buffer.UnpooledByteBufAllocator.DEFAULT;
|
||||
}
|
||||
private static List<String> read(EmbeddedChannel client) {
|
||||
ByteBuf frame = client.readOutbound();
|
||||
assertNotNull(frame);
|
||||
try { return MinecraftTabCompletion.response(frame); } finally { frame.release(); }
|
||||
}
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
final EmbeddedChannel client = new EmbeddedChannel();
|
||||
final EmbeddedChannel backend = new EmbeddedChannel();
|
||||
final CompletableFuture<List<String>> result = new CompletableFuture<>();
|
||||
final AtomicBoolean closed = new AtomicBoolean();
|
||||
final TabCompletionBridge bridge = new TabCompletionBridge(client,
|
||||
prefix -> List.of("/proxy"),
|
||||
text -> text.startsWith("/proxy ") ? Optional.of(result) : Optional.empty(),
|
||||
() -> { closed.set(true); });
|
||||
Fixture() {
|
||||
client.pipeline().addLast(bridge.frontendHandler());
|
||||
backend.pipeline().addLast(bridge.backendHandler());
|
||||
}
|
||||
void backendReply(List<String> values) {
|
||||
backend.writeInbound(MinecraftTabCompletion.response(backend.alloc(), values));
|
||||
}
|
||||
void releaseForwarded() { ByteBuf frame = client.readInbound(); assertNotNull(frame); frame.release(); }
|
||||
@Override public void close() { bridge.close(); client.finishAndReleaseAll(); backend.finishAndReleaseAll(); }
|
||||
}
|
||||
|
||||
@Test void mergesRootNamesAndDeduplicates() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/p")); f.releaseForwarded();
|
||||
f.backendReply(List.of("/proxy", "/plugins"));
|
||||
assertEquals(List.of("/proxy", "/plugins"), read(f.client));
|
||||
assertFalse(f.closed.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void localArgumentsNeverReachBackendAndCompleteOffCallback() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/proxy "));
|
||||
assertNull(f.client.readInbound());
|
||||
f.result.complete(List.of("lobby", "islands"));
|
||||
f.client.runPendingTasks();
|
||||
assertEquals(List.of("lobby", "islands"), read(f.client));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unknownCommandResponsePreservesExactFrame() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/backend arg")); f.releaseForwarded();
|
||||
ByteBuf original = MinecraftTabCompletion.response(f.backend.alloc(), List.of("argument"));
|
||||
ByteBuf expected = original.copy();
|
||||
f.backend.writeInbound(original);
|
||||
ByteBuf actual = f.client.readOutbound();
|
||||
try { assertEquals(expected, actual); } finally { expected.release(); actual.release(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void timedOutBackendReplyCannotCompleteNewLocalRequest() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/p")); f.releaseForwarded();
|
||||
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks();
|
||||
assertEquals(List.of("/proxy"), read(f.client));
|
||||
f.client.writeInbound(request("/proxy "));
|
||||
f.backendReply(List.of("stale"));
|
||||
assertNull(f.client.readOutbound());
|
||||
f.result.complete(List.of("fresh")); f.client.runPendingTasks();
|
||||
assertEquals(List.of("fresh"), read(f.client));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void doesNotIssueAnotherBackendRequestUntilExpiredReplyIsConsumed() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/other arg")); f.releaseForwarded();
|
||||
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks(); read(f.client);
|
||||
f.client.writeInbound(request("/other next"));
|
||||
assertNull(f.client.readInbound()); assertEquals(List.of(), read(f.client));
|
||||
f.backendReply(List.of("old")); assertNull(f.client.readOutbound());
|
||||
f.client.writeInbound(request("/other last")); f.releaseForwarded();
|
||||
f.backendReply(List.of("last")); assertEquals(List.of("last"), read(f.client));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void closesGenerationAndCancelsPluginWorkReleasingQueuedFrames() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/proxy "));
|
||||
ByteBuf queued = request("/p"); f.client.writeInbound(queued);
|
||||
f.bridge.close();
|
||||
assertTrue(f.result.isCancelled()); assertEquals(0, queued.refCnt());
|
||||
f.client.runPendingTasks(); assertNull(f.client.readOutbound());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryFramesPassThroughByIdentity() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
ByteBuf frame = Unpooled.wrappedBuffer(new byte[]{2, 1, 0});
|
||||
f.client.writeInbound(frame);
|
||||
assertSame(frame, f.client.readInbound()); frame.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void localTimeoutReturnsEmptyAndCancelsStage() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/proxy "));
|
||||
f.client.advanceTimeBy(2, TimeUnit.SECONDS); f.client.runScheduledPendingTasks();
|
||||
assertEquals(List.of(), read(f.client)); assertTrue(f.result.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementGenerationIgnoresOldBackendReplies() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
EmbeddedChannel replacement = new EmbeddedChannel();
|
||||
f.client.writeInbound(request("/p")); f.releaseForwarded();
|
||||
f.bridge.close();
|
||||
f.client.pipeline().removeFirst();
|
||||
var next = new TabCompletionBridge(f.client, prefix -> List.of("/new"),
|
||||
text -> Optional.empty(), () -> f.closed.set(true));
|
||||
try {
|
||||
f.client.pipeline().addLast(next.frontendHandler());
|
||||
replacement.pipeline().addLast(next.backendHandler());
|
||||
f.client.writeInbound(request("/n")); f.releaseForwarded();
|
||||
f.backendReply(List.of("/old"));
|
||||
assertNull(f.client.readOutbound());
|
||||
replacement.writeInbound(MinecraftTabCompletion.response(replacement.alloc(), List.of("/native")));
|
||||
assertEquals(List.of("/new", "/native"), read(f.client));
|
||||
} finally { next.close(); replacement.finishAndReleaseAll(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedRequestsStayInOrderAndOverflowClosesConnection() {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/proxy "));
|
||||
for (int i = 0; i < 8; i++) f.client.writeInbound(request("/p"));
|
||||
ByteBuf overflow = request("/p"); f.client.writeInbound(overflow);
|
||||
assertTrue(f.closed.get()); assertEquals(0, overflow.refCnt());
|
||||
}
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.client.writeInbound(request("/proxy "));
|
||||
f.client.writeInbound(request("/p"));
|
||||
assertNull(f.client.readInbound());
|
||||
f.result.complete(List.of("first")); f.client.runPendingTasks();
|
||||
assertEquals(List.of("first"), read(f.client));
|
||||
f.releaseForwarded();
|
||||
f.backendReply(List.of("/second"));
|
||||
assertEquals(List.of("/proxy", "/second"), read(f.client));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ plugins {
|
||||
|
||||
dependencies {
|
||||
api(libs.findLibrary("slf4j-api").get())
|
||||
api(libs.findLibrary("adventure-api").get())
|
||||
api(libs.findLibrary("adventure-minimessage").get())
|
||||
}
|
||||
|
||||
java {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
/** Allows or denies a connection or login access check. */
|
||||
public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDecision.Denied {
|
||||
@@ -9,6 +10,11 @@ public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDec
|
||||
}
|
||||
|
||||
static AccessDecision deny(String reason) {
|
||||
PlainTextValidation.validateString(reason, false, "reason");
|
||||
return new Denied(Component.text(reason));
|
||||
}
|
||||
|
||||
static AccessDecision deny(Component reason) {
|
||||
return new Denied(reason);
|
||||
}
|
||||
|
||||
@@ -16,12 +22,9 @@ public sealed interface AccessDecision permits AccessDecision.Allowed, AccessDec
|
||||
private static final Allowed INSTANCE = new Allowed();
|
||||
}
|
||||
|
||||
record Denied(String reason) implements AccessDecision {
|
||||
record Denied(Component reason) implements AccessDecision {
|
||||
public Denied {
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
|
||||
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/**
|
||||
* Produces last-token suggestions asynchronously on bounded plugin workers. The plugin owns prefix matching;
|
||||
* the proxy validates and bounds results but does not filter them against the player's current token.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface CommandCompleter {
|
||||
CompletionStage<List<String>> complete(CommandCompletion completion) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable context for completing raw arguments after a known command root, excluding one separator. */
|
||||
public record CommandCompletion(PlayerView player, String commandName, String arguments) {
|
||||
public CommandCompletion {
|
||||
Objects.requireNonNull(player, "player");
|
||||
Objects.requireNonNull(commandName, "commandName");
|
||||
Objects.requireNonNull(arguments, "arguments");
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,16 @@ package dev.strataproxy.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
/** One player command. The raw arguments exclude the leading command name and separator. */
|
||||
public final class CommandInvocation {
|
||||
private final PlayerView player;
|
||||
private final String name;
|
||||
private final String arguments;
|
||||
private final Consumer<String> reply;
|
||||
private final Consumer<Component> reply;
|
||||
|
||||
public CommandInvocation(PlayerView player, String name, String arguments, Consumer<String> reply) {
|
||||
public CommandInvocation(PlayerView player, String name, String arguments, Consumer<Component> reply) {
|
||||
this.player = Objects.requireNonNull(player, "player");
|
||||
this.name = Objects.requireNonNull(name, "name");
|
||||
this.arguments = Objects.requireNonNull(arguments, "arguments");
|
||||
@@ -21,8 +22,14 @@ public final class CommandInvocation {
|
||||
public String name() { return name; }
|
||||
public String arguments() { return arguments; }
|
||||
|
||||
/** Queues plain text chat if the session is connected and writable; excess replies may be dropped. */
|
||||
public void reply(String message) {
|
||||
/** Queues rich text if the session is connected and writable; excess replies may be dropped. */
|
||||
public void reply(Component message) {
|
||||
reply.accept(Objects.requireNonNull(message, "message"));
|
||||
}
|
||||
|
||||
/** Queues plain text, retaining the 1024 Unicode code point convenience limit. */
|
||||
public void reply(String message) {
|
||||
PlainTextValidation.validateString(message, true, "message");
|
||||
reply.accept(Component.text(message));
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,10 @@ public interface Commands {
|
||||
* Reserves a root command name. Names are case insensitive and unique across plugins.
|
||||
* An unregistered command continues to the backend unchanged.
|
||||
*/
|
||||
CommandRegistration register(String name, CommandHandler handler);
|
||||
CommandRegistration register(String name, CommandHandler handler, CommandCompleter completer);
|
||||
|
||||
/** Registers a command without argument completion. */
|
||||
default CommandRegistration register(String name, CommandHandler handler) {
|
||||
return register(name, handler, null);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.Objects;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
/** Decision returned by an initial placement handler. */
|
||||
public sealed interface PlacementDecision permits PlacementDecision.Select, PlacementDecision.Reject {
|
||||
@@ -9,6 +10,11 @@ public sealed interface PlacementDecision permits PlacementDecision.Select, Plac
|
||||
}
|
||||
|
||||
static PlacementDecision reject(String reason) {
|
||||
PlainTextValidation.validateString(reason, false, "reason");
|
||||
return new Reject(Component.text(reason));
|
||||
}
|
||||
|
||||
static PlacementDecision reject(Component reason) {
|
||||
return new Reject(reason);
|
||||
}
|
||||
|
||||
@@ -21,12 +27,9 @@ public sealed interface PlacementDecision permits PlacementDecision.Select, Plac
|
||||
}
|
||||
}
|
||||
|
||||
record Reject(String reason) implements PlacementDecision {
|
||||
record Reject(Component reason) implements PlacementDecision {
|
||||
public Reject {
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (reason.isBlank() || reason.codePointCount(0, reason.length()) > 1024) {
|
||||
throw new IllegalArgumentException("reason must contain 1 to 1024 characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Shared validation for the plain String convenience overloads. */
|
||||
final class PlainTextValidation {
|
||||
private PlainTextValidation() { }
|
||||
|
||||
static void validateString(String text, boolean allowEmpty, String name) {
|
||||
Objects.requireNonNull(text, name);
|
||||
int length = text.codePointCount(0, text.length());
|
||||
if ((!allowEmpty && text.isBlank()) || length > 1024) {
|
||||
throw new IllegalArgumentException(name + (allowEmpty
|
||||
? " must contain at most 1024 Unicode code points"
|
||||
: " must contain 1 to 1024 Unicode code points"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package dev.strataproxy.api;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
@@ -21,24 +23,36 @@ public interface Players {
|
||||
CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName);
|
||||
|
||||
/**
|
||||
* Sends a plain-text message to this exact connection. Text may contain up to 1024 Unicode
|
||||
* code points, including an empty message. Login, initial Forge negotiation, and transfer
|
||||
* Sends a rich-text message to this exact connection. Components are bounded and validated
|
||||
* before serialization. Login, initial Forge negotiation, and transfer
|
||||
* transitions return NOT_READY. At most 64 messages per connection may be outstanding;
|
||||
* a full queue or unwritable connection returns BACKPRESSURED without waiting for capacity.
|
||||
* SENT means the network write completed, not that the client displayed the message.
|
||||
* Network write failures complete the stage exceptionally; invalid arguments throw immediately.
|
||||
* Through PluginContext, the result completes off the player's I/O loop.
|
||||
*/
|
||||
CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message);
|
||||
CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message);
|
||||
|
||||
/** Sends plain text with a 1024 Unicode code point limit; the empty string is allowed. */
|
||||
default CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, String message) {
|
||||
PlainTextValidation.validateString(message, true, "message");
|
||||
return sendMessage(identity, Component.text(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes this exact connection, including an identified player still in admission or placement.
|
||||
* The nonblank plain-text reason must contain at most 1024 Unicode code points. Its delivery is
|
||||
* The reason must render as nonblank text. Its delivery is
|
||||
* best effort, with at most five seconds allowed for the write before closing. DISCONNECTED
|
||||
* means the connection has been cleaned up and closed, not that the client read the reason.
|
||||
* Repeated requests while closing join the same operation. Cancelling the returned stage does
|
||||
* not undo an accepted disconnect. Stale connection identities return NOT_CONNECTED.
|
||||
* Invalid arguments throw immediately. Through PluginContext, completion occurs off I/O.
|
||||
*/
|
||||
CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason);
|
||||
CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason);
|
||||
|
||||
/** Disconnects with plain text, limited to 1 to 1024 Unicode code points. */
|
||||
default CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, String reason) {
|
||||
PlainTextValidation.validateString(reason, false, "reason");
|
||||
return disconnect(identity, Component.text(reason));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
class ApiValueTypesTest {
|
||||
@Test
|
||||
@@ -47,14 +48,16 @@ class ApiValueTypesTest {
|
||||
|
||||
@Test
|
||||
void placementRejectionBoundsTheClientVisibleReason() {
|
||||
assertEquals("Try again later", ((PlacementDecision.Reject) PlacementDecision.reject("Try again later")).reason());
|
||||
assertEquals(Component.text("Try again later"),
|
||||
((PlacementDecision.Reject) PlacementDecision.reject("Try again later")).reason());
|
||||
assertThrows(IllegalArgumentException.class, () -> PlacementDecision.reject(" "));
|
||||
assertThrows(IllegalArgumentException.class, () -> PlacementDecision.reject("x".repeat(1025)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDenialBoundsTheClientVisibleReason() {
|
||||
assertEquals("Blocked", ((AccessDecision.Denied) AccessDecision.deny("Blocked")).reason());
|
||||
assertEquals(Component.text("Blocked"),
|
||||
((AccessDecision.Denied) AccessDecision.deny("Blocked")).reason());
|
||||
assertThrows(IllegalArgumentException.class, () -> AccessDecision.deny(" "));
|
||||
assertThrows(IllegalArgumentException.class, () -> AccessDecision.deny("x".repeat(1025)));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user