RhythMC-Reborn WebSocket 协议文档
目录
协议概述
基本信息
| 项目 | 值 |
|---|---|
| 协议 | WebSocket over TLS (WSS) |
| 数据格式 | JSON |
| 字符编码 | UTF-8 |
| 心跳间隔 | 30秒 (由HTTP客户端配置) |
实现说明:插件打包时不再 relocate fastjson2,保留其原始包名,避免 fastjson2 的 ASM 反序列化 reader 在运行时回查原始 com.alibaba.fastjson2.* 类名时触发 NoClassDefFoundError。
架构位置
类型层次
恢复语义
- 服务端在
serverSessionWebSocket 意外断开后,会保留玩家队列、房间和待处理对战请求一段恢复窗口,而不是立刻把玩家视为主动退出。 - 客户端在 WebSocket 重新连上后,必须继续为每个在线玩家发送一次
resume_session,服务端才会把这些运行态重新绑定到新的连接。 - 如果恢复窗口内没有重新连回,后端的
serverSession清理器才会执行真正的强制下线与房间清理。
连接管理
连接生命周期
连接参数
// 配置参数
maxRetry = 5 // 退避上限阶段,不再代表最终停止次数
reconnectDelayMs = 1000 // 初始重连延迟(毫秒)
// 指数退避算法
delay = reconnectDelayMs * 2^min(retryCount, maxRetry - 1)
握手认证
- WebSocket 握手统一使用
Authorization: Bearer <serverAccessToken>。 - 客户端不再在连接 URL query 中附带
serverSessionId。 - 后端在握手成功后从 Bearer token 反查当前
serverSession,再将后续resume_session、聊天、对战等玩家动作绑定到该服务器会话。
重连时序图
恢复语义
- 服务端在
serverSessionWebSocket 意外断开后,会保留玩家队列、房间和待处理对战请求一段恢复窗口,而不是立刻把玩家视为主动退出。 - 客户端在 WebSocket 重新连上后,必须继续为每个在线玩家发送一次
resume_session,服务端才会把这些运行态重新绑定到新的连接。 - 如果恢复窗口内没有重新连回,后端的
serverSession清理器才会执行真正的强制下线与房间清理。
消息格式
通用结构
所有 WebSocket 消息都包含 type 字段用于类型识别:
{
"type": "message_type",
// ... 其他字段
}
序列化/反序列化
使用 JsonUtils 工具类处理:
// 序列化
String json = JsonUtils.toJson(wsPayload);
// 反序列化 (根据type字段自动识别)
WsPayload payload = JsonUtils.fromWsJson(json);
类型路由
switch (type) {
case "ping" -> Ping.class
case "pong" -> Pong.class
default -> throw IllegalArgumentException
}
消息类型
1. Ping (心跳请求)
方向: Server → Client
用途: 检测客户端连接状态
{
"type": "ping"
}
字段:
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 "ping" |
2. Pong (心跳响应)
方向: Client → Server
用途: 响应心跳请求
{
"type": "pong"
}
字段:
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 "pong" |
实时同步流程
心跳保活机制
数据包详解
WsPayload 接口
public sealed interface WsPayload
permits Ping, Pong {
String type();
}
设计特点:
- Sealed Interface: 限制实现类型,确保类型安全
- 统一type方法: 所有消息必须提供类型标识
完整类型定义
// Ping - 心跳请求
public record Ping(String type) implements WsPayload {
public static final String TYPE = "ping";
public static Ping create() {
return new Ping(TYPE);
}
}
// Pong - 心跳响应
public record Pong(String type) implements WsPayload {
public static final String TYPE = "pong";
public static Pong create() {
return new Pong(TYPE);
}
}
消息类型常量表
| 类型常量 | 值 | 方向 | 用途 |
|---|---|---|---|
| Ping.TYPE | "ping" | S→C | 心跳请求 |
| Pong.TYPE | "pong" | C→S | 心跳响应 |
状态管理
NetworkStatus 枚举
public enum NetworkStatus {
OFFLINE("&4📱"), // 离线
CONNECTING("&e📱"), // 连接中
ONLINE("&2📱"), // 在线
LAGGING("&6📱"); // 延迟
}
状态转换图:
状态转换规则:
| 当前状态 | 事件 | 新状态 |
|---|---|---|
| OFFLINE | connect() | CONNECTING |
| CONNECTING | onOpen | ONLINE |
| CONNECTING | onFailure | CONNECTING (持续重试) |
| ONLINE | onClosed/onFailure | CONNECTING (自动重连) |
| ONLINE | 心跳超时 | LAGGING |
| LAGGING | 心跳恢复 | ONLINE |
| 任意 | disconnect() | OFFLINE |
错误处理
错误类型
| 错误 | 处理方式 |
|---|---|
| 连接失败 | 指数退避重连 |
| 消息解析失败 | 忽略该消息,记录日志 |
| 监听器异常 | 捕获异常,不影响其他监听器 |
| 握手 401/403 | 尝试 /server/refresh,失败后才重建服务器认证 |
日志级别
| 级别 | 场景 |
|---|---|
| INFO | 连接成功、重连调度、正常断开 |
| WARNING | 连接失败、消息解析错误 |
| SEVERE | 初始化失败等不可恢复错误 |
2026-03-18 Update: kick_player
Packet
Direction: Server -> Client
{
"type": "kick_player",
"playerId": 12345,
"message": "&cYou have been banned.\n&7Reason: &fabusive chat"
}
Fields:
| Field | Type | Description |
|---|---|---|
| type | String | fixed value kick_player |
| playerId | int | target RhythMC player uid on the current game server |
| message | String | final kick text shown to the player |
Client behavior
- Resolve
playerIdto the activeRhyPlayer. - If that player is online, immediately kick the Bukkit player with
message. - Intended for admin ban and other forced disconnect scenarios.
Binding requirement
kick_playerdepends on the player already being bound to the current server WebSocket.- The client therefore now sends
resume_sessionafter successful login sync, not only after reconnect. resume_session使用玩家短期 access token;服务端会同时校验该玩家 access token 属于当前 WebSocket 已绑定的 server session。- 客户端还会按
config.yml -> net.player-session-refresh-interval-seconds周期性重发resume_session相关玩家状态同步,用于在玩家空闲时持续续期短期 access token;默认 240 秒一次。
2026-03-18 Update: Cross-Server Chat And Private Messaging
Client entry points
- Global shout uses configurable aliases from
config.yml -> chat.global-command-aliases. - The default alias is
hh, so/hh <message>sends a cross-server global chat message. - Cross-server private messaging uses
/pm <player> <message>. - These chat commands are registered through ACF, so the client can see them as valid commands and request tab completion for
/pmtargets. - Chat delivery is routed to currently bound server WebSocket sessions; PM falls back to
TARGET_OFFLINEif the target session is no longer websocket-bound.
Packet: global_chat_request
Direction: Client -> Server
{
"type": "global_chat_request",
"playerId": 12345,
"accessToken": "player-access-token",
"message": "大家晚上好"
}
Fields:
| Field | Type | Description |
|---|---|---|
| type | String | fixed value global_chat_request |
| playerId | int | sender RhythMC uid |
| accessToken | String | current player access token, used for auth and server-session binding |
| message | String | final chat content after local trimming |
Packet: private_message_request
Direction: Client -> Server
{
"type": "private_message_request",
"playerId": 12345,
"accessToken": "player-access-token",
"target": "Frkovo",
"message": "来一把吗?"
}
Fields:
| Field | Type | Description |
|---|---|---|
| type | String | fixed value private_message_request |
| playerId | int | sender RhythMC uid |
| accessToken | String | current player access token, used for auth |
| target | String | target player username or numeric uid |
| message | String | PM content after local trimming |
Packet: chat_message
Direction: Server -> Client
{
"type": "chat_message",
"channel": "global",
"outgoing": false,
"sender": {
"uid": 12345,
"username": "Frkovo",
"displayName": "&bFrkovo",
"aptitude": 12840,
"wattHour": 952700,
"currentServerName": "CN-East-01",
"prefix": {
"key": "rhythmc:title.beta",
"text": "&6[Beta]",
"description": [
"参与封测的玩家",
"感谢你的陪伴"
],
"rarity": "Legendary",
"position": true
},
"suffix": null
},
"target": null,
"message": "大家晚上好",
"timestamp": 1760000000000
}
Fields:
| Field | Type | Description |
|---|---|---|
| type | String | fixed value chat_message |
| channel | String | global or private |
| outgoing | boolean | only meaningful for private messages; true for the local sender copy |
| sender | ChatPlayerInfo | sender profile used for rendering name / hover |
| target | ChatPlayerInfo? | target profile for PM packets |
| message | String | plain chat content |
| timestamp | long | server-generated unix millis |
Packet: chat_error
Direction: Server -> Client
{
"type": "chat_error",
"playerId": 12345,
"channel": "global",
"code": "RATE_LIMITED",
"target": null,
"retryAfterSeconds": 7
}
Fields:
| Field | Type | Description |
|---|---|---|
| type | String | fixed value chat_error |
| playerId | int | local player that should receive the error |
| channel | String | global or private |
| code | String | one of AUTH_REQUIRED, INVALID_MESSAGE, RATE_LIMITED, SELF_TARGET, TARGET_NOT_FOUND, TARGET_OFFLINE |
| target | String? | target ref when the error is PM-related |
| retryAfterSeconds | int | retry delay for rate-limit responses |
Rendering rules on the client
- The client renders incoming chat through Adventure components.
- Title/prefix/suffix blocks show hover text built from collection
descriptionandrarity. - The player-name block shows hover text with display name, username, uid, aptitude, watt-hour and current server.
- Clicking the player-name block suggests
/pm <username>.
Server-side behavior
- Global chat is rate-limited on the backend only.
- The limit is fixed: one global message per player every 10 seconds.
- This limit is intentionally not configurable.
- Delivery depends on the player already being rebound to the current WebSocket via
resume_session.