commit 0acef64f480d986792060f7de17dc9653c051ffb Author: wupengabc <2508932142@qq.com> Date: Thu Oct 22 17:07:08 2026 +0800 chore: initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa3cea1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Dependencies and generated output +node_modules/ +dist/ +coverage/ +*.tsbuildinfo + +# Local configuration and runtime data +/config.json +/data/bot/ +.env +.env.* +!.env.example +!.env.sample +*.local +logs/ +*.log + +# Local tooling and editor files +.idea/ +.codex/ +.design-review/ +.DS_Store +*.swp +*.swo diff --git a/FORWARD_IMPLEMENTATION_PLAN.md b/FORWARD_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..4bfb0e0 --- /dev/null +++ b/FORWARD_IMPLEMENTATION_PLAN.md @@ -0,0 +1,795 @@ +# Bot 转发通道完整实现计划 + +本文用于把工作分配给多个 Agent 分步实现。本文是设计及任务约束,不代表功能已经实现。 + +工作区根目录:`/home/wupeng/Project/mf_v3`。 + +## 1. 目标与边界 + +用户在 Web 请求连接某个 Bot 的转发通道,主服务器鉴权并确认 Node 在线后,命令 Node 连接转发服务器。Node 成功入房并准备好请求处理后,主服务器返回 Web 连接地址与 Web 专用凭证。Web 经转发服务器查询状态、订阅事件及执行白名单交互。 + +必须满足: + +- 任意 `bot_status` 均允许申请转发通道,包括 `offline`、`ready`、`connecting`、`online`、`disconnecting`、`reconnecting`。 +- 不存在 BotContext 也允许建立通道,查询结果为 `offline`、`context_exists: false`。 +- Node 必须是主服务器当前已认证、在线且能接收命令的节点会话。 +- 建立或恢复转发连接不得调用 `start_bot_context()`,不得重置 Minecraft 自动重连次数。 +- `stop_bot` 只销毁 BotContext,转发通道保持,前端收到 `offline`。 +- 再次启动 Bot 后,通道自动绑定新 BotContext,无需重新连接 Web Socket。 +- Node 和 Web 使用不同角色凭证;Web 不能伪装 Node,不能向其他 Web 广播。 +- 命令响应只返回请求方;只允许白名单事件和业务方法。 +- 每一个连接、入房、请求及关闭过程都有超时、取消及身份检查。 +- 本次实现统一升级协议,不增加旧协议兼容分支。 +- Bot 游戏状态、Node 在线状态、转发连接状态分别维护,禁止互相覆盖。 + +首版转发支持:快照、日志读取、事件订阅、取消订阅、在线聊天。启动、停止、配置修改继续走主服务器现有接口,不在转发层复制一套生命周期入口。 + +## 2. 当前源码问题与处理归属 + +以下是设计时确认的源码问题,实施前应重新核对文件现状和行号。 + +| 问题 | 位置 | 归属任务 | +| --- | --- | --- | +| handler 参数 `socket` 与局部变量重复,缺少 `io` 导入,动态加载可能导致 Node 启动失败 | `node_server/event_handler/bot/forward_bot.ts` | D | +| 分发器忽略所有 `forward_` 开头事件,导致 `forward_bot` 被静默丢弃 | `node_server/event_handler/init.ts` | D | +| HTTP 转发路由缺少开头 `/`,校验后没有响应及命令流程 | `main_server/router/node/action.ts` | E | +| 转发房间不区分 Node/Web,任意事件广播给所有其他成员 | `forward_server/index.ts` | B | +| 将原始 ACK 函数传给房间广播,实际变成广播 ACK,语义和超时错误 | `forward_server/index.ts` | B | +| token 只有 `node_id`、`bot_id`,没有角色、有效期及会话作用域 | `forward_server/types.ts` 和 token 工具 | A、B、E | +| 把转发服务启动 ID 当作执行 Node ID,内置服务 `-1` 与实际节点不匹配 | `forward_server/index.ts`、`main_server/index.ts` | B、G | +| 重复加入新房间后没有离开旧房间 | `forward_server/index.ts` | B | +| 服务单例并发初始化、监听端口错误、关闭清理和单例复位不完整 | `forward_server/index.ts` | G | +| 转发服务注册表不清理死连接,重复注册可能断开共享 Node 控制 Socket | `main_server/runtime/forward_server/socket.ts` | G | +| BotContext 注册表缺少查询、创建、移除通知;直接绑定一个 Bot 实例无法跨重建保持 | `node_server/bot/instance/index.ts` | C | +| `BotContext.onAny()` 没有对应解绑入口;原始事件可能包含完整 Bot 对象 | `node_server/bot/instance/bot_instance_init.ts` | C、D | +| 跨域、公开地址、TLS 和反向代理未形成明确配置 | 转发服务启动配置 | G、F | + +AES-GCM 解密已有完整性校验,不应把问题误写成“token 没有验签”。问题是缺少声明验证、角色隔离、到期和撤销机制。没有证据说明当前公开 HTTP DTO 已泄漏转发密钥,新接口仍必须显式投影公开字段。 + +## 3. 推荐端到端时序 + +```mermaid +sequenceDiagram + participant W as Web + participant M as Main + participant N as Node + participant F as Forward + W->>M: POST /api/user/node/forward_bot {bot_id} + M->>M: 用户/Bot归属/节点与转发权限/当前Node会话 + M->>F: forward_open_session + F-->>M: 控制ACK + M->>N: forward_bot(作用域、地址、Node凭证、截止时间) + N->>F: Socket.IO connect + join_room(node) + F-->>N: join ACK + N->>N: 注册请求处理及Bot上下文订阅 + N-->>M: joined:true ACK + M->>M: 再次核对当前Node会话及Bot归属/分配 + M-->>W: 公开URL/path/namespace + Web凭证 + W->>F: connect + join_room(web) + F-->>W: join ACK + node_ready + W->>F: relay_request(bot.snapshot) + F->>N: relay_request(内部关联ID) + N-->>F: 定向ACK + F-->>W: 定向ACK + N-->>F: bot_event + F-->>W: 已授权且已订阅的Web +``` + +`forward_open_session` 由已经认证的主服务器与转发服务控制通道发送。不能由 Web Socket 调用。 + +## 4. 任务 A:协议约定与各项目本地实现清单 + +### 4.1 文件所有权 + +不新增根目录共享协议目录或公共包,也不要求各项目跨目录引用类型、校验器或 token 工具。每个项目使用自己的类型、工具和测试目录,沿用现有结构。 + +本文件是多 Agent 协作时的通信协议约定,不是运行时依赖。相同协议类型允许在各项目本地定义,但事件名、字段、错误码、时间单位和验证行为必须一致。 + +建议落点及文件所有权: + +| 项目 | 本地类型与校验 | 凭证工具 | 实现 owner | +| --- | --- | --- | --- | +| `forward_server` | 沿用 `types.ts`,校验与常量放本项目工具目录 | 沿用 `utils/token_utils.ts`,负责验证 | B;启动校验由 G 接入 | +| `node_server` | 在现有 `types/`、`utils/` 中定义节点命令、ACK 和事件 DTO | 节点转发客户端只使用 Main 下发的 token,不为远程转发服务签发凭证 | D;C 提供观察事件接口 | +| `main_server` | 在现有 `types/`、`utils/` 中定义控制协议和 HTTP DTO | 沿用 `utils/token_utils.ts`,负责签发 | E;控制接入由 G 负责 | +| `main_server_web` | 在现有类型/API目录中定义浏览器所需类型和响应检查 | 不包含密钥、签发或服务端解密工具 | F | + +A 只整理并冻结文档中的协议、字段归属、校验规则及测试向量,不提前创建或占用 B/D/E/F 负责的生产文件。各 Agent 只实现本项目实际使用的部分,不必复制无关服务端类型。 + +不得为此引入跨项目源码别名、新 workspace 包、代码生成或改变项目构建结构。协议校验不依赖数据库、BotContext 或 UI 框架;服务端密钥工具禁止进入前端 bundle。 + +协议变更先更新本文件并通知受影响的 owner,再分别同步各项目本地实现。H 通过共同的合法/非法消息样例、token 测试向量及跨服务联调验证一致性,不能仅凭本地 TypeScript 类型检查判断网络协议兼容。 + +### 4.2 身份与时间约定 + +| 字段 | 定义 | +| --- | --- | +| `forward_id` | 转发服务 ID,非负安全整数,内置服务为 `0` | +| `node_id`、`bot_id`、`user_id` | 正安全整数 | +| `node_session_id` | Main 当前认证的 Node 会话标识,不由浏览器提供 | +| `forward_session_id` | Main 分配的转发通道 UUID,独立于 Socket.IO `socket.id` | +| `grant_id` | 单个浏览器授权的 UUID,不同标签页独立 | +| `request_id` | 一次业务请求的 UUID | +| `context_generation` | 每次 BotContext 创建的新 UUID;无上下文时为 `null` | +| `stream_id` | 节点事件流 UUID;Node 重启或流重建时改变 | +| `seq` | 同一 `stream_id` 内递增的安全整数 | +| `*_at`、`*_before` | Unix 毫秒时间戳 | + +Bot 状态类型不包含 `disposed`。销毁是内部终止标记,公开状态为 `offline`。 + +### 4.3 Socket ACK + +```ts +type ForwardAck = + | {status: 'success'; data: T} + | {status: 'error'; data: { + code: ForwardErrorCode; + message: string; + retryable: boolean; + }}; + +type ForwardErrorCode = + | 'INVALID_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' + | 'NODE_OFFLINE' | 'RELAY_UNAVAILABLE' | 'SESSION_STALE' + | 'TOKEN_EXPIRED' | 'TIMEOUT' | 'CANCELLED' + | 'NO_CONTEXT' | 'BOT_NOT_ONLINE' | 'RATE_LIMITED' + | 'RESYNC_REQUIRED' | 'INTERNAL_ERROR'; +``` + +转发业务统一采用这个 ACK;现有 `register_node` 等控制协议不在 A 中整体重写。HTTP 层沿用项目响应包装,并显式映射错误,不调用只适配旧数字 `data` 的辅助函数来解析新 ACK。 + +### 4.4 Main → Node 命令 + +事件 `forward_bot`: + +```ts +interface ForwardBotCommand { + request_id: string; + forward_session_id: string; + node_session_id: string; + forward_id: number; + node_id: number; + bot_id: number; + user_id: number; + deadline_at: number; + relay: { + url: string; + path: string; + namespace: string; + token: string; + }; +} + +interface ForwardBotReady { + request_id: string; + forward_session_id: string; + node_session_id: string; + forward_id: number; + node_id: number; + bot_id: number; + joined: true; +} +``` + +`ForwardAck` 成功表示 Node 已入房、业务请求处理器已安装、上下文观察已建立;不要求存在 BotContext,也不代表 Minecraft 在线。 + +### 4.5 入房及续期 + +```ts +interface JoinRoomRequest {token: string} + +interface JoinRoomReady { + forward_session_id: string; + forward_id: number; + node_id: number; + bot_id: number; + role: 'node' | 'web'; + node_ready: boolean; + expires_at: number; +} +``` + +事件 `join_room` 使用 `ForwardAck`。作用域全部从受保护 token 取得,不相信客户端另传的房间名、角色和 Bot ID。 + +事件 `reauth` 接收 `{token: string}`,返回同一作用域的更新凭证结果。续期不得改变角色、Bot、Node、会话或 Web `grant_id`;变更这些字段必须重新建立授权。 + +### 4.6 Token 声明 + +```ts +interface RelayClaims { + v: 1; + forward_id: number; + forward_session_id: string; + node_session_id: string; + node_id: number; + bot_id: number; + role: 'node' | 'web'; + user_id?: number; + grant_id?: string; + permissions: RelayMethod[]; + issued_at: number; + join_before: number; + expires_at: number; + jti: string; +} +``` + +默认策略:新 token 在 60 秒内可用于入房或 reauth,连接授权最长 5 分钟,到期前 60 秒续期。时间参数集中定义,可配置;所有端使用相同毫秒单位,允许有限时钟偏差。 + +验证顺序:大小与格式 → GCM 完整性 → JSON 字段 → 版本/服务 audience → 时间 → 控制通道登记的有效会话 → 角色/权限/浏览器 grant。 + +Node token 不带 Web grant;Web token 必须带 `user_id`、`grant_id`。Main 与指定转发服务共享服务端密钥,密钥不得进入 Node 命令、公开响应或日志;Node 只获得受限 token,不获得转发密钥。Node 自己提供内置转发服务时,只能持有它本来配置的本地服务密钥,不能借此取得其他转发服务密钥。Node 不能仅通过解码把 token 当成已验证身份,最终有效性由转发服务 ACK 确认。 + +### 4.7 业务 RPC + +```ts +type RelayMethod = + | 'bot.snapshot' | 'bot.logs' + | 'bot.subscribe' | 'bot.unsubscribe' | 'bot.chat'; + +interface RelayRequest { + request_id: string; + method: RelayMethod; + payload: unknown; +} +``` + +Web 发送 `relay_request` 并等待 ACK。转发服务为它分配内部关联 ID,只对当前 Node Socket 发单目标 `emitWithAck`;Node 的响应由转发服务校验后返回原 Web ACK。禁止把 Web 的 ACK 函数传进房间广播。 + +每个内部关联记录绑定 Web Socket、grant、Node Socket 版本及超时。Web 的 `request_id` 仅在对应 grant 内唯一,不直接作为全局 Map 键。 + +`bot.subscribe` 参数为 `{events: BotEventName[]}`,整体替换当前 Web 的订阅集;重复请求幂等。`bot.unsubscribe` 删除给定事件,空数组不影响其他事件。订阅默认空,Web 入房后显式设置。 + +`bot.logs` 参数为 `{stream_id?: string, after_seq?: number, limit: number}`,限制 1 到 200;传 after_seq 时必须携带 stream_id。流已更换或缓存不覆盖请求区间时返回 `RESYNC_REQUIRED` 或明确 `truncated: true`,不得伪装完整记录。响应包含本次 stream ID、读取水位、记录及是否截断。 + +`bot.chat` 参数为 `{message: string}`,限制为非空字符串和可配置长度。Node 再次检查上下文及当前实例在线;不自动重放。 + +### 4.8 快照及事件 + +```ts +interface BotSnapshot { + bot_id: number; + bot_status: BotStatus; + context_exists: boolean; + context_generation: string | null; + stream_id: string; + seq: number; + captured_at: number; +} + +type BotEventName = 'status_update' | 'log' | 'message' | 'bot_error'; + +interface BotEvent { + forward_session_id: string; + bot_id: number; + context_generation: string | null; + stream_id: string; + seq: number; + emitted_at: number; + name: BotEventName; + payload: unknown; +} +``` + +`bot_event` 只允许从 Node 输入,转发给已授权、订阅相应事件的 Web。每种 `payload` 定义具体 DTO,不保留最终的任意 `unknown` 实现。 + +- 状态:`{status: BotStatus, context_exists: boolean}`。 +- 日志:保留项目日志 ID、时间戳和样式 DTO,限制批次数及总字节数。 +- 消息:文本、位置及必要的有界 JSON,禁止直接发送 ChatMessage 类实例。 +- 错误:`{code?: string, message: string}`,生产环境不传完整堆栈和敏感配置。 + +`relay_status` 由转发服务发给 Web,包含 `node_ready` 和原因,不改变 `bot_status`。节点通道未准备好时可允许 Web 入房,但业务请求返回 `NODE_OFFLINE`,不能返回虚假成功。 + +### 4.9 控制通道事件 + +Main 与转发服务器的已认证控制通道支持: + +- `forward_open_session`:登记/更新作用域、当前 Node 会话及租约,严格幂等。 +- `forward_authorize_grant`:登记/延长浏览器 grant、用户身份、权限和有效期,必须在签发 Web token 并返回前完成。 +- `forward_revoke_session`:关闭指定转发会话,撤销 Node/Web,失败所有等待请求。 +- `forward_revoke_grant`:撤销指定浏览器授权,不关闭其他浏览器。 +- `forward_session_status`:转发服务上报会话就绪/失联信息,绑定当前控制会话。 + +控制 payload 和 ACK 类型在 A 中一并冻结。open/renew 不得覆盖不同 Node 会话的旧作用域而不先撤销旧会话。 + +会话租约上限不得由数据面自行延长;Main 根据仍有效的 grant 续租。转发服务重启丢失内存授权后,Main 先重新登记 session/grant,再允许 Node/Web 恢复入房。 + +### 4.10 A 的验收 + +- 文档明确非法 ID、错误角色、过期 token、错误 audience、缺字段及超大输入的预期结果。 +- 提供无生产密钥的固定 token 测试向量和篡改/跨作用域失败样例,由 B/E 在各项目测试中分别验证。 +- 浏览器 bundle 不包含密钥及 Node token 工具。 +- 所有 Agent 获得事件名、payload、错误码、时间单位与版本的固定清单。 +- 不新增根目录共享协议包,不引入跨项目源码依赖;本地类型和校验的 owner 已明确。 + +## 5. 任务 B:实现转发服务器的数据路由与鉴权 + +### 5.1 文件所有权与依赖 + +依赖 A;与 G 在接口冻结后并行。 + +B 独占 `forward_server` 新增的 room/session/router/auth 模块及相关测试。B 不直接改启动入口;向 G 提供 `install_relay_routes(io, session_store)` 和对应清理函数,由 G 接入。 + +已有 `types.ts` 的旧声明由 B 按 A 冻结的文档约定更新为本项目本地类型;不得保留两套含义不同的 token 类型,也不从其他项目导入协议实现。 + +### 5.2 会话存储 + +按 `forward_session_id` 存储: + +- 固定 `forward_id / node_id / bot_id / node_session_id`。 +- 当前有效 Node Socket 及本地连接 generation。 +- Web Socket 与 grant 的映射、订阅集。 +- 会话及每个 Socket 的授权截止时间。 +- 有界 pending RPC 表。 +- 首个 Web 到达前、最后 Web 离开后的回收定时器。 + +房间名由服务端生成,建议包含 Node、Bot、会话;禁止客户端任意指定 room。可以使用独立 Node/Web 子房间,但 RPC 必须仍按单 Socket 定向。 + +### 5.3 入房 + +1. 连接后启动 10 秒未鉴权截止定时器。 +2. 要求有效 ACK 回调,校验 token 和登记会话。 +3. 同一 Socket 只能绑定一个不可变作用域;相同入房请求幂等返回,不累积其他房间。 +4. Node 同作用域最多一条有效上行连接。新连接入房成功后才替换旧连接,并递增 generation。 +5. 旧连接断开回调只能清理自己拥有的记录,不能清空新连接。 +6. Web grant 和权限写入服务端 `socket.data`,不相信每次消息中的身份字段。 +7. `await socket.join()` 成功后才 ACK。 +8. 成功或断开时清理未鉴权定时器。 + +Node 尚未 ready 时 Web 可入房并收到 `node_ready: false`;HTTP 初次建立仍必须先等 Node ready。此设计处理 HTTP 返回后到 Web 入房之间 Node 又掉线的竞态。 + +### 5.4 RPC 路由 + +1. Web 必须已鉴权,检查 method 权限和对应 payload。 +2. `bot.subscribe/unsubscribe` 在转发服务处理,仅修改本 Web 订阅集。 +3. 其他 method 选择该会话唯一当前 Node Socket。 +4. 分配内部关联 ID,保存请求方和 Node generation。 +5. 对 Node 单目标调用,设置明确 timeout;Node 必须返回完整 `ForwardAck`。 +6. 校验当前 Node 所有权仍一致后,把结果回给原 Web,ACK 只调用一次。 +7. 超时、Web 离开、Node 替换、会话撤销时清理记录,拒绝迟到结果。 + +默认 RPC timeout 5 秒;限制每个 Web 的 pending 数量及速率。请求 ID 去重缓存只保留有界窗口,不能宣称跨进程“恰好一次”。有副作用请求断线后不得自动重发。 + +### 5.5 事件推送 + +- 只接受当前 Node Socket 的 `bot_event`。 +- 校验 Bot、会话、事件白名单、大小和单流递增序号。 +- 发给匹配订阅及权限的 Web,不广播给 Node 或其他房间。 +- 非当前 Node、Web 伪造 `bot_event`、任意未知事件全部拒绝。 +- 不使用 `error` 作为 Bot 业务事件名,避免与 Socket.IO 控制错误混淆。 + +### 5.6 授权与回收 + +- token 到期计时器真正断开已有 Socket;不能只在入房时检查。 +- `reauth` 仅允许延长同一作用域与 grant 的授权,替换到期计时器。 +- 会话撤销及 grant 撤销分别清理对应资源。 +- 初次 Node ready 后 60 秒没有任何 Web 加入,回收未使用会话。 +- 最后一个 Web 离开后保留 30 秒宽限期,再向 Node 通知释放并清理会话。 +- 回收操作核对 generation,不能回收并发建立的新会话。 + +### 5.7 B 的验收 + +- Web A 请求不会被 Web B 接收,响应只给 A。 +- Web 无法伪造 Node、越权换 Bot、调用控制事件。 +- 两个 Web 使用相同 request ID 不冲突。 +- Node 替换后旧 ACK、旧事件和旧 disconnect 不影响新连接。 +- 重复入房不累积房间;过期、断开及回收后无遗留 timer/pending。 +- 默认同步 adapter 和模拟异步 `join` 均在真实入房后 ACK。 + +## 6. 任务 C:节点 BotContext 注册表观察接口 + +### 6.1 文件所有权与依赖 + +依赖 A,可与 B、G 并行。 + +C 独占: + +- `node_server/bot/instance/index.ts`。 +- `node_server/bot/instance/bot_instance_init.ts` 中注册表观察所需的最小改动。 +- 新增节点 Bot 快照/事件序列化模块。 +- 对应测试。 + +不重写现有 Bot 生命周期、不修改 Minecraft 自动重连插件,不让 D 在这些文件重复实现另一套观察机制。 + +### 6.2 注册表 API + +建议暴露: + +```ts +get_bot_context(bot_id: number): bot_instance_context | undefined; +observe_bot_context(bot_id: number, listener: ContextObserver): () => void; +get_bot_snapshot(bot_id: number): BotSnapshot; +``` + +`observe_bot_context` 注册时应提供当前上下文快照,之后提供创建、替换、状态更新及移除通知;每次通知包含 generation。 + +明确线性化点:创建通知在上下文可查询且状态上报绑定完成后、实际连接前发出;移除通知在该代次最终 `offline` 可观察后发出。初始化失败的短暂上下文也必须解绑,不留下幽灵订阅。 + +可以返回一个订阅对象同时含初始快照和 dispose,避免“先查询后注册”之间漏事件。具体 API 由 C 提前冻结给 D,不得让调用方自己轮询私有 Map。 + +### 6.3 事件桥 + +- 对稳定的 BotContext 订阅 `status_update/log/message/error`,不对某个 mineflayer Bot 长期持有引用。 +- 使用明确事件名及 `off` 解绑;若使用 `onAny`,必须增加匹配的 `offAny` 或 disposer。 +- 不把 `instance_created`、完整 Bot、插件状态对象、配置密钥发往转发层。 +- 新上下文使用新 generation;旧代次回调立即失效。 +- Node 级事件流维护 stream ID、seq 和有界日志缓存,BotContext 销毁不丢失最后 offline 事件。 +- 旧日志明确标注历史 generation;状态事件不得用旧代次覆盖当前状态。 +- 无上下文快照为 `offline/context_exists:false/context_generation:null`,不创建 Bot 来获取日志。 + +### 6.4 C 的验收 + +- 先订阅后启动、先启动后订阅均收到一致快照和后续事件。 +- stop/start 跨上下文替换后订阅自动转移。 +- Minecraft 实例重建不重复绑定、不丢失稳定上下文事件。 +- 解绑后监听器计数恢复;旧回调不修改新 generation。 +- JSON 序列化不会遍历 Bot、循环对象或泄漏代理密码。 +- snapshot 的 seq 与事件水位一致,可供 F 解决快照竞态。 + +## 7. 任务 D:节点转发连接管理器与 forward_bot handler + +### 7.1 文件所有权与依赖 + +依赖 A、C 的接口,以及 B 的入房/RPC 协议;允许先对协议桩开发。 + +D 独占: + +- `node_server/event_handler/bot/forward_bot.ts`。 +- `node_server/event_handler/init.ts` 的精确分发规则。 +- 新增 `node_server/runtime/forward/` 或等效独立管理目录。 +- 节点转发 RPC 处理器与测试。 + +`node_server/index.ts` 由 G 独占,D 提供控制连接状态 hook,由 G 调用。不要在 handler 每次执行时创建不受管理的 Socket。 + +### 7.2 管理器接口 + +```ts +ensure_connected(command: ForwardBotCommand): Promise; +release_session(session_id: string, reason: string): Promise; +on_main_registered(node_session_id: string): Promise; +on_main_disconnected(): void; +close_all(): Promise; +``` + +按 `(forward_id,node_id,bot_id)` 去重,记录当前 forward session、连接代次、pending ensure、Socket、context disposer、定时器和租约。 + +会话状态:`idle / connecting / joining / ready / retry_wait / closed`,不得写入 `bot_status`。 + +### 7.3 ensure_connected + +1. 校验命令、URL scheme/path、当前 Node ID/会话和截止时间。 +2. 来源必须是主服务器当前已注册控制连接;拒绝旧连接迟到命令。 +3. 同作用域同会话 ready 时幂等返回;若 token 更新,先 reauth 成功再 ACK。 +4. 同会话正在初始化时共享同一个建立任务,各请求保留自己的等待截止时间。 +5. 新会话替代旧会话时取消旧任务、解绑旧观察、关闭旧 Socket。 +6. 创建 Socket 使用 `autoConnect:false`,先安装所有监听器,再 connect。 +7. 每次 `connect` 都发送 `join_room` 并等待 ACK。 +8. 校验 ACK 作用域后安装/确认 RPC 和 C 的观察桥,标记 ready。 +9. 向 Main 返回对应 request ID 的成功 ACK。 +10. 失败路径标准化错误,只执行一次 ACK,清理仅由本会话拥有的资源。 + +多个并发申请共享通道,某一个调用者超时不能直接关闭仍被其他有效申请/授权使用的通道。请求截止与会话租约分别管理,控制租约过期才能回收孤立结果。 + +### 7.4 Node RPC + +- `bot.snapshot`:从 C 获取一致快照,Bot 离线也成功。 +- `bot.logs`:读取 C 的有界日志缓存;没有上下文时仍可返回已有历史或空列表。 +- `bot.chat`:校验权限和字符串,临执行前核对当前上下文 generation、实例身份和 `online`;否则返回 `NO_CONTEXT/BOT_NOT_ONLINE`。 +- 未知 method 拒绝,禁止根据字符串任意调用 context 方法。 +- 业务事件通过 serializer 后使用 `bot_event` 发送。 +- 主服务器断开期间不接受新的有副作用操作;已有只读订阅可在租约内继续。 + +### 7.5 重连 + +- 转发连接重连独立于主服务器重连和 Minecraft 自动重连插件。 +- 默认指数退避 1 秒到 30 秒,加入随机抖动,最多 8 次;参数集中配置。 +- 只有入房 ACK 成功才重置次数,不以 TCP/Socket.IO connect 为准。 +- 意外 `io server disconnect` 可重试;通过明确的撤销/过期/旧会话错误停止旧凭证重试。 +- 每次重连必须重新入房;不依赖服务端恢复自动恢复权限。 +- token 过期时等待 Main 下发新 token,不能使用旧凭证无限重试。 +- 不缓存并重放 chat;状态恢复靠快照,日志按序补偿。 +- 关闭/替换时先递增连接代次,使排队回调失效,再清理 timer、监听器、观察桥和 Socket。 + +### 7.6 handler 与分发修复 + +- 主控制 Socket 命名为 `main_socket`,转发客户端为 `relay_socket`。 +- 修复重复声明并导入正确的 `io`。 +- 将广泛的 `startsWith('forward_')` 忽略改为明确的控制事件路由,确保 `forward_bot` 可执行。 +- 转发数据 Socket 使用独立白名单 handler,不把它接进主服务器命令的全量 dispatcher。 +- 保持 Node 启动时所有 handler 可正常导入。 + +### 7.7 D 的验收 + +- 所有 Bot 状态及无上下文均能建立转发通道,不产生 Minecraft 连接动作。 +- 两个并发 ensure 只创建一个 Socket,超时调用者不误关闭共享会话。 +- 重连时重新入房但不重复绑定上下文事件。 +- stop/start 只改变 Bot 快照,转发连接保持。 +- 旧命令、旧 ACK、旧重试回调均不能污染新连接。 +- 超时、租约到期、手动释放和进程关闭无监听器或定时器泄漏。 + +## 8. 任务 E:主服务器 HTTP 编排、授权、续期与撤销 + +### 8.1 文件所有权与依赖 + +依赖 A;B、D 协议冻结后可并行开发,最终集成依赖 G 的健康注册表。 + +E 独占: + +- `main_server/router/node/action.ts`。 +- 新增主服务器转发业务 service、会话与 grant 存储。 +- 相关权限 helper 的必要提取及测试。 +- HTTP 续期和释放接口。 + +E 不直接修改 G 所有的转发注册表及启动入口,使用 G 暴露的健康连接及控制命令 API。 + +### 8.2 初次申请 + +接口:`POST /api/user/node/forward_bot`,body 严格为 `{bot_id}`。 + +1. 检查用户会话、账号有效性和请求数据。 +2. 用 `query_bot_info(user_id,bot_id)` 或等效逻辑检查 Bot 归属。 +3. 检查对应 Node/团队/转发资源权限,但不检查 Bot 游戏状态、版本、代理可用性或启动队列。 +4. 使用 `get_node_server_socket()` 获取当前可命令 Node,不仅看缓存 ONLINE。 +5. 按 `(forward_id,node_id,bot_id,node_session_id)` 查找/创建通道,合并建立任务。 +6. 分配浏览器独立 grant,不让不同标签页互相覆盖。 +7. 经 G 的控制通道调用 `forward_open_session`。 +8. 签发 Node token,发送 `forward_bot`,等待完整 ready ACK。 +9. ACK 前后核对相同 Node 当前会话;再次检查 Bot 归属、节点/转发分配及配置版本。 +10. 经控制通道登记该浏览器 grant,成功后签发 Web token,构造公开 DTO 返回。 + +不得把资源授权 helper 返回的完整实体序列化给浏览器,其中可能包含转发密钥。 + +公开 DTO: + +```ts +interface ForwardAccess { + bot_id: number; + node_id: number; + forward_id: number; + forward_session_id: string; + grant_id: string; + url: string; // 浏览器可访问的公开地址 + path: string; + namespace: string; + token: string; // 仅 Web 角色 + expires_at: number; +} +``` + +### 8.3 续期与释放 + +- `POST /api/user/node/forward_bot/renew`,body `{bot_id,forward_session_id,grant_id}`。 +- `POST /api/user/node/forward_bot/release`,body `{bot_id,forward_session_id,grant_id}`。 +- 两者都验证登录会话、Bot 归属、grant 所有权及当前资源分配,不能仅凭 grant ID 操作。 +- renew 先延长控制会话,再对 Node ensure/reauth 成功,登记更新后的 grant,最后签发 Web 新 token。 +- renew 若发现 Node 会话/转发分配变化,返回 `SESSION_STALE`,前端重新初次申请。 +- release 幂等撤销本 grant,立即通知转发层;不关闭其他 grant 使用的通道。 +- 主服务器可依据有效 grant 延长 Node 会话租约,但不得绕过账号/资源复核。 +- 浏览器异常关闭无法保证 release 到达,仍由租约和 B 的空闲回收兜底。 + +用户退出、权限撤销、Bot 删除、节点迁移、Node 控制会话替换时撤销对应 grant 或通道。首次实现只要求覆盖项目已有这些操作的入口,避免创建一个永不调用的撤销 API。 + +### 8.4 超时、幂等与失败 + +- Node 建立总预算 8 秒,Main 等待 10 秒,HTTP 总预算 12 秒,前端约 15 秒。 +- 所有等待包含排队时间,不逐阶段无限叠加 timeout。 +- 在 HTTP 超时后不返回凭证。迟到成功只能保留在有效租约/其他申请仍使用的共享通道内。 +- 不把转发建立加入持久化的离线 Bot 停止队列。 +- Node 离线与转发不可用使用不同错误。 +- 沿用当前 HTTP 响应 envelope,错误码映射由 E 明确写测试并向 F 发布。 +- 进程重启不假装恢复内存会话:旧租约过期/撤销,Web 重新申请。 + +### 8.5 E 的验收 + +- foreign Bot、无权限 Node、离线 Node 不能获取凭证。 +- offline Bot 和无运行上下文不阻止申请。 +- 非当前 Node 的成功 ACK 不被采纳。 +- 等待期间 Bot 被迁移/删除或权限撤销不会返回可用旧凭证。 +- 并发标签页共用上行但各有独立 grant,释放一个不影响另一个。 +- 公开响应没有 Node token、密钥、内部地址或数据库实体泄漏。 + +## 9. 任务 F:前端转发客户端与页面接入 + +### 9.1 文件所有权与依赖 + +依赖 A、E 的 HTTP 约定及 B 的 Socket 协议。F 独占 `main_server_web` 中新增转发 API、连接管理模块、状态展示和相关测试。 + +不要把现有 Main Socket 和转发 Socket 混成同一个全局变量,也不要修改 Minecraft Bot 状态来表达转发断线。 + +### 9.2 客户端生命周期 + +1. 用户打开 Bot 交互页或显式连接时申请 ForwardAccess。 +2. 保存页面/账号/Bot 的请求 generation,丢弃迟到的旧响应。 +3. 用返回的 URL/path/namespace 创建 Socket,先安装监听器。 +4. connect 后 join_room,成功后才启用交互操作。 +5. 设置事件订阅,再请求 snapshot,同步完成前缓冲实时事件。 +6. 账号退出、切换 Bot、离开页面时取消 pending、移除监听器、关闭 Socket,尽力 release grant。 + +连接状态建议:`idle / requesting / connecting / joining / syncing / ready / retrying / expired / error`。 + +同页多个组件共用一个会话客户端,组件卸载采用引用计数,不各建一条 Socket。不同标签页使用独立 grant。 + +### 9.3 快照与事件顺序 + +- 订阅后暂存事件,再请求快照,防止入房前首次状态丢失。 +- 快照携带 stream ID 和 seq 水位,先应用快照,再应用同流中 seq 更大的缓冲事件。 +- 流改变时丢弃旧流缓冲,重新同步,不能跨流比较 seq。 +- context generation 改变时重置当前上下文视图,历史日志仍可按 generation 显示。 +- seq 用于去重与快照水位,按事件订阅过滤后不保证连续,不能把自然跳号误判成丢包。由服务端显式 resync 通知、日志缓存截断或 `RESYNC_REQUIRED` 触发快照/日志重新同步。 +- UI 不把 `node_ready:false` 显示成 Bot 已 offline,分别展示通道状态和 Bot 状态。 + +### 9.4 重连与凭证 + +- 网络断线采用有限退避,connect 后必须重新 join。 +- 凭证在 expires_at 前 60 秒 renew,成功后调用 reauth,不必断开整个 Socket。 +- 过期、SESSION_STALE 时重新走 HTTP 初次申请,不使用旧 token 无限重试。 +- 服务端明确撤销授权时停止自动重试并提示重新授权,不能无条件重连绕过撤销。 +- snapshot、日志读取可显式重试;chat 不能在 ACK 超时后自动重发。 +- 切换 Bot 后旧 ACK、旧事件、旧 HTTP 响应不能更新新页面。 + +### 9.5 F 的验收 + +- Bot 离线页面可连接并展示 `context_exists:false`。 +- 多标签页互不收到对方命令内容或私有响应。 +- 停止/再次启动 Bot 后无需重连页面,即可显示新状态。 +- 短断线、token 续期、主服务器重新分配转发地址均可恢复。 +- 页面快速切换、重复挂载卸载不增加 Socket/监听器数量。 +- 保持现有设计系统,桌面和移动端可用,不为此重做无关页面。 + +## 10. 任务 G:服务启动、控制注册表与部署配置 + +### 10.1 文件所有权与依赖 + +依赖 A,与 B/C 并行。G 是以下共享入口的唯一修改者: + +- `forward_server/index.ts` 和现有控制 runtime 入口。 +- `main_server/index.ts`。 +- `main_server/runtime/forward_server/socket.ts` 及控制注册 handler。 +- `node_server/index.ts` 中与控制认证会话及进程关闭相关的集成。 +- 所需配置、配置校验和生命周期测试。 + +注意:`node_server/forward_server`、`main_server/forward_server` 可能引用同一份共享源码。实施前确认实际路径,不把别名当成独立文件让不同 Agent 同时编辑。 + +### 10.2 转发服务启动/关闭 + +- 使用共享初始化 Promise,避免 await 前检查单例造成并发启动。 +- 等待 owned HTTP server 的 listening,处理端口占用与启动失败。 +- 明确 HTTP server 所有权:内置服务借用 Main 的 HTTP server,停止转发功能不能意外关闭主站。 +- 显式 stop API 清理 B 的会话、pending、监听器、定时器,再释放自有服务。 +- 停止后复位单例和初始化状态,允许受控重启。 +- 不依赖未保证存在的 Engine.IO server `close` 事件作为唯一清理入口。 +- 启动参数明确为 forward ID,不继续使用 `-1` 充当执行 Node 身份。 +- 密钥缺失时启动失败,取消已知字符串兜底。 + +### 10.3 Main 注册表 + +- 同一当前连接重复注册同一转发服务幂等成功。 +- 注册准备完成后才加入健康表并 ACK,初始化失败必须返回失败。 +- 保存 cleanup disposer;断开时仅删除仍由该连接持有的记录。 +- 健康查询不返回已断开的 Socket。 +- origin 1 借用 Node 控制 Socket,移除转发登记不能直接 disconnect 整个 Node。 +- origin 0 的 EventEmitter 不能调用不存在的 disconnect。 +- 替代时间戳猜测,使用明确注册 generation/节点会话所有权。 +- 暴露供 E 使用的当前转发健康查询、控制命令及撤销方法。 + +### 10.4 控制链路恢复 + +- Main/Node 控制连接成功认证后,通知 D 当前 node_session_id。 +- 主服务器短断线时,已有转发只读通道可存活到租约到期,禁止新有副作用请求。 +- Main 重新认证产生新 Node 会话时,旧转发会话被替换/撤销,Web 重新申请。 +- 内置转发使用进程内 EventEmitter 也必须遵守相同控制协议、ACK 和清理契约。 +- 外部控制连接必须先认证,数据 Socket 不得调用控制事件。 + +Node 自带转发服务时,共用的 Main-Node 控制 Socket 需要同时路由 Node 命令和本地转发控制事件。G 与 D 采用明确事件表及独立 disposer,不得让修复 `forward_` 前缀过滤后,同一个控制事件被两套 handler 重复执行。Main 内置 EventEmitter 使用相同 request/ACK 适配,但不依赖 Socket.IO 才有的 timeout/disconnect 方法。 + +### 10.5 地址和部署 + +每个转发服务配置分别包含: + +- `node_url`:Node 可访问地址。 +- `public_url`:浏览器可访问地址。 +- `path`、`namespace`。 +- 明确允许的 Web Origin。 +- TLS/反向代理配置及载荷限制。 + +HTTPS 页面必须使用可访问的 HTTPS/WSS 入口;Socket.IO 客户端传 HTTP(S) URL,不把 path 拼成错误 namespace。默认 polling 与 WebSocket 都应通过代理,不能以强制 WebSocket 掩盖未配置的跨域问题。 + +首版限定每个转发实例单进程。未来多进程需要共享唯一 Node 所有权、会话、pending 路由及 adapter;只加 Redis 房间广播不等于完成横向扩展。 + +### 10.6 G 的验收 + +- 并发启动只创建一个服务,端口占用不返回成功。 +- 关闭再启动可用,借用 Main HTTP server 时不误关主站。 +- 重复注册不踢掉 Node;旧 disconnect 不删除新注册。 +- 内置与外部转发使用相同作用域规则。 +- Node 初始化过程中 `forward_bot` handler 可正常导入。 +- 浏览器默认连接方式能通过配置的公开域名和代理连接。 + +## 11. 任务 H:集成测试、故障注入与交付核对 + +### 11.1 文件所有权 + +H 独占跨服务集成测试目录及测试运行说明。生产文件修改归原 owner,H 发现问题后给证据和复现,不跨范围修复同一文件。 + +建议使用可控 Main、真实 Socket.IO relay 和模拟 BotContext,避免依赖公网 Minecraft 服务。时间相关使用可控时钟,额外保留至少一组真实网络连接测试。 + +### 11.2 必测矩阵 + +| 类别 | 场景 | 期望 | +| --- | --- | --- | +| Bot 状态 | 六种状态及无上下文 | 均允许申请,状态真实,不触发 start | +| 权限 | 未登录、他人 Bot、无 Node/转发资源权限 | 拒绝,不签发有效凭证 | +| Node | 离线、会话替换、等待期间断开 | 不返回旧连接成功 | +| 角色 | Web 伪造 Node、伪造事件、控制消息 | 拒绝 | +| 作用域 | 跨 Bot、跨 Node、跨 relay、旧 session token | 拒绝 | +| 入房 | 重复、不同 room、异步 join | 幂等或明确拒绝,ACK 时已入房 | +| RPC | 两 Web 相同请求 ID、超时、迟到 ACK | 不串响应,不泄漏 pending | +| RPC | Node 在执行期间替换 | 旧响应失效,有副作用请求不自动重放 | +| 生命周期 | Bot stop/start、实例 Minecraft 重连 | 通道保持,订阅跟随新上下文 | +| 事件 | 首次状态、快照期间事件、序号缺口 | 一致同步,不让旧状态覆盖新状态 | +| 重连 | Node-relay、Web-relay、Main-Node 分别断开 | 独立恢复,重新入房,无重复监听 | +| 凭证 | 到期前续期、已连接后到期、撤销 | 正确续期或断开,不能永久授权 | +| 并发 | 两标签页同时申请、一个超时/关闭 | 一条上行,各自授权,不误关共享连接 | +| 回收 | 浏览器从未到达、最后浏览器离开、进程退出 | timer/Socket/listener/pending 回收 | +| 部署 | 内置 relay、外部 relay、HTTPS、跨域 polling | 全流程可用 | +| 容量 | 日志洪峰、慢客户端、恶意 pending | 限流/截断明确,内存有界 | + +### 11.3 检查与交付 + +- 各服务类型检查通过,前端构建通过。 +- 单元测试、Socket.IO 集成测试通过。 +- 每个 Agent 列出修改文件、协议变化、执行的命令及未覆盖风险。 +- 明确区分“transport connected”“joined”“node ready”“Bot online”。 +- 不把 mock 通过描述成真实生产部署已验证。 +- 验证全过程不输出密钥、token、代理密码或完整用户配置。 + +## 12. 执行依赖与分工顺序 + +```mermaid +flowchart TD + A[A 协议约定冻结] --> B[B 转发数据面] + A --> C[C Bot注册表观察] + A --> G[G 启动与控制注册] + A --> E[E Main业务编排] + C --> D[D Node转发管理] + B --> D + G --> E + D --> E + E --> F[F Web接入] + B --> F + B --> H[H 集成验收] + C --> H + D --> H + E --> H + F --> H + G --> H +``` + +推荐批次: + +1. A 先冻结协议,明确全部类型、错误和事件;B/C/G 提前检查接口是否可实现。 +2. B、C、G 并行实现;E/F 可按已冻结协议开发本地桩,不自行变更契约。 +3. C 的观察 API 完成后 D 实现 Node 管理器;B/G 提供真实 relay 联调。 +4. E 接通授权、会话及 HTTP 全流程;F 接入真实 API。 +5. H 执行故障注入和完整验收,原 owner 修复各自模块。 + +不要让 E 和 G 同时修改主服务器启动/注册表,也不要让 C 和 D 同时修改 BotContext 生命周期。 + +## 13. 可直接附给各 Agent 的工作指令 + +```text +以 FORWARD_IMPLEMENTATION_PLAN.md 为协议和任务边界来源。 +只实现分配给你的任务及文件,不顺手重构其他模块。 +先核对当前源码,保留已有用户修改。 +共享 forward_server 路径可能为别名,先确认真实文件归属。 +不自行更改事件名、字段、时间单位、角色、错误码和权限语义。 +不新增根目录共享协议包,不跨项目引用协议源码;在自己负责的项目内定义必要类型和校验。 +契约有问题时给出具体冲突,由协调 Agent 更新本文,再由受影响 owner 同步本地实现。 +所有异步操作考虑超时、取消、旧会话和回收。 +提供真实验收证据,不以 Socket connect 代替入房成功。 +不得把转发重连实现成重新启动 Bot。 +完成后报告:修改文件、接口、测试、依赖、未完成项和联调注意事项。 +不要提交 Git commit,除非用户另有明确要求。 +``` + +协调 Agent 负责协议冻结、文件所有权、集成顺序和最终验证;各 Agent 的局部测试通过不自动代表端到端交付完成。 diff --git a/bot/forward/files.ts b/bot/forward/files.ts new file mode 100644 index 0000000..bee90ca --- /dev/null +++ b/bot/forward/files.ts @@ -0,0 +1,250 @@ +import {randomUUID} from 'node:crypto'; +import {constants} from 'node:fs'; +import {lstat, open, opendir, realpath} from 'node:fs/promises'; +import type {BigIntStats} from 'node:fs'; +import path from 'node:path'; +import {performance} from 'node:perf_hooks'; +import {get_project_root_path} from '../../utils/path_utils.ts'; +import {exact, ForwardFailure, integer, uuid} from '../../utils/forward_protocol.ts'; + +export type DataFileEntry = {type: 'directory'; name: string; children: DataFileEntry[]} + | {type: 'file'; name: string; id: string; size: number; modified_at: number}; +export interface DataFileList { + listing_id: string; + expires_at: number; + partial: boolean; + children: DataFileEntry[]; +} +export interface FileData { + id: string; + name: string; + encoding: 'base64'; + offset: number; + next_offset: number; + total_bytes: number; + eof: boolean; + data: string; +} +export interface BotFileServiceOptions { + /** Trusted server-side override of the bot data root, primarily for tests. */ + root?: string; + now?: () => number; +} +type FileRecord = {parts: string[]; stat: BigIntStats; expires_at: number; append: boolean}; +const TTL = 300_000; +const CHUNK = 262_144; +const MAX_IDS = 8000; +const MAX_ENTRIES = 2000; +const MAX_BYTES = 512 * 1024; +const identity = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino + && a.birthtimeNs === b.birthtimeNs; +const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'ELOOP'].includes((error as NodeJS.ErrnoException)?.code ?? ''); + +export class BotFileService { + private readonly root: string; + private readonly now: () => number; + private readonly files = new Map(); + private disposed = false; + private listing = false; + private reads = 0; + + constructor(bot_id: number, options: BotFileServiceOptions = {}) { + if (!integer(bot_id, 1)) throw new ForwardFailure('INVALID_REQUEST'); + this.root = path.resolve(options.root ?? path.join(get_project_root_path(), 'data', 'bot', String(bot_id))); + this.now = options.now ?? Date.now; + } + + dispose(): void { + this.disposed = true; + this.files.clear(); + } + + private check(deadline: number): void { + if (this.disposed) throw new ForwardFailure('CANCELLED'); + if (!integer(deadline, 1)) throw new ForwardFailure('INVALID_REQUEST'); + if (this.now() >= deadline) throw new ForwardFailure('TIMEOUT'); + } + + private prune(): void { + for (const [id, record] of this.files) { + if (record.expires_at <= this.now()) this.files.delete(id); + } + } + + // Check every ancestor, including ancestors above the configured root. These + // checks narrow parent replacement races; they do not make path lookup atomic. + private async safe(parts: string[], deadline: number): Promise { + const target = path.join(this.root, ...parts); + const relative = path.relative(this.root, target); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new ForwardFailure('RESYNC_REQUIRED'); + } + const parsed = path.parse(target); + let current = parsed.root; + let stat = await lstat(current, {bigint: true}); + for (const component of target.slice(parsed.root.length).split(path.sep).filter(Boolean)) { + this.check(deadline); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED'); + current = path.join(current, component); + stat = await lstat(current, {bigint: true}); + this.check(deadline); + if (stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED'); + } + const resolved = await realpath(target); + this.check(deadline); + if (resolved !== target) throw new ForwardFailure('RESYNC_REQUIRED'); + return stat; + } + + async list(deadline_at: number, expires_at?: number): Promise { + this.check(deadline_at); + if (expires_at !== undefined && (!integer(expires_at, 1) || expires_at <= this.now())) + throw new ForwardFailure('TOKEN_EXPIRED'); + if (this.listing) throw new ForwardFailure('RATE_LIMITED'); + this.listing = true; + const pending = new Map(); + const result: DataFileList = {listing_id: randomUUID(), expires_at: Math.min(this.now() + TTL, expires_at ?? Infinity), + partial: false, children: []}; + let entries = 0; + let bytes = Buffer.byteLength(JSON.stringify(result)); + const started = performance.now(); + const budget = () => { + this.check(deadline_at); + if (performance.now() - started >= 2000) result.partial = true; + return !result.partial; + }; + try { + this.prune(); + const rootStat = await this.safe([], deadline_at); + if (!rootStat.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED'); + const visit = async (parts: string[], children: DataFileEntry[], depth: number): Promise => { + if (!budget()) return; + const before = await this.safe(parts, deadline_at); + if (!before.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED'); + const names: string[] = []; + const directory = await opendir(path.join(this.root, ...parts)); + try { + while (budget()) { + const entry = await directory.read(); + this.check(deadline_at); + if (!entry) break; + if (names.length >= MAX_ENTRIES) { result.partial = true; break; } + names.push(entry.name); + } + } finally { await directory.close(); } + const after = await this.safe(parts, deadline_at); + if (!identity(before, after)) throw new ForwardFailure('RESYNC_REQUIRED'); + // A bounded directory read may have hit its limit; still emit its + // collected entries, then stop further traversal. + const stopped = result.partial; + result.partial = false; + for (const name of names.sort()) { + if (!budget()) break; + if (entries >= MAX_ENTRIES) { result.partial = true; break; } + if (depth >= 32) { result.partial = true; break; } + const childParts = [...parts, name]; + let stat: BigIntStats; + try { stat = await lstat(path.join(this.root, ...childParts), {bigint: true}); } + catch { + this.check(deadline_at); + result.partial = true; + break; + } + this.check(deadline_at); + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) continue; + if (stat.isFile() && (stat.size > BigInt(Number.MAX_SAFE_INTEGER) + || this.files.size + pending.size >= MAX_IDS)) { result.partial = true; break; } + const entry: DataFileEntry = stat.isDirectory() + ? {type: 'directory', name, children: []} + : {type: 'file', name, id: randomUUID(), size: Number(stat.size), modified_at: Number(stat.mtimeMs)}; + const cost = Buffer.byteLength(JSON.stringify(entry)) + 1; + if (bytes + cost > MAX_BYTES) { result.partial = true; break; } + bytes += cost; + entries++; + children.push(entry); + if (entry.type === 'file') pending.set(entry.id, {parts: childParts, stat, + expires_at: result.expires_at, append: childParts.length === 2 + && childParts[0] === 'log' && name.endsWith('.log')}); + else { + try { await visit(childParts, entry.children, depth + 1); } + catch (error) { + this.check(deadline_at); + if (error instanceof ForwardFailure) throw error; + result.partial = true; + } + } + if (result.partial) break; + } + result.partial ||= stopped; + }; + await visit([], result.children, 0); + const finalRoot = await this.safe([], deadline_at); + if (!identity(rootStat, finalRoot)) throw new ForwardFailure('RESYNC_REQUIRED'); + this.check(deadline_at); + for (const [id, record] of pending) this.files.set(id, record); + return result; + } catch (error) { + this.check(deadline_at); + if (missing(error) && result.children.length === 0 && pending.size === 0) { + // Only a genuinely absent root is an empty listing. + try { await lstat(this.root); } + catch (rootError) { + this.check(deadline_at); + if ((rootError as NodeJS.ErrnoException).code === 'ENOENT') return result; + } + } + throw error instanceof ForwardFailure ? error : new ForwardFailure('INTERNAL_ERROR'); + } finally { this.listing = false; } + } + + async read(payload: {id: string; offset?: number; length?: number}, deadline_at: number): Promise { + this.check(deadline_at); + if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id) + || (payload.offset !== undefined && !integer(payload.offset)) + || (payload.length !== undefined && (!integer(payload.length, 1) || payload.length > CHUNK))) { + throw new ForwardFailure('INVALID_REQUEST'); + } + this.prune(); + const record = this.files.get(payload.id); + if (!record) throw new ForwardFailure('INVALID_REQUEST'); + const offset = payload.offset ?? 0; + const size = Number(record.stat.size); + if (offset > size) throw new ForwardFailure('INVALID_REQUEST'); + if (this.reads >= 4) throw new ForwardFailure('RATE_LIMITED'); + this.reads++; + const validate = (stat: BigIntStats) => { + if (!stat.isFile() || !identity(record.stat, stat) || (record.append + ? stat.size < record.stat.size + : stat.size !== record.stat.size || stat.mtimeNs !== record.stat.mtimeNs + || stat.ctimeNs !== record.stat.ctimeNs)) throw new ForwardFailure('RESYNC_REQUIRED'); + }; + try { + validate(await this.safe(record.parts, deadline_at)); + const handle = await open(path.join(this.root, ...record.parts), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + this.check(deadline_at); + validate(await handle.stat({bigint: true})); + validate(await this.safe(record.parts, deadline_at)); + const buffer = Buffer.alloc(Math.min(payload.length ?? CHUNK, size - offset)); + let received = 0; + while (received < buffer.length) { + const {bytesRead} = await handle.read(buffer, received, buffer.length - received, offset + received); + this.check(deadline_at); + if (bytesRead === 0) throw new ForwardFailure('RESYNC_REQUIRED'); + received += bytesRead; + } + validate(await handle.stat({bigint: true})); + validate(await this.safe(record.parts, deadline_at)); + this.check(deadline_at); + if (record.expires_at <= this.now()) throw new ForwardFailure('INVALID_REQUEST'); + return {id: payload.id, name: record.parts.at(-1)!, encoding: 'base64', offset, + next_offset: offset + received, total_bytes: size, eof: offset + received === size, + data: buffer.toString('base64')}; + } finally { await handle.close(); } + } catch (error) { + this.check(deadline_at); + throw error instanceof ForwardFailure ? error + : new ForwardFailure(missing(error) ? 'RESYNC_REQUIRED' : 'INTERNAL_ERROR'); + } finally { this.reads--; } + } +} diff --git a/bot/forward/manager.ts b/bot/forward/manager.ts new file mode 100644 index 0000000..f45916d --- /dev/null +++ b/bot/forward/manager.ts @@ -0,0 +1,430 @@ +import {createHash} from 'node:crypto'; +import {io, type Socket} from 'socket.io-client'; +import {BotStream} from './stream.ts'; +import {BotFileService} from './files.ts'; +import type {EnsureForward, ForwardAck, ForwardReady, ForwardScope} from '../../types/forward.ts'; +import {error_ack, exact, failure_code, ForwardFailure, success_data, validate_command, validate_join, + validate_rpc} from '../../utils/forward_protocol.ts'; + +type Entry = { + command: EnsureForward; + chain: Promise; + pending: number; + socket?: Socket; + detach?: () => void; + ready: boolean; + terminal: boolean; + epoch: number; + lease: number; + retain_until: number; + expiry?: ReturnType; + stream?: BotStream; + files?: Pick; + unsubscribe?: () => void; + cancel: Set<() => void>; + used_tokens: Map; +}; + +type Options = { + socket_factory?: typeof io; + stream_factory?: (bot_id: number) => BotStream; + file_factory?: (bot_id: number) => Pick; +}; + +const scope_keys = ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id'] as const; +const fingerprint = (token: string) => createHash('sha256').update(token).digest('hex'); +const managers = new WeakMap(); + +export class NodeForwardManager { + private readonly entries = new Map(); + private readonly streams = new Map(); + private readonly socket_factory: typeof io; + private readonly stream_factory: (bot_id: number) => BotStream; + private readonly file_factory: (bot_id: number) => Pick; + private stopped = false; + + constructor(readonly node_id: number, readonly node_session_id: string, options: Options = {}) { + this.socket_factory = options.socket_factory ?? io; + this.stream_factory = options.stream_factory ?? (bot_id => new BotStream(bot_id)); + this.file_factory = options.file_factory ?? (bot_id => new BotFileService(bot_id)); + } + + async ensure(raw: unknown): Promise> { + let entry: Entry | undefined; + try { + const command = validate_command(raw); + this.check_command(command); + for (const [id, previous] of this.entries) { + if (previous.terminal && previous.pending === 0 + && previous.retain_until + 5000 <= Date.now()) this.entries.delete(id); + } + entry = this.entries.get(command.forward_session_id); + if (entry) { + if (scope_keys.some(key => entry!.command[key] !== command[key]) + || (['url', 'path', 'namespace'] as const).some(key => entry!.command[key] !== command[key])) { + throw new ForwardFailure('SESSION_STALE'); + } + if (entry.terminal) throw new ForwardFailure('SESSION_STALE'); + if (entry.pending >= 16) throw new ForwardFailure('RATE_LIMITED'); + } else { + if (this.entries.size >= 4096 || [...this.entries.values()].filter(item => !item.terminal).length >= 500) { + throw new ForwardFailure('RATE_LIMITED'); + } + // Retain closed identities past all known credentials, separately from active capacity. + entry = {command: {...command, token: ''}, chain: Promise.resolve(), pending: 0, ready: false, terminal: false, + epoch: 0, lease: 0, retain_until: command.expires_at, cancel: new Set(), used_tokens: new Map()}; + this.entries.set(command.forward_session_id, entry); + this.arm_expiry(entry); + } + const current = entry; + current.retain_until = Math.max(current.retain_until, command.expires_at); + current.pending++; + let running = false; + const run = current.chain.then(() => { + running = true; + return this.establish(current, command); + }); + current.chain = run.then(() => { current.pending--; }, () => { current.pending--; }); + return await this.until_deadline(run, command.deadline_at, () => { + if (running) this.close_transport(current); + }); + } catch (error) { + return error_ack(failure_code(error)); + } + } + + private check_command(command: EnsureForward) { + if (this.stopped || command.node_session_id !== this.node_session_id) throw new ForwardFailure('SESSION_STALE'); + if (command.node_id !== this.node_id) throw new ForwardFailure('FORBIDDEN'); + if (command.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT'); + if (command.expires_at <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED'); + } + + private scope(command: EnsureForward): ForwardScope { + const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = command; + return {forward_id, forward_session_id, node_session_id, node_id, bot_id}; + } + + private async establish(entry: Entry, command: EnsureForward): Promise> { + this.check_command(command); + if (entry.terminal) throw new ForwardFailure('SESSION_STALE'); + if (command.expires_at < entry.lease) throw new ForwardFailure('SESSION_STALE'); + const digest = fingerprint(command.token); + for (const [token, expiry] of entry.used_tokens) if (expiry <= Date.now()) entry.used_tokens.delete(token); + if (!entry.used_tokens.has(digest) && entry.used_tokens.size >= 256) throw new ForwardFailure('RATE_LIMITED'); + let socket = entry.socket; + try { + if (socket?.connected && entry.ready) { + entry.used_tokens.set(digest, command.expires_at); + const joined = validate_join(await this.ack(entry, socket, 'reauth', {token: command.token}, command), command, false); + this.check_command(command); + if (!joined.node_ready) { + entry.ready = false; + await this.ready(entry, socket, command); + } + } else { + if (entry.used_tokens.has(digest)) throw new ForwardFailure('TOKEN_EXPIRED'); + this.close_transport(entry); + // No autonomous reconnect: a join window can expire before the lease does. + socket = this.socket_factory(`${command.url.replace(/\/$/, '')}${command.namespace === '/' ? '' : command.namespace}`, { + path: command.path, autoConnect: false, reconnection: false, forceNew: true, + timeout: Math.min(10_000, this.remaining(command)), auth: {token: command.token}, + }); + entry.socket = socket; + this.install_transport(entry, socket); + await this.connect(entry, socket, command); + entry.used_tokens.set(digest, command.expires_at); + validate_join(await this.ack(entry, socket, 'join_room', {token: command.token}, command), command, true); + this.check_command(command); + if (!entry.stream) { + let shared = this.streams.get(command.bot_id); + if (!shared) { + shared = {stream: this.stream_factory(command.bot_id), refs: 0}; + this.streams.set(command.bot_id, shared); + } + shared.refs++; + entry.stream = shared.stream; + } + this.observe_updates(entry, socket); + await this.ready(entry, socket, command); + } + this.check_command(command); + if (entry.socket !== socket || !socket.connected || entry.terminal || !entry.ready) throw new ForwardFailure('SESSION_STALE'); + entry.command = {...command, token: ''}; + entry.lease = command.expires_at; + entry.used_tokens.set(digest, command.expires_at); + this.arm_expiry(entry); + entry.stream?.resync(); + this.check_command(command); + return {status: 'success', data: {...this.scope(command), node_ready: true, lease_expires_at: command.expires_at}}; + } catch (error) { + this.close_transport(entry); + if (!entry.lease) this.release_stream(entry); + throw error; + } + } + + private remaining(command: EnsureForward) { + const remaining = Math.min(command.deadline_at, command.expires_at) - Date.now(); + if (remaining <= 0) throw new ForwardFailure('TIMEOUT'); + return Math.min(remaining, 2_147_483_647); + } + + private until_deadline(task: Promise, deadline: number, timeout: () => void): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + timeout(); + reject(new ForwardFailure('TIMEOUT')); + }, Math.max(0, Math.min(deadline - Date.now(), 2_147_483_647))); + task.then(resolve, reject).finally(() => clearTimeout(timer)); + }); + } + + private connect(entry: Entry, socket: Socket, command: EnsureForward): Promise { + return new Promise((resolve, reject) => { + const done = (error?: ForwardFailure) => { + clearTimeout(timer); + socket.off('connect', connected); + socket.off('connect_error', failed); + entry.cancel.delete(cancel); + if (error) reject(error); else resolve(); + }; + const connected = () => done(); + const failed = () => done(new ForwardFailure('RELAY_UNAVAILABLE')); + const cancel = () => done(new ForwardFailure('CANCELLED')); + const timer = setTimeout(() => done(new ForwardFailure('TIMEOUT')), this.remaining(command)); + entry.cancel.add(cancel); + socket.once('connect', connected); + socket.once('connect_error', failed); + socket.connect(); + }); + } + + private async ack(entry: Entry, socket: Socket, event: string, payload: unknown, command: EnsureForward): Promise { + if (entry.terminal || entry.socket !== socket || !socket.connected || this.stopped) throw new ForwardFailure('SESSION_STALE'); + const epoch = entry.epoch; + const result = await new Promise((resolve, reject) => { + const cancel = () => { entry.cancel.delete(cancel); reject(new ForwardFailure('CANCELLED')); }; + entry.cancel.add(cancel); + socket.timeout(this.remaining(command)).emit(event, payload, (error: unknown, response: unknown) => { + entry.cancel.delete(cancel); + if (error) reject(new ForwardFailure('TIMEOUT')); else resolve(response); + }); + }); + if (entry.terminal || entry.epoch !== epoch || entry.socket !== socket || !socket.connected || this.stopped) { + throw new ForwardFailure('SESSION_STALE'); + } + return result; + } + + private async ready(entry: Entry, socket: Socket, command: EnsureForward) { + const data = success_data(await this.ack(entry, socket, 'forward_node_ready', { + forward_session_id: command.forward_session_id, + }, command)); + if (!exact(data, ['node_ready']) || data.node_ready !== true) throw new ForwardFailure('RELAY_UNAVAILABLE'); + entry.ready = true; + } + + private install_transport(entry: Entry, socket: Socket) { + const seen = new Map(); + const rpc = async (raw: unknown, callback: unknown) => { + if (typeof callback !== 'function') return; + let response: ForwardAck; + try { + // Relay may dispatch immediately after accepting ready, before its ACK reaches Node. + if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || !entry.stream) { + throw new ForwardFailure('SESSION_STALE'); + } + const request = validate_rpc(raw); + if (request.forward_session_id !== entry.command.forward_session_id) throw new ForwardFailure('SESSION_STALE'); + if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT'); + if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED'); + for (const [id, deadline] of seen) if (deadline <= Date.now()) seen.delete(id); + if (seen.has(request.correlation_id)) throw new ForwardFailure('CANCELLED'); + if (seen.size >= 512) throw new ForwardFailure('RATE_LIMITED'); + seen.set(request.correlation_id, request.deadline_at); + let data: unknown; + switch (request.method) { + case 'bot.snapshot': data = entry.stream.snapshot(); break; + case 'get_context_info': data = entry.stream.get_context_info(request.payload, request.deadline_at); break; + case 'bot.logs': data = entry.stream.logs(request.payload as {limit: number; stream_id?: string; after_seq?: number}); break; + case 'get_log_history': + case 'get_data_file_list': + case 'get_file_data': + case 'execute_function': + case 'workflow.start': + case 'workflow.stop': + case 'workflow.snapshot': + case 'workflow.catalog': + case 'workflow.runtime': + case 'workflow.control': + case 'workflow.command': + case 'tab_complete': { + const epoch = entry.epoch; + const check_transport = () => { + if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch) + throw new ForwardFailure('SESSION_STALE'); + if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT'); + if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED'); + }; + if (request.method === 'get_data_file_list' || request.method === 'get_file_data') { + // File IDs belong to this transport/session, never the shared BotStream. + const files = entry.files ??= this.file_factory(entry.command.bot_id); + const deadline = Math.min(request.deadline_at, entry.lease || entry.command.expires_at); + data = request.method === 'get_data_file_list' ? await files.list(deadline, entry.lease || entry.command.expires_at) + : await files.read(request.payload as {id: string; offset?: number; length?: number}, deadline); + if (request.method === 'get_data_file_list') { + const listing = data as {expires_at: number}; + listing.expires_at = Math.min(listing.expires_at, entry.lease || entry.command.expires_at); + } + } else if (request.method === 'workflow.start') { + data = entry.stream.workflow_start(request.payload, request.deadline_at, check_transport); + } else if (request.method === 'workflow.stop') { + data = await this.until_deadline(entry.stream.workflow_stop(request.payload, request.deadline_at, check_transport), + request.deadline_at, () => {}); + } else if (request.method === 'workflow.snapshot') { + data = entry.stream.workflow_snapshot(request.payload, request.deadline_at, check_transport); + } else if (request.method === 'workflow.catalog') { + data = entry.stream.workflow_catalog(request.payload, request.deadline_at, check_transport); + } else if (request.method === 'workflow.runtime') { + data = entry.stream.workflow_runtime(request.payload, request.deadline_at, check_transport); + } else if (request.method === 'workflow.control') { + data = entry.stream.workflow_control(request.payload, request.deadline_at, check_transport); + } else if (request.method === 'workflow.command') { + data = entry.stream.workflow_command(request.payload, request.deadline_at, check_transport); + } else data = request.method === 'get_log_history' ? await entry.stream.get_log_history() + : request.method === 'execute_function' ? await entry.stream.execute_function( + request.payload as {function: string; name: string; args: unknown[]}, request.deadline_at, check_transport) + : await entry.stream.tab_complete(request.payload, request.deadline_at); + if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch) + throw new ForwardFailure('SESSION_STALE'); + if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT'); + if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED'); + break; + } + case 'bot.chat': data = entry.stream.chat(request.payload as {message: string}, request.deadline_at); break; + } + response = {status: 'success', data}; + } catch (error) { + response = error_ack(failure_code(error)); + } + try { callback(response); } catch { /* The request ACK must never escape an event callback. */ } + }; + const closed = (raw: unknown) => { + if (entry.socket !== socket || !exact(raw, ['reason', 'code'], ['forward_session_id']) + || (raw.forward_session_id !== undefined && raw.forward_session_id !== entry.command.forward_session_id)) return; + this.retire(entry); + }; + const disconnected = () => { + if (entry.socket === socket) this.close_transport(entry); + }; + const status = (raw: unknown) => { + if (entry.socket === socket && exact(raw, ['forward_session_id', 'node_ready', 'reason']) + && raw.forward_session_id === entry.command.forward_session_id && raw.reason === 'resync_required') { + entry.stream?.resync(); + } + }; + socket.on('relay_request', rpc); + socket.on('forward_session_closed', closed); + socket.on('relay_status', status); + socket.on('disconnect', disconnected); + entry.detach = () => { + socket.off('relay_request', rpc); + socket.off('forward_session_closed', closed); + socket.off('relay_status', status); + socket.off('disconnect', disconnected); + }; + } + + private observe_updates(entry: Entry, socket: Socket) { + entry.unsubscribe?.(); + let window = Date.now(); + let count = 0; + entry.unsubscribe = entry.stream!.subscribe(event => { + if ((event.name !== 'log' && event.name !== 'info_update') || entry.socket !== socket || !socket.connected + || !entry.ready || entry.terminal || this.stopped) return; + if (Date.now() - window >= 1000) { window = Date.now(); count = 0; } + if (++count > 150 || (socket.io.engine?.writeBuffer.length ?? 0) >= 64) return; + if (event.name === 'log') { + socket.volatile.emit('bot_log', {...event, forward_session_id: entry.command.forward_session_id}); + } else { + const {name, payload, ...metadata} = event; + const update = {...metadata, + forward_session_id: entry.command.forward_session_id, data: payload}; + // Plugin lifecycle transitions can finish within the same transport + // write. Volatile would drop the final state while that write drains, + // leaving the UI initializing until another plugin change occurs. + // Keep the shared rate/buffer bounds above for these queued packets. + if (payload.name === 'plugin_states' || payload.name === 'workflow_states') socket.emit('info_update', update); + else socket.volatile.emit('info_update', update); + } + }); + } + + private close_transport(entry: Entry) { + entry.epoch++; + entry.ready = false; + entry.unsubscribe?.(); + entry.unsubscribe = undefined; + entry.files?.dispose(); + entry.files = undefined; + const socket = entry.socket; + entry.socket = undefined; + entry.detach?.(); + entry.detach = undefined; + for (const cancel of [...entry.cancel]) cancel(); + entry.cancel.clear(); + socket?.disconnect(); + if (socket) socket.auth = {}; + } + + private arm_expiry(entry: Entry) { + clearTimeout(entry.expiry); + const expires_at = Math.max(entry.lease, entry.command.expires_at); + entry.expiry = setTimeout(() => { + if (expires_at <= Date.now()) this.retire(entry); + else this.arm_expiry(entry); + }, Math.min(Math.max(0, expires_at - Date.now()), 2_147_483_647)); + entry.expiry.unref(); + } + + private release_stream(entry: Entry) { + if (!entry.stream) return; + const shared = this.streams.get(entry.command.bot_id); + if (shared && --shared.refs === 0) { + shared.stream.dispose(); + this.streams.delete(entry.command.bot_id); + } + entry.stream = undefined; + } + + private retire(entry: Entry) { + entry.terminal = true; + clearTimeout(entry.expiry); + this.close_transport(entry); + this.release_stream(entry); + entry.used_tokens.clear(); + } + + stop() { + if (this.stopped) return; + this.stopped = true; + for (const entry of this.entries.values()) this.retire(entry); + this.entries.clear(); + } +} + +export function bind_node_session(socket: Socket, node_id: number, node_session_id: string): void { + clear_node_session(socket); + managers.set(socket, new NodeForwardManager(node_id, node_session_id)); +} + +export function clear_node_session(socket: Socket): void { + managers.get(socket)?.stop(); + managers.delete(socket); +} + +export function ensure_forward(socket: Socket, command: unknown): Promise> { + if (!socket.connected) return Promise.resolve(error_ack('NODE_OFFLINE')); + return managers.get(socket)?.ensure(command) ?? Promise.resolve(error_ack('UNAUTHORIZED')); +} diff --git a/bot/forward/stream.ts b/bot/forward/stream.ts new file mode 100644 index 0000000..973e110 --- /dev/null +++ b/bot/forward/stream.ts @@ -0,0 +1,820 @@ +import {randomUUID} from 'node:crypto'; +import type {log} from '../../types/utils/logger.ts'; +import {Logger, type LogHistoryRecord} from '../../utils/logger.ts'; +import {get_project_root_path} from '../../utils/path_utils.ts'; +import * as path from 'node:path'; +import {local_command_completion} from '../../utils/command_completion.ts'; +import {get_bot_context, observe_bot_context} from '../instance/index.ts'; +import type {bot_status} from '../../types/bot/instance/bot_runtime.ts'; +import type {InfoManager, InfoSnapshot, InfoUpdate} from '../info/init.ts'; +import type {FunctionManager} from '../function/init.ts'; +import type {RunSnapshot, StartResult, WorkflowManager, WorkflowCommand, WorkflowRuntimeSnapshot} from '../../types/bot/workflow/runtime.ts'; +import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts'; +import {get_workflow_catalog} from '../workflow/catalog.ts'; +import {summarize_run, type RunSummary} from '../workflow/runtime/summary.ts'; +import {object, validate_workflow_payload} from '../../utils/forward_protocol.ts'; + +export interface StreamContext { + readonly bot_status: bot_status; + readonly bot_instance: { + chat(message: string): unknown; + players?: Record; + tabComplete?(text: string, assumeCommand?: boolean, sendBlockInSight?: boolean, timeout?: number): Promise; + _client?: { + protocolVersion?: number; + state?: string; + _mcBundle?: Array<{data?: {params?: {transactionId?: number}}; metadata?: {name?: string}}>; + write(name: string, payload: unknown): void; + on(name: string, listener: (packet: any) => void): unknown; + off(name: string, listener: (packet: any) => void): unknown; + }; + } | null; + readonly logger?: {read_recent_log(count: number): Promise}; + readonly info_manager?: Pick | null; + readonly function_manager?: Pick | null; + readonly workflow_manager?: Pick + & Partial> | null; + log?(segments: log[]): void; + on(event: any, callback: (...args: any[]) => void): unknown; + off(event: any, callback: (...args: any[]) => void): unknown; +} + +export interface StreamContextSource { + get(bot_id: number): StreamContext | undefined; + observe(bot_id: number, callback: (context: StreamContext | undefined) => void): () => void; +} + +export interface LogSegment { + message: string; + font_color: string; + font_style: Array<'normal' | 'italic' | 'bold' | 'light' | 'underline' | 'url'>; + hoverEvent?: log['hoverEvent']; + clickEvent?: log['clickEvent']; + insertion?: string; +} + +export interface BotLogRecord { + id: string; + timestamp: number; + context_generation: string | null; + seq: number; + segments: LogSegment[]; +} + +export interface BotSnapshot { + bot_id: number; + bot_status: bot_status; + context_exists: boolean; + context_generation: string | null; + stream_id: string; + seq: number; + captured_at: number; +} + +export interface BotLogsResult { + stream_id: string; + seq: number; + records: BotLogRecord[]; + truncated: boolean; +} + +interface StreamEventPayloads { + status_update: {status: bot_status; context_exists: boolean}; + log: {records: BotLogRecord[]}; + message: {text: string; position: string}; + bot_error: {message: string}; + info_update: InfoUpdate; +} + +export type BotStreamEvent = {[K in keyof StreamEventPayloads]: { + bot_id: number; + context_generation: string | null; + stream_id: string; + seq: number; + emitted_at: number; + name: K; + payload: StreamEventPayloads[K]; +}}[keyof StreamEventPayloads]; + +export type BotStreamErrorCode = 'INVALID_REQUEST' | 'TIMEOUT' | 'NO_CONTEXT' + | 'BOT_NOT_ONLINE' | 'INTERNAL_ERROR' | 'CANCELLED' | 'RESYNC_REQUIRED' | 'RATE_LIMITED'; + +export class BotStreamError extends Error { + readonly retryable = false; + constructor(readonly code: BotStreamErrorCode, message: string) { + super(message); + this.name = 'BotStreamError'; + } +} + +export interface BotStreamOptions { + source?: StreamContextSource; + capacity?: number; + now?: () => number; + max_chat_length?: number; + history_reader?: (count: number) => Promise; +} + +const default_source: StreamContextSource = {get: get_bot_context, observe: observe_bot_context}; +const generations = new WeakMap(); +const completion_pending = new WeakSet(); +let completion_transaction = 0; +const statuses = new Set(['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']); +const styles = new Set(['normal', 'italic', 'bold', 'light', 'underline', 'url']); + +function own(value: unknown, key: string): unknown { + if (!value || typeof value !== 'object') return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; +} + +function exact(value: unknown, required: string[], optional: string[] = []): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Reflect.ownKeys(value); + return required.every(key => Object.hasOwn(value, key)) && keys.every(key => { + if (typeof key !== 'string' || !required.includes(key) && !optional.includes(key)) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + return 'value' in descriptor && descriptor.enumerable; + }); +} + +function text(value: string, bytes: number): string { + if (Buffer.byteLength(value) <= bytes) return value; + let result = ''; + let size = 0; + for (const character of value) { + size += Buffer.byteLength(character); + if (size > bytes) break; + result += character; + } + return result; +} + +// Project module values to bounded JSON without invoking getters or toJSON methods. +class InfoDataError extends BotStreamError { + constructor(readonly reason: string, readonly field_path: string) { + super('INTERNAL_ERROR', 'Invalid info data'); + } +} + +function clone_info_data(value: unknown): unknown { + let entries = 0; + let bytes = 0; + const ancestors = new Set(); + const max_bytes = 4 * 1024 * 1024; + const visit = (item: unknown, depth: number, field_path: string): unknown => { + const invalid = (reason: string): never => { throw new InfoDataError(reason, field_path); }; + if (depth > 64) invalid('max_depth'); + if (++entries > 250_000) invalid('max_entries'); + let result: unknown; + if (item === null || typeof item === 'boolean' || typeof item === 'string' || typeof item === 'number') { + if (typeof item === 'number' && !Number.isFinite(item)) invalid('non_finite_number'); + if (typeof item === 'string' && Buffer.byteLength(item) > max_bytes) invalid('max_bytes'); + bytes += Buffer.byteLength(JSON.stringify(item)); + result = item; + } else { + if (!Array.isArray(item) && !object(item)) invalid('non_json_value'); + const container = item as object; + if (ancestors.has(container)) invalid('cyclic_reference'); + ancestors.add(container); + const keys = Reflect.ownKeys(container); + bytes += 2; + if (Array.isArray(item)) { + if (item.length > 65_536) invalid('max_array_length'); + if (keys.length !== item.length + 1) invalid('non_dense_array'); + const values: unknown[] = []; + for (let index = 0; index < item.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(item, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property'); + if (index) bytes++; + values.push(visit(descriptor!.value, depth + 1, `${field_path}[${index}]`)); + } + result = values; + } else { + if (keys.length > 65_536) invalid('max_keys'); + const values = Object.create(null) as Record; + for (let index = 0; index < keys.length; index++) { + const key = keys[index]; + if (typeof key !== 'string') invalid('non_string_key'); + if (Buffer.byteLength(key as string) > max_bytes) invalid('max_bytes'); + const descriptor = Object.getOwnPropertyDescriptor(container, key)!; + if (!descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property'); + bytes += Buffer.byteLength(JSON.stringify(key)) + 1 + (index ? 1 : 0); + // Paths contain property indexes rather than server-provided keys or item content. + const path_key = ['name', 'value', 'window', 'slots', 'nbt', 'components', 'special', 'offers', 'data', 'pages'].includes(key as string) + ? String(key) : ``; + values[key as string] = visit(descriptor.value, depth + 1, `${field_path}.${path_key}`); + } + result = values; + } + ancestors.delete(container); + } + if (bytes > max_bytes) invalid('max_bytes'); + return result; + }; + return visit(value, 0, '$'); +} + +function valid_info_name(value: unknown): value is string { + return typeof value === 'string' && value.length <= 128 && /^[a-zA-Z0-9_-]+$/.test(value); +} + +function valid_info_text(value: unknown, max: number): boolean { + return typeof value === 'string' && Buffer.byteLength(value) <= max; +} + +function valid_info_i18n(value: unknown): boolean { + if (!exact(value, ['lang', 'name', 'description'], ['fields', 'values']) + || !valid_info_text(value.lang, 128) || !valid_info_text(value.name, 256) + || !valid_info_text(value.description, 4096)) return false; + return ['fields', 'values'].every(key => !Object.hasOwn(value, key) + || object(value[key]) && Object.entries(value[key]).every(([name, metadata]) => + valid_info_text(name, 128) && valid_info_i18n(metadata))); +} + +function project_segments(value: unknown): LogSegment[] { + if (!Array.isArray(value)) return []; + const segments: LogSegment[] = []; + let bytes = 2; + for (let index = 0; index < Math.min(value.length, 256); index++) { + const item = own(value, String(index)); + const message = own(item, 'message'); + if (typeof message !== 'string') continue; + const color = own(item, 'font_color'); + const font_style = own(item, 'font_style'); + const safe_styles: LogSegment['font_style'] = []; + if (Array.isArray(font_style)) { + for (let i = 0; i < Math.min(font_style.length, styles.size); i++) { + const style = own(font_style, String(i)); + if (typeof style === 'string' && styles.has(style as any) && !safe_styles.includes(style as any)) { + safe_styles.push(style as LogSegment['font_style'][number]); + } + } + } + const segment: LogSegment = { + message: text(message, 16_000), + font_color: typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color) ? color : '#ffffff', + font_style: safe_styles.length ? safe_styles : ['normal'], + }; + for (const key of ['hoverEvent', 'clickEvent'] as const) { + const event = own(item, key); + const action = own(event, 'action'); + const value = own(event, 'value'); + if (typeof action !== 'string' || typeof value !== 'string') continue; + if (key === 'hoverEvent') { + if (['show_text', 'show_item', 'show_entity'].includes(action)) { + segment.hoverEvent = {action: action as NonNullable['action'], value: text(value, 4096)}; + const parts = own(event, 'segments'); + if (action === 'show_text' && Array.isArray(parts)) { + let remaining = 4096; + segment.hoverEvent.segments = project_segments(parts.slice(0, 64).map(part => ({ + message: own(part, 'message'), font_color: own(part, 'font_color'), font_style: own(part, 'font_style'), + }))).map(({message, font_color, font_style}) => { + const clipped = message.slice(0, remaining); + remaining -= clipped.length; + return {message: clipped, font_color, font_style}; + }).filter(part => part.message.length > 0); + } + } + } else if (['open_url', 'open_file', 'run_command', 'suggest_command', 'change_page', + 'copy_to_clipboard', 'show_dialog', 'custom'].includes(action)) { + segment.clickEvent = {action, value: text(value, 4096)}; + } + } + const insertion = own(item, 'insertion'); + if (typeof insertion === 'string') segment.insertion = text(insertion, 4096); + const size = Buffer.byteLength(JSON.stringify(segment)) + (segments.length ? 1 : 0); + if (bytes + size > 16_384) break; + segments.push(segment); + bytes += size; + } + return segments; +} + +function message_text(value: unknown, depth = 0): string { + if (typeof value === 'string') return text(value, 4096); + if (depth >= 4) return ''; + const json = own(value, 'json'); + if (json !== undefined) return message_text(json, depth + 1); + const content = own(value, 'text'); + let result = typeof content === 'string' ? text(content, 4096) : ''; + const extra = own(value, 'extra'); + if (Array.isArray(extra)) { + for (let index = 0; index < Math.min(extra.length, 16); index++) { + result = text(result + message_text(own(extra, String(index)), depth + 1), 4096); + } + } + return result; +} + +export class BotStream { + readonly stream_id = randomUUID(); + private readonly source: StreamContextSource; + private readonly capacity: number; + private readonly now: () => number; + private readonly max_chat_length: number; + private readonly history_reader: (count: number) => Promise; + private readonly history_stream_id = randomUUID(); + private context: StreamContext | undefined; + private generation: string | null = null; + private sequence = 0; + private dropped_through = -1; + private readonly records: BotLogRecord[] = []; + private readonly listeners = new Set<(event: BotStreamEvent) => void>(); + private readonly pending_events: BotStreamEvent[] = []; + private publishing = false; + private context_listeners: Array<[string, (...args: any[]) => void]> = []; + private unobserve: (() => void) | undefined; + private disposed = false; + + constructor(readonly bot_id: number, options: BotStreamOptions = {}) { + if (!Number.isSafeInteger(bot_id) || bot_id < 1) throw new BotStreamError('INVALID_REQUEST', 'Invalid bot ID'); + this.capacity = options.capacity ?? 200; + this.max_chat_length = options.max_chat_length ?? 256; + if (!Number.isSafeInteger(this.capacity) || this.capacity < 1 || this.capacity > 1000 + || !Number.isSafeInteger(this.max_chat_length) || this.max_chat_length < 1) { + throw new BotStreamError('INVALID_REQUEST', 'Invalid stream limits'); + } + this.source = options.source ?? default_source; + this.now = options.now ?? Date.now; + this.history_reader = options.history_reader ?? (count => this.context?.logger + ? this.context.logger.read_recent_log(count) + : Logger.read_recent_log(path.join(get_project_root_path(), 'data/bot', String(bot_id), 'log'), count)); + this.unobserve = this.source.observe(bot_id, context => this.bind(context)); + this.bind(this.source.get(bot_id)); + } + + private bind(context: StreamContext | undefined) { + if (this.disposed || this.context === context) return; + this.detach(); + this.context = context; + this.generation = context ? generations.get(context) ?? randomUUID() : null; + if (context) { + generations.set(context, this.generation!); + const listen = (event: string, callback: (...args: any[]) => void) => { + const guarded = (...args: any[]) => { + if (!this.disposed && this.context === context && this.source.get(this.bot_id) === context) { + callback(...args); + } + }; + this.context_listeners.push([event, guarded]); + context.on(event, guarded); + }; + listen('status_update', () => this.status_event()); + listen('log', input => { + const segments = project_segments(input); + if (!segments.length) return; + const record: BotLogRecord = { + id: randomUUID(), timestamp: this.now(), context_generation: this.generation, + seq: ++this.sequence, segments, + }; + this.records.push(record); + if (this.records.length > this.capacity) this.dropped_through = this.records.shift()!.seq; + this.publish('log', {records: [record]}, record.seq); + }); + listen('message', (input, position) => { + const projected = message_text(input); + if (projected) this.publish('message', { + text: projected, + position: typeof position === 'string' && position.trim() ? text(position, 128) : 'chat', + }); + }); + listen('error', () => this.publish('bot_error', {message: 'Bot connection error'})); + listen('info_update', input => { + if (context.bot_status !== 'online' || !context.bot_instance || !context.info_manager) return; + try { + const data = clone_info_data(input); + if (!exact(data, ['name', 'value']) || !valid_info_name(data.name)) { + throw new BotStreamError('INTERNAL_ERROR', 'Invalid info update'); + } + this.publish('info_update', data as unknown as InfoUpdate); + } catch (error) { + const name = own(input, 'name'); + console.error('Invalid bot info update was skipped', { + module: valid_info_name(name) ? name : 'unknown', + reason: error instanceof InfoDataError ? error.reason : 'invalid_update', + path: error instanceof InfoDataError ? error.field_path : '$', + }); + } + }); + } + this.status_event(); + } + + private detach() { + for (const [event, listener] of this.context_listeners) this.context?.off(event, listener); + this.context_listeners = []; + } + + private state() { + return { + status: this.context && statuses.has(this.context.bot_status) ? this.context.bot_status : 'offline' as bot_status, + context_exists: !!this.context, + }; + } + + private status_event() { + this.publish('status_update', this.state()); + } + + private publish(name: K, payload: StreamEventPayloads[K], seq = ++this.sequence) { + const event = { + bot_id: this.bot_id, context_generation: this.generation, stream_id: this.stream_id, + seq, emitted_at: this.now(), name, payload, + } as BotStreamEvent; + // Serialize reentrant callbacks so every subscriber sees increasing sequence numbers. + if (this.pending_events.length === 128) this.pending_events.shift(); + this.pending_events.push(event); + if (this.publishing) return; + this.publishing = true; + try { + let dispatched = 0; + while (this.pending_events.length && !this.disposed && dispatched++ < 1024) { + const pending = this.pending_events.shift()!; + for (const listener of Array.from(this.listeners)) { + if (this.disposed) break; + try { listener(structuredClone(pending)); } catch { /* Isolate consumer failures from the bot lifecycle. */ } + } + } + } finally { + this.pending_events.length = 0; + this.publishing = false; + } + } + + private active() { + if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed'); + this.bind(this.source.get(this.bot_id)); + } + + resync(): void { + this.active(); + this.status_event(); + } + + snapshot(payload: unknown = {}): BotSnapshot { + this.active(); + if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid snapshot request'); + const state = this.state(); + return { + bot_id: this.bot_id, bot_status: state.status, context_exists: state.context_exists, + context_generation: this.generation, stream_id: this.stream_id, + seq: this.sequence, captured_at: this.now(), + }; + } + + get_context_info(payload: unknown, deadline_at: number): InfoSnapshot[] { + this.active(); + if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid info request'); + if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) + throw new BotStreamError('TIMEOUT', 'Info request deadline expired'); + const context = this.context; + if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist'); + const instance = context.bot_instance; + if (context.bot_status !== 'online' || !instance) + throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online'); + const manager = context.info_manager; + if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Bot info manager is unavailable'); + let data: unknown; + try { data = clone_info_data(manager.get_all_info()); } + catch { throw new BotStreamError('INTERNAL_ERROR', 'Could not read bot info'); } + const names = new Set(); + if (!Array.isArray(data) || data.length > 256 || !data.every(info => { + if (!exact(info, ['name', 'i18n', 'value']) || !valid_info_name(info.name) || names.has(info.name) + || !valid_info_i18n(info.i18n)) return false; + names.add(info.name); + return true; + })) throw new BotStreamError('INTERNAL_ERROR', 'Invalid bot info result'); + if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Info request deadline expired'); + if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance + || context.info_manager !== manager || context.bot_status !== 'online') + throw new BotStreamError('CANCELLED', 'Bot context changed'); + return data as InfoSnapshot[]; + } + + logs(payload: unknown): BotLogsResult { + this.active(); + if (!exact(payload, ['limit'], ['stream_id', 'after_seq']) + || !Number.isSafeInteger(payload.limit) || (payload.limit as number) < 1 || (payload.limit as number) > 200 + || Object.hasOwn(payload, 'stream_id') && (typeof payload.stream_id !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(payload.stream_id)) + || Object.hasOwn(payload, 'after_seq') && (!Number.isSafeInteger(payload.after_seq) + || (payload.after_seq as number) < 0 || typeof payload.stream_id !== 'string')) { + throw new BotStreamError('INVALID_REQUEST', 'Invalid logs request'); + } + const mismatch = payload.stream_id !== undefined && payload.stream_id !== this.stream_id; + const cursor = !mismatch && typeof payload.after_seq === 'number' ? payload.after_seq : undefined; + if (cursor !== undefined && cursor > this.sequence) { + throw new BotStreamError('RESYNC_REQUIRED', 'Log cursor exceeds stream sequence'); + } + const available = this.records.filter(record => cursor === undefined || record.seq > cursor); + const candidates = cursor === undefined ? available.slice(-(payload.limit as number)).reverse() + : available.slice(0, payload.limit as number); + const records: BotLogRecord[] = []; + let bytes = 0; + for (const record of candidates) { + const size = Buffer.byteLength(JSON.stringify(record)) + 1; + if (bytes + size > 196_608) break; + records.push(record); + bytes += size; + } + if (cursor === undefined) records.reverse(); + return { + stream_id: this.stream_id, seq: this.sequence, records: structuredClone(records), + truncated: mismatch || available.length > records.length + || (cursor === undefined ? this.dropped_through >= 0 : cursor < this.dropped_through), + }; + } + + async get_log_history(): Promise { + this.active(); + const history = await this.history_reader(20); + this.active(); + const records: BotLogRecord[] = []; + let bytes = 0; + for (const entry of history.slice(-20).reverse()) { + const segments = project_segments(entry.log); + if (!segments.length) continue; + const record: BotLogRecord = {id: entry.id, timestamp: entry.timestamp, + context_generation: null, seq: 0, segments}; + const size = Buffer.byteLength(JSON.stringify(record)) + 1; + if (bytes + size > 196_608) break; + bytes += size; + records.push(record); + } + // History has its own sequence namespace so it cannot advance live log cursors. + records.reverse(); + records.forEach((record, index) => { record.seq = index + 1; }); + return {stream_id: this.history_stream_id, seq: records.length, records, + truncated: history.length >= 20 || records.length < history.length}; + } + + async tab_complete(payload: unknown, deadline_at: number): Promise<{matches: string[]; start: number; length: number}> { + this.active(); + if (!exact(payload, ['text']) || typeof payload.text !== 'string' || payload.text.length > 256 + || /[\r\n\u0000]/.test(payload.text)) throw new BotStreamError('INVALID_REQUEST', 'Invalid completion text'); + if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) + throw new BotStreamError('TIMEOUT', 'Completion deadline expired'); + const context = this.context; + if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist'); + const instance = context.bot_instance; + if (context.bot_status !== 'online' || !instance || typeof instance.tabComplete !== 'function') + throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online'); + if (completion_pending.has(instance)) throw new BotStreamError('RATE_LIMITED', 'Completion request is already pending'); + completion_pending.add(instance); + // Leave time for Node -> Forward -> Web delivery before the relay deadline. + const timeout = Math.max(1, Math.min(4500, deadline_at - this.now() - 250)); + const input = payload.text; + let start = input.search(/\S*$/); + if (start === 0 && input.startsWith('/')) start = 1; + let length = input.length - start; + try { + const client = instance._client; + const modern = (client?.protocolVersion ?? 0) >= 393; + const local = modern ? local_command_completion(instance, input, Object.keys(instance.players ?? {})) : undefined; + const values = await new Promise((resolve, reject) => { + let received = 0; + let settled = false; + const transactionId = completion_transaction = (completion_transaction + 1) % 2_147_483_647; + const finish = (error?: BotStreamError, values?: unknown[]) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (modern) client!.off('tab_complete', receive); + client?.off('end', ended); + if (error) reject(error); else resolve(values ?? []); + }; + const receive = (packet: {transactionId?: number; matches?: unknown[]; start?: number; length?: number}) => { + received++; + if (packet.transactionId !== transactionId) return; + if (!Number.isSafeInteger(packet.start) || !Number.isSafeInteger(packet.length) + || packet.start! < 0 || packet.length! < 0 || packet.start! + packet.length! > input.length) { + finish(new BotStreamError('INTERNAL_ERROR', 'Invalid completion range')); + return; + } + start = packet.start!; + length = packet.length!; + finish(undefined, packet.matches); + }; + const ended = () => finish(new BotStreamError('CANCELLED', 'Bot connection ended')); + const timer = setTimeout(() => { + if (local?.matches.length) { start = local.start; length = local.length; finish(undefined, []); } + else { + try { + context.log?.([{message: `[Tab补全超时] protocol=${client?.protocolVersion ?? 'unknown'} state=${client?.state ?? 'unknown'} transactionId=${transactionId} responses=${received} bundledPackets=${client?._mcBundle?.length ?? 0}`, + font_color: '#d1b478', font_style: ['normal']}]); + } catch { /* Diagnostics cannot prevent request completion. */ } + finish(new BotStreamError('TIMEOUT', 'Completion timed out')); + } + }, local?.matches.length ? Math.min(timeout, 200) : timeout); + client?.on('end', ended); + try { + if (modern) { + client!.on('tab_complete', receive); + client!.write('tab_complete', {transactionId, text: payload.text}); + } else { + instance.tabComplete!(payload.text as string, false, false, timeout).then( + values => finish(undefined, values), + () => finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed'))); + } + } catch { finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed')); } + }); + if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Completion deadline expired'); + if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance + || context.bot_status !== 'online') throw new BotStreamError('CANCELLED', 'Bot context changed'); + const matches: string[] = []; + if (!Array.isArray(values)) throw new BotStreamError('INTERNAL_ERROR', 'Invalid completion result'); + for (const value of values.slice(0, 1000)) { + let match = typeof value === 'string' ? value : own(value, 'match'); + if (!modern && start === 1 && input.startsWith('/') && typeof match === 'string' && match.startsWith('/')) + match = match.slice(1); + if (typeof match === 'string' && match.length > 0 && match.length <= 256 && !matches.includes(match)) + matches.push(match); + if (matches.length === 100) break; + } + if (local?.matches.length && (!matches.length || start === local.start && length === local.length)) { + if (!matches.length) { start = local.start; length = local.length; } + for (const match of local.matches) { + if (matches.length >= 100) break; + if (!matches.includes(match)) matches.push(match); + } + } + // Legacy protocols only return candidates; replace the last token, preserving the command slash. + return {matches, start, length}; + } finally { completion_pending.delete(instance); } + } + + async execute_function(payload: {function: string; name: string; args: unknown[]}, deadline_at: number, + check_transport: () => void = () => {}): Promise { + this.active(); + const context = this.context; + const instance = context?.bot_instance; + const manager = context?.function_manager; + const check = () => { + check_transport(); + if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Function deadline expired'); + if (this.disposed || !context || this.source.get(this.bot_id) !== context || context.bot_instance !== instance) + throw new BotStreamError('CANCELLED', 'Bot context changed'); + }; + check(); + if (!instance || context!.bot_status !== 'online' || !manager) return false; + try { + return await manager.run(payload.function, payload.name, payload.args, check) === true; + } catch (error) { + check(); + if (error instanceof BotStreamError) throw error; + return false; + } + } + + private workflow_context(deadline_at: number, check_transport: () => void, online = false) { + this.active(); + check_transport(); + if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) + throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired'); + const context = this.context; + if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist'); + const instance = context.bot_instance; + const manager = context.workflow_manager; + if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Workflow manager is unavailable'); + const check = () => { + check_transport(); + if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired'); + if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance + || context.workflow_manager !== manager) throw new BotStreamError('CANCELLED', 'Bot context changed'); + if (online && (context.bot_status !== 'online' || !instance)) + throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online'); + }; + check(); + return {manager, check}; + } + + workflow_start(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): StartResult { + const input = validate_workflow_payload('workflow.start', payload); + const {manager, check} = this.workflow_context(deadline_at, check_transport, true); + const trigger = input.trigger === undefined ? undefined : structuredClone(input.trigger); + check(); + const result = structuredClone(manager.start(input.workflow_id, trigger)); + check(); + return result; + } + + async workflow_stop(payload: unknown, deadline_at: number, + check_transport: () => void = () => {}): Promise<{stopped: true}> { + const input = validate_workflow_payload('workflow.stop', payload); + const {manager, check} = this.workflow_context(deadline_at, check_transport); + check(); + await manager.stop(input.run_id); + check(); + return {stopped: true}; + } + + workflow_snapshot(payload: unknown, deadline_at: number, + check_transport: () => void = () => {}): RunSnapshot | RunSummary[] | (RunSummary & Pick) | null { + const input = validate_workflow_payload('workflow.snapshot', payload); + const {manager, check} = this.workflow_context(deadline_at, check_transport); + const snapshot = input.run_id === undefined ? null : manager.get_snapshot(input.run_id) ?? null; + const result = structuredClone(input.run_id === undefined ? manager.get_snapshots().map(summarize_run) + : snapshot && input.include_branches === false ? {...summarize_run(snapshot), ...(snapshot.trigger === undefined ? {} : {trigger: snapshot.trigger})} : snapshot); + check(); + return result; + } + + workflow_catalog(payload: unknown, deadline_at: number, + check_transport: () => void = () => {}): WorkflowCatalog { + this.active(); + validate_workflow_payload('workflow.catalog', payload); + const check = () => { + check_transport(); + if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) + throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired'); + if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed'); + }; + check(); + const result = structuredClone(get_workflow_catalog()); + check(); + return result; + } + + workflow_runtime(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowRuntimeSnapshot { + validate_workflow_payload('workflow.runtime', payload); + const {manager, check} = this.workflow_context(deadline_at, check_transport); + if (!manager.get_runtime_snapshot) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable'); + const result = structuredClone(manager.get_runtime_snapshot()); + check(); + return result; + } + + workflow_control(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand { + const input = validate_workflow_payload('workflow.control', payload); + const {manager, check} = this.workflow_context(deadline_at, check_transport); + if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed'); + if (!manager.control) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable'); + const {context_generation, ...request} = structuredClone(input); + check(); + try { + const result = structuredClone(manager.control(request)); + check(); + return result; + } catch (error) { + check(); + if (error instanceof BotStreamError) throw error; + const code = (error as {code?: string})?.code; + throw new BotStreamError(code === 'RATE_LIMITED' ? 'RATE_LIMITED' : 'INVALID_REQUEST', 'Workflow control rejected'); + } + } + + workflow_command(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand | null { + const input = validate_workflow_payload('workflow.command', payload); + const {manager, check} = this.workflow_context(deadline_at, check_transport); + if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed'); + if (!manager.get_command) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable'); + const result = structuredClone(manager.get_command(input.command_id) ?? null); + check(); + return result; + } + + chat(payload: unknown, deadline_at: number): {accepted: true} { + this.active(); + if (!exact(payload, ['message']) || typeof payload.message !== 'string' + || !payload.message.trim() || payload.message.length > this.max_chat_length) { + throw new BotStreamError('INVALID_REQUEST', 'Invalid chat request'); + } + if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) { + throw new BotStreamError('TIMEOUT', 'Chat deadline expired'); + } + const context = this.context; + if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist'); + const instance = context.bot_instance; + if (context.bot_status !== 'online' || !instance || typeof instance.chat !== 'function') { + throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online'); + } + if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Chat deadline expired'); + if (this.source.get(this.bot_id) !== context || context.bot_instance !== instance || context.bot_status !== 'online') { + throw new BotStreamError('CANCELLED', 'Bot context changed'); + } + try { instance.chat(payload.message); } catch { + throw new BotStreamError('INTERNAL_ERROR', 'Bot chat failed'); + } + return {accepted: true}; + } + + subscribe(listener: (event: BotStreamEvent) => void): () => void { + this.active(); + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.unobserve?.(); + this.unobserve = undefined; + this.detach(); + this.context = undefined; + this.generation = null; + this.listeners.clear(); + this.records.length = 0; + this.pending_events.length = 0; + } +} diff --git a/bot/function/block/define.ts b/bot/function/block/define.ts new file mode 100644 index 0000000..d5b006f --- /dev/null +++ b/bot/function/block/define.ts @@ -0,0 +1,12 @@ +import {base_define} from "../../../types/bot/plugins/plugin.ts"; + +export const define: base_define = { + path: "block", + i18n: [ + { + lang: "zh-CN", + name: "方块操作", + description: "Bot 方块操作, 提供方块相关功能" + } + ] +} \ No newline at end of file diff --git a/bot/function/block/destroy_cursor_block.ts b/bot/function/block/destroy_cursor_block.ts new file mode 100644 index 0000000..1ac9fab --- /dev/null +++ b/bot/function/block/destroy_cursor_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'destroy_cursor_block'; + readonly i18n = {lang: 'zh-CN', name: '破坏光标方块', description: '破坏光标指向的方块,默认保留视角,不自动移动或切换工具'}; + readonly args = block_arguments('cursor'); + async run(bot: Bot, args: CursorArgs): Promise { + return run_block_operation(bot, args, 'cursor', 'destroy'); + } +} diff --git a/bot/function/block/destroy_nearest_block.ts b/bot/function/block/destroy_nearest_block.ts new file mode 100644 index 0000000..a5ec703 --- /dev/null +++ b/bot/function/block/destroy_nearest_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'destroy_nearest_block'; + readonly i18n = {lang: 'zh-CN', name: '破坏最近方块', description: '破坏范围内名称匹配且距离眼睛最近的已加载方块,不自动移动或切换工具'}; + readonly args = block_arguments('nearest'); + async run(bot: Bot, args: NearestArgs): Promise { + return run_block_operation(bot, args, 'nearest', 'destroy'); + } +} diff --git a/bot/function/block/destroy_position_block.ts b/bot/function/block/destroy_position_block.ts new file mode 100644 index 0000000..8e22591 --- /dev/null +++ b/bot/function/block/destroy_position_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'destroy_position_block'; + readonly i18n = {lang: 'zh-CN', name: '破坏指定方块', description: '破坏指定坐标及面的方块,操作距离为 4.5,不自动移动或切换工具'}; + readonly args = block_arguments('position'); + async run(bot: Bot, args: PositionArgs): Promise { + return run_block_operation(bot, args, 'position', 'destroy'); + } +} diff --git a/bot/function/block/destroy_position_group_block.ts b/bot/function/block/destroy_position_group_block.ts new file mode 100644 index 0000000..c3fe816 --- /dev/null +++ b/bot/function/block/destroy_position_group_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'destroy_position_group_block'; + readonly i18n = {lang: 'zh-CN', name: '依次破坏方块', description: '依次破坏坐标列表中的方块,失败停止且不回滚,不自动移动或切换工具'}; + readonly args = block_arguments('group'); + async run(bot: Bot, args: GroupArgs): Promise { + return run_block_operation(bot, args, 'group', 'destroy'); + } +} diff --git a/bot/function/block/place_cursor_block.ts b/bot/function/block/place_cursor_block.ts new file mode 100644 index 0000000..7ba01c2 --- /dev/null +++ b/bot/function/block/place_cursor_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'place_cursor_block'; + readonly i18n = {lang: 'zh-CN', name: '向光标方块放置', description: '使用主手物品向光标命中面及交点放置,默认保留视角,不自动移动或装备'}; + readonly args = block_arguments('cursor'); + async run(bot: Bot, args: CursorArgs): Promise { + return run_block_operation(bot, args, 'cursor', 'place'); + } +} diff --git a/bot/function/block/place_nearest_block.ts b/bot/function/block/place_nearest_block.ts new file mode 100644 index 0000000..f2b9bec --- /dev/null +++ b/bot/function/block/place_nearest_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'place_nearest_block'; + readonly i18n = {lang: 'zh-CN', name: '向最近方块放置', description: '使用主手物品向范围内最近的匹配方块指定面放置,不自动移动或装备'}; + readonly args = block_arguments('nearest'); + async run(bot: Bot, args: NearestArgs): Promise { + return run_block_operation(bot, args, 'nearest', 'place'); + } +} diff --git a/bot/function/block/place_position_block.ts b/bot/function/block/place_position_block.ts new file mode 100644 index 0000000..4d5e138 --- /dev/null +++ b/bot/function/block/place_position_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'place_position_block'; + readonly i18n = {lang: 'zh-CN', name: '向指定方块放置', description: '使用主手物品向指定坐标方块的指定面放置,距离为 4.5,不自动移动或装备'}; + readonly args = block_arguments('position'); + async run(bot: Bot, args: PositionArgs): Promise { + return run_block_operation(bot, args, 'position', 'place'); + } +} diff --git a/bot/function/block/place_position_group_block.ts b/bot/function/block/place_position_group_block.ts new file mode 100644 index 0000000..92fad5f --- /dev/null +++ b/bot/function/block/place_position_group_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'place_position_group_block'; + readonly i18n = {lang: 'zh-CN', name: '依次放置方块', description: '使用主手物品按列表放置,每次间隔 100 毫秒,失败停止且不回滚,不自动移动或装备'}; + readonly args = block_arguments('group'); + async run(bot: Bot, args: GroupArgs): Promise { + return run_block_operation(bot, args, 'group', 'place'); + } +} diff --git a/bot/function/block/shared.ts b/bot/function/block/shared.ts new file mode 100644 index 0000000..7cbe2a0 --- /dev/null +++ b/bot/function/block/shared.ts @@ -0,0 +1,199 @@ +import type {Bot, RaycastBlock} from '@wp2508/mineflayer'; +import type {FunctionArgument} from '../../../types/bot/function/function.ts'; +import {on_function_cancel} from '../cancellation.ts'; + +type Block = NonNullable>; +type Vector = Bot['entity']['position']; +export type BlockOperation = 'destroy' | 'place' | 'use'; +export type BlockTarget = 'cursor' | 'nearest' | 'position' | 'group'; +export type CursorArgs = [reach: number, need_look?: boolean]; +export type NearestArgs = [name: string, reach: number, face: number, need_look?: boolean]; +export type PositionArgs = [x: number, y: number, z: number, face: number, need_look?: boolean]; +export type GroupArgs = [positions: number[], need_look?: boolean]; + +const digging = new WeakSet(); +const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]] as const; + +export function block_arguments(target: BlockTarget): FunctionArgument[] { + const arg = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument => + ({name, type, i18n: {lang: 'zh-CN', name: label, description}}); + const reach = arg('reach', 'number', '最大距离', '眼睛到方块碰撞箱的最大距离,必须大于 0 且不超过 6'); + const face = arg('face', 'number', '操作面', '整数 0 下、1 上、2 北、3 南、4 西、5 东'); + const look = {...arg('need_look', 'boolean', '转向操作面', '操作前转向方块操作面的中心,默认不转向'), default: false}; + switch (target) { + case 'cursor': return [reach, look]; + case 'nearest': return [arg('name', 'string', '方块 ID', '不区分大小写的方块 ID 子串,可带 minecraft: 前缀'), reach, face, look]; + case 'position': return [ + ...['x', 'y', 'z'].map(axis => arg(axis, 'number', `${axis.toUpperCase()} 坐标`, '方块坐标,必须是安全整数;最大操作距离为 4.5')), + face, look, + ]; + case 'group': return [arg('positions', 'number-array', '坐标与操作面列表', '非空平铺数组,每四项为 x、y、z、face;坐标为安全整数,面为 0 到 5,最大距离 4.5;按顺序操作,失败停止且不回滚'), look]; + } +} + +function valid_face(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 5; +} + +function valid_position(values: unknown[]): boolean { + return values.length === 4 && [values[0], values[1], values[2]].every(value => typeof value === 'number' && Number.isSafeInteger(value)) + && valid_face(values[3]); +} + +function valid_block(block: Block | null | undefined): block is Block { + return !!block && !!block.position && typeof block.name === 'string' + && !['air', 'cave_air', 'void_air'].includes(block.name.toLowerCase().replace(/^minecraft:/, '')) + && [block.position.x, block.position.y, block.position.z].every(Number.isSafeInteger); +} + +function eye(bot: Bot): Vector | undefined { + const self = bot.entity; + const height = (self as Bot['entity'] & {eyeHeight?: number})?.eyeHeight ?? self?.height; + if (!self?.position || ![self.position.x, self.position.y, self.position.z, height].every(Number.isFinite)) return; + return self.position.offset(0, height, 0); +} + +function distance(point: Vector, block: Block): number { + const pos = block.position; + return Math.hypot(Math.max(pos.x - point.x, point.x - pos.x - 1, 0), + Math.max(pos.y - point.y, point.y - pos.y - 1, 0), Math.max(pos.z - point.z, point.z - pos.z - 1, 0)); +} + +export async function run_block_operation(bot: Bot, args: unknown[], target: BlockTarget, operation: BlockOperation): Promise { + if (!Array.isArray(args)) return false; + const length = target === 'position' ? 4 : target === 'nearest' ? 3 : 1; + if (args.length !== length && args.length !== length + 1) return false; + if (args.length > length && typeof args[length] !== 'boolean') return false; + const need_look = args[length] === true; + args = args.slice(0, length); + let positions: number[][] = []; + let reach = 4.5; + let query = ''; + if (target === 'cursor' || target === 'nearest') { + if (args.length !== (target === 'cursor' ? 1 : 3)) return false; + const value = args[target === 'cursor' ? 0 : 1]; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 6) return false; + reach = value; + if (target === 'nearest') { + if (typeof args[0] !== 'string' || !valid_face(args[2])) return false; + query = args[0].trim().toLowerCase().replace(/^minecraft:/, ''); + if (!query) return false; + } + } else if (target === 'position') { + if (!valid_position(args)) return false; + positions = [args.slice() as number[]]; + } else { + if (args.length !== 1 || !Array.isArray(args[0]) || !args[0].length || args[0].length % 4 !== 0) return false; + for (let i = 0; i < args[0].length; i += 4) { + const position = args[0].slice(i, i + 4); + if (!valid_position(position)) return false; + positions.push(position); + } + } + + const self = bot.entity; + const client = bot._client; + let ended = false; + let owned_dig: Block | undefined; + let cancel!: () => void; + const cancelled = new Promise(resolve => { cancel = () => resolve(false); }); + const active = () => !ended && !!self && !!client && bot.entity === self && bot._client === client + && self.isValid !== false && !(client as typeof client & {ended?: boolean}).ended; + const stop_owned = () => { + if (owned_dig && bot.targetDigBlock === owned_dig) { + try { bot.stopDigging(); } catch { /* The connection may already be closed. */ } + } + }; + const on_end = () => { ended = true; stop_owned(); cancel(); }; + const unwatch = on_function_cancel(bot, on_end); + const wait = async (promise: Promise): Promise => { + const result = await Promise.race([promise.then(() => true, () => false), cancelled]); + return result && active(); + }; + try { + bot.on('end', on_end); + if (!active() || !eye(bot) || operation === 'place' && !bot.heldItem) return false; + let selected: Block | undefined; + let cursor: RaycastBlock | undefined; + if (target === 'cursor') { + cursor = bot.blockAtCursor(reach) ?? undefined; + if (!valid_block(cursor) || !valid_face(cursor.face) || !cursor.intersect + || ![cursor.intersect.x, cursor.intersect.y, cursor.intersect.z].every(Number.isFinite)) return false; + selected = cursor; + } else if (target === 'nearest') { + // findBlocks sorts block origins around a floored point. Include every + // candidate before applying our eye-to-AABB distance ordering. + const radius = Math.ceil(reach + 2 * Math.sqrt(3)); + const matching = (block: Block | null) => !!block && typeof block.name === 'string' + && block.name.toLowerCase().replace(/^minecraft:/, '').includes(query); + selected = bot.findBlocks({point: eye(bot)!, matching, maxDistance: radius, count: (2 * radius + 1) ** 3}) + .map(position => bot.blockAt(position)).filter(valid_block).filter(matching) + .map(block => ({block: block!, distance: distance(eye(bot)!, block!)})) + .filter(entry => entry.distance <= reach).sort((a, b) => a.distance - b.distance)[0]?.block; + if (!selected) return false; + } + const total = target === 'group' ? positions.length : 1; + for (let i = 0; i < total; i++) { + if (!active()) return false; + const position = positions[i]; + const block = selected ?? bot.blockAt(self.position.scaled(0).offset(position![0]!, position![1]!, position![2]!)); + const point = eye(bot); + if (!valid_block(block) || !point || distance(point, block) > reach) return false; + const loaded = bot.blockAt(block.position); + if (!valid_block(loaded) || loaded.type !== block.type) return false; + const face = cursor?.face ?? (target === 'nearest' ? args[2] as number : position![3]!); + const components = faces[face]!; + const vector = self.position.scaled(0).offset(components[0], components[1], components[2]); + const delta = cursor?.intersect.minus(block.position); + const {type, stateId} = block; + const valid = () => { + const point = eye(bot); + const loaded = bot.blockAt(block.position); + return active() && !!point && distance(point, block) <= reach && valid_block(loaded) + && loaded.type === type && loaded.stateId === stateId + && (operation !== 'place' || !!bot.heldItem) + && (operation !== 'destroy' || bot.canDigBlock(block)); + }; + if (operation === 'destroy') { + if (digging.has(bot) || bot.targetDigBlock || !bot.canDigBlock(block)) return false; + digging.add(bot); + owned_dig = block; + } + try { + if (need_look && !await wait(bot.lookAt(block.position.offset(0.5, 0.5, 0.5).plus(vector.scaled(0.5))))) return false; + if (!valid()) return false; + if (operation === 'destroy') { + if (bot.targetDigBlock) return false; + try { + const promise = bot.dig(block, 'ignore', vector); + if (!await wait(promise)) { stop_owned(); return false; } + } catch { + stop_owned(); + return false; + } + } else if (operation === 'place') { + if (!await wait(bot._placeBlockWithOptions(block, vector, {swingArm: 'right', forceLook: 'ignore', delta}))) return false; + } else if (!await wait(bot.activateBlock(block, vector, delta, 'ignore'))) return false; + } finally { + if (operation === 'destroy') { + owned_dig = undefined; + digging.delete(bot); + } + } + if (!active()) return false; + if (operation === 'place' && target === 'group' && i + 1 < total) { + let timer: ReturnType | undefined; + try { + if (!await wait(new Promise(resolve => { timer = setTimeout(resolve, 100); }))) return false; + } finally { clearTimeout(timer); } + } + } + return active(); + } catch { + stop_owned(); + return false; + } finally { + unwatch(); + bot.removeListener('end', on_end); + } +} diff --git a/bot/function/block/use_cursor_block.ts b/bot/function/block/use_cursor_block.ts new file mode 100644 index 0000000..fdcbaa1 --- /dev/null +++ b/bot/function/block/use_cursor_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'use_cursor_block'; + readonly i18n = {lang: 'zh-CN', name: '使用光标方块', description: '右键使用光标命中方块的面与交点,默认保留视角,不自动移动或装备'}; + readonly args = block_arguments('cursor'); + async run(bot: Bot, args: CursorArgs): Promise { + return run_block_operation(bot, args, 'cursor', 'use'); + } +} diff --git a/bot/function/block/use_nearest_block.ts b/bot/function/block/use_nearest_block.ts new file mode 100644 index 0000000..712999d --- /dev/null +++ b/bot/function/block/use_nearest_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'use_nearest_block'; + readonly i18n = {lang: 'zh-CN', name: '使用最近方块', description: '右键使用范围内最近的匹配方块指定面,不自动移动或装备'}; + readonly args = block_arguments('nearest'); + async run(bot: Bot, args: NearestArgs): Promise { + return run_block_operation(bot, args, 'nearest', 'use'); + } +} diff --git a/bot/function/block/use_position_block.ts b/bot/function/block/use_position_block.ts new file mode 100644 index 0000000..f561ffe --- /dev/null +++ b/bot/function/block/use_position_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'use_position_block'; + readonly i18n = {lang: 'zh-CN', name: '使用指定方块', description: '右键使用指定坐标方块的指定面,距离为 4.5,不自动移动或装备'}; + readonly args = block_arguments('position'); + async run(bot: Bot, args: PositionArgs): Promise { + return run_block_operation(bot, args, 'position', 'use'); + } +} diff --git a/bot/function/block/use_position_group_block.ts b/bot/function/block/use_position_group_block.ts new file mode 100644 index 0000000..2141a68 --- /dev/null +++ b/bot/function/block/use_position_group_block.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'use_position_group_block'; + readonly i18n = {lang: 'zh-CN', name: '依次使用方块', description: '按坐标列表依次右键使用方块,失败停止且不回滚,不自动移动或装备'}; + readonly args = block_arguments('group'); + async run(bot: Bot, args: GroupArgs): Promise { + return run_block_operation(bot, args, 'group', 'use'); + } +} diff --git a/bot/function/bot/chat.ts b/bot/function/bot/chat.ts new file mode 100644 index 0000000..b44cb18 --- /dev/null +++ b/bot/function/bot/chat.ts @@ -0,0 +1,17 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {argument, guarded} from './shared.ts'; + +export class init implements BaseFunction<[message: string]> { + readonly name = 'chat'; + readonly i18n = {lang: 'zh-CN', name: '发送聊天', description: '原样发送聊天消息,成功仅表示本地调用完成,不等待服务器确认'}; + readonly args = [argument('message', 'string', '消息', '非空白消息,最多 256 个字符,不允许换行或空字符')]; + async run(bot: Bot, args: [string]): Promise { + if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'string' + || !args[0].trim() || args[0].length > 256 || /[\r\n\0]/.test(args[0])) return false; + return guarded(bot, async session => { + bot.chat(args[0]); + return session.active(); + }); + } +} diff --git a/bot/function/bot/define.ts b/bot/function/bot/define.ts new file mode 100644 index 0000000..866c5fe --- /dev/null +++ b/bot/function/bot/define.ts @@ -0,0 +1,12 @@ +import {base_define} from "../../../types/bot/plugins/plugin.ts"; + +export const define: base_define = { + path: "bot", + i18n: [ + { + lang: "zh-CN", + name: "BOT操作", + description: "Bot 自身操作, 提供自身相关功能" + } + ] +} \ No newline at end of file diff --git a/bot/function/bot/jump.ts b/bot/function/bot/jump.ts new file mode 100644 index 0000000..7057acf --- /dev/null +++ b/bot/function/bot/jump.ts @@ -0,0 +1,13 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {pulse} from './shared.ts'; + +export class init implements BaseFunction<[]> { + readonly name = 'jump'; + readonly i18n = {lang: 'zh-CN', name: '跳跃一次', description: '在地面且未按住跳跃时,按住跳跃直到下一物理刻,最多等待 1 秒'}; + readonly args: [] = []; + async run(bot: Bot, args: []): Promise { + if (!Array.isArray(args) || args.length !== 0 || !bot.entity?.onGround) return false; + return pulse(bot, 'jump'); + } +} diff --git a/bot/function/bot/look_at.ts b/bot/function/bot/look_at.ts new file mode 100644 index 0000000..5ecf636 --- /dev/null +++ b/bot/function/bot/look_at.ts @@ -0,0 +1,21 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {argument, guarded} from './shared.ts'; + +export class init implements BaseFunction<[x: number, y: number, z: number]> { + readonly name = 'look_at'; + readonly i18n = {lang: 'zh-CN', name: '看向坐标', description: '看向指定有限坐标,允许小数,最多等待 5 秒,目标不能与眼睛重合'}; + readonly args = ['x', 'y', 'z'].map(axis => argument(axis, 'number', `${axis} 坐标`, '有限数值,允许小数')); + async run(bot: Bot, args: [number, number, number]): Promise { + if (!Array.isArray(args) || args.length !== 3 || !args.every(value => typeof value === 'number' && Number.isFinite(value))) return false; + return guarded(bot, async session => { + const self = bot.entity; + const eye_height = (self as typeof self & {eyeHeight?: number}).eyeHeight; + if (!self.position || ![self.position.x, self.position.y, self.position.z, eye_height].every(Number.isFinite)) return false; + const target = self.position.offset(0, 0, 0); + [target.x, target.y, target.z] = args; + if (target.x === self.position.x && target.y === self.position.y + eye_height! && target.z === self.position.z) return false; + return await session.wait(() => bot.lookAt(target), 5000, 'promise') && session.active(); + }); + } +} diff --git a/bot/function/bot/shared.ts b/bot/function/bot/shared.ts new file mode 100644 index 0000000..a5e9afe --- /dev/null +++ b/bot/function/bot/shared.ts @@ -0,0 +1,103 @@ +import type {Bot, ControlState} from '@wp2508/mineflayer'; +import type {FunctionArgument} from '../../../types/bot/function/function.ts'; +import {on_function_cancel} from '../cancellation.ts'; + +export const argument = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument => + ({name, type, i18n: {lang: 'zh-CN', name: label, description}}); + +const owners = new WeakMap>(); +function controls(bot: Bot): Map { + let keys = owners.get(bot); + if (!keys) owners.set(bot, keys = new Map()); + return keys; +} + +interface Session { + active(): boolean; + wait(start: () => unknown, duration: number, mode: 'physicsTick' | 'delay' | 'promise'): Promise; +} + +export async function guarded(bot: Bot, operation: (session: Session) => Promise): Promise { + const self = bot.entity; + const client = bot._client; + let ended = false; + let cancel: (() => void) | undefined; + const on_end = () => { ended = true; cancel?.(); }; + const active = () => !ended && !!self && !!client && self.isValid !== false + && bot.entity === self && bot._client === client && client.state === 'play'; + const unwatch = on_function_cancel(bot, on_end); + try { + if (!active()) return false; + bot.on('end', on_end); + bot.on('error', on_end); + client.on('end', on_end); + client.on('error', on_end); + return await operation({active, wait(start, duration, mode) { + return new Promise(resolve => { + let settled = false; + let started = false; + let tick_seen = false; + const finish = (success: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + bot.removeListener('physicsTick', on_tick); + cancel = undefined; + resolve(success && active()); + }; + const on_tick = () => { + tick_seen = true; + if (started) finish(true); + }; + const timer = setTimeout(() => finish(mode === 'delay'), duration); + cancel = () => finish(false); + if (mode === 'physicsTick') bot.on('physicsTick', on_tick); + try { + if (!active()) return finish(false); + const result = start(); + started = true; + Promise.resolve(result).then(() => { + if (mode === 'promise') finish(true); + }, () => finish(false)); + if (mode === 'physicsTick' && tick_seen) finish(true); + } catch { + finish(false); + } + }); + }}); + } catch { + return false; + } finally { + unwatch(); + cancel?.(); + bot.removeListener('end', on_end); + bot.removeListener('error', on_end); + client?.removeListener('end', on_end); + client?.removeListener('error', on_end); + } +} + +export async function pulse(bot: Bot, key: ControlState, duration?: number): Promise { + return guarded(bot, async session => { + if (bot.getControlState(key)) return false; + const keys = controls(bot); + const owner = Symbol(key); + keys.set(key, owner); + try { + const success = await session.wait(() => bot.setControlState(key, true), duration ?? 1000, + duration === undefined ? 'physicsTick' : 'delay'); + return success && session.active(); + } finally { + // A persistent command may have taken ownership while this pulse was waiting. + if (keys.get(key) === owner) { + keys.delete(key); + bot.setControlState(key, false); + } + } + }); +} + +export function set_sneak(bot: Bot, enabled: boolean): void { + controls(bot).delete('sneak'); + bot.setControlState('sneak', enabled); +} diff --git a/bot/function/bot/squat_continue_switch.ts b/bot/function/bot/squat_continue_switch.ts new file mode 100644 index 0000000..5fbb422 --- /dev/null +++ b/bot/function/bot/squat_continue_switch.ts @@ -0,0 +1,16 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {argument, guarded, set_sneak} from './shared.ts'; + +export class init implements BaseFunction<[enabled: boolean]> { + readonly name = 'squat_continue_switch'; + readonly i18n = {lang: 'zh-CN', name: '设置持续下蹲', description: '显式设置持续下蹲状态,重复设置相同状态不会切换'}; + readonly args = [argument('enabled', 'boolean', '启用下蹲', 'true 持续下蹲,false 取消下蹲')]; + async run(bot: Bot, args: [boolean]): Promise { + if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'boolean') return false; + return guarded(bot, async session => { + set_sneak(bot, args[0]); + return session.active(); + }); + } +} diff --git a/bot/function/bot/squat_once.ts b/bot/function/bot/squat_once.ts new file mode 100644 index 0000000..61efdae --- /dev/null +++ b/bot/function/bot/squat_once.ts @@ -0,0 +1,13 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {pulse} from './shared.ts'; + +export class init implements BaseFunction<[]> { + readonly name = 'squat_once'; + readonly i18n = {lang: 'zh-CN', name: '下蹲一次', description: '未按住下蹲时,按住下蹲直到下一物理刻,最多等待 1 秒'}; + readonly args: [] = []; + async run(bot: Bot, args: []): Promise { + if (!Array.isArray(args) || args.length !== 0) return false; + return pulse(bot, 'sneak'); + } +} diff --git a/bot/function/bot/stop_current_function.ts b/bot/function/bot/stop_current_function.ts new file mode 100644 index 0000000..04253a0 --- /dev/null +++ b/bot/function/bot/stop_current_function.ts @@ -0,0 +1,19 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {cancel_functions} from '../cancellation.ts'; + +export class init implements BaseFunction<[]> { + readonly name = 'stop_current_function'; + readonly i18n = {lang: 'zh-CN', name: '停止当前函数', description: '立即取消当前和排队函数,停止挖掘、使用物品并释放所有控制键'}; + readonly args: [] = []; + + async run(bot: Bot, args: []): Promise { + if (!Array.isArray(args) || args.length !== 0) return false; + let success = true; + for (const stop of [() => cancel_functions(bot), () => bot.stopDigging(), + () => bot.deactivateItem(), () => bot.clearControlStates()]) { + try { stop(); } catch { success = false; } + } + return success; + } +} diff --git a/bot/function/bot/use_direction_key.ts b/bot/function/bot/use_direction_key.ts new file mode 100644 index 0000000..33e963b --- /dev/null +++ b/bot/function/bot/use_direction_key.ts @@ -0,0 +1,16 @@ +import type {Bot, ControlState} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {argument, pulse} from './shared.ts'; + +export class init implements BaseFunction<[direction: string, duration_ms: number]> { + readonly name = 'use_direction_key'; + readonly i18n = {lang: 'zh-CN', name: '按住方向键', description: '按住指定方向键一段时间后释放,拒绝操作已按住的方向键'}; + readonly args = [argument('direction', 'string', '方向', '仅允许 forward、back、left、right'), + argument('duration_ms', 'number', '持续毫秒数', '1 到 5000 的安全整数')]; + async run(bot: Bot, args: [string, number]): Promise { + if (!Array.isArray(args) || args.length !== 2 || typeof args[0] !== 'string' + || !['forward', 'back', 'left', 'right'].includes(args[0]) + || !Number.isSafeInteger(args[1]) || args[1] < 1 || args[1] > 5000) return false; + return pulse(bot, args[0] as ControlState, args[1]); + } +} diff --git a/bot/function/cancellation.ts b/bot/function/cancellation.ts new file mode 100644 index 0000000..ac683f7 --- /dev/null +++ b/bot/function/cancellation.ts @@ -0,0 +1,52 @@ +import {EventEmitter} from 'node:events'; +import {AsyncLocalStorage} from 'node:async_hooks'; +import type {Bot} from '@wp2508/mineflayer'; + +type Cancellation = {generation: number; events: EventEmitter}; +const scopes = new AsyncLocalStorage<{bot: Bot; cancellation: Cancellation}>(); +const states = new WeakMap}>(); +function state(bot: Bot) { + let value = states.get(bot); + if (!value) states.set(bot, value = {generation: 0, events: new EventEmitter(), scopes: new Set()}); + return value; +} + +function current(bot: Bot): Cancellation { + const scope = scopes.getStore(); + return scope?.bot === bot ? scope.cancellation : state(bot); +} + +export function cancellation_generation(bot: Bot): number { + return current(bot).generation; +} + +export function on_function_cancel(bot: Bot, listener: () => void): () => void { + const {events} = current(bot); + events.on('cancel', listener); + return () => { events.removeListener('cancel', listener); }; +} + +/** Existing helpers inherit the call's cancellation scope across awaits. */ +export function create_function_cancellation(bot: Bot) { + const value = state(bot); + const cancellation: Cancellation = {generation: 0, events: new EventEmitter()}; + value.scopes.add(cancellation); + return { + run(operation: () => T): T { return scopes.run({bot, cancellation}, operation); }, + cancel() { + cancellation.generation++; + cancellation.events.emit('cancel'); + }, + dispose() { value.scopes.delete(cancellation); }, + }; +} + +export function cancel_functions(bot: Bot): void { + const value = state(bot); + value.generation++; + value.events.emit('cancel'); + for (const cancellation of value.scopes) { + cancellation.generation++; + cancellation.events.emit('cancel'); + } +} diff --git a/bot/function/entity/attack_cursor_entity.ts b/bot/function/entity/attack_cursor_entity.ts new file mode 100644 index 0000000..6fa9f50 --- /dev/null +++ b/bot/function/entity/attack_cursor_entity.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'attack_cursor_entity'; + readonly i18n = {lang: 'zh-CN', name: '攻击光标实体', description: '攻击光标指向且在指定距离内的实体,不自动移动'}; + readonly args = entity_arguments(false, false, true); + async run(bot: Bot, args: CursorArgs): Promise { + return run_entity_operation(bot, args, false, 'attack'); + } +} diff --git a/bot/function/entity/attack_nearest_entity.ts b/bot/function/entity/attack_nearest_entity.ts new file mode 100644 index 0000000..9727dfa --- /dev/null +++ b/bot/function/entity/attack_nearest_entity.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'attack_nearest_entity'; + readonly i18n = {lang: 'zh-CN', name: '攻击最近实体', description: '攻击指定距离内名称匹配的最近实体,可选瞄准,不自动移动'}; + readonly args = entity_arguments(true, false, true); + async run(bot: Bot, args: NearestArgs): Promise { + return run_entity_operation(bot, args, true, 'attack', this.name); + } +} diff --git a/bot/function/entity/define.ts b/bot/function/entity/define.ts new file mode 100644 index 0000000..d3b0bbc --- /dev/null +++ b/bot/function/entity/define.ts @@ -0,0 +1,12 @@ +import {base_define} from "../../../types/bot/plugins/plugin.ts"; + +export const define: base_define = { + path: "entity", + i18n: [ + { + lang: "zh-CN", + name: "实体操作", + description: "Bot 实体操作, 提供实体相关功能" + } + ] +} \ No newline at end of file diff --git a/bot/function/entity/feed_cursor_entity.ts b/bot/function/entity/feed_cursor_entity.ts new file mode 100644 index 0000000..d5db180 --- /dev/null +++ b/bot/function/entity/feed_cursor_entity.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type CursorItemArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'feed_cursor_entity'; + readonly i18n = {lang: 'zh-CN', name: '喂食光标实体', description: '对光标实体使用指定手的物品,可选瞄准,由服务器判断是否可以喂食'}; + readonly args = entity_arguments(false, true); + async run(bot: Bot, args: CursorItemArgs): Promise { + return run_entity_operation(bot, args, false, 'item'); + } +} diff --git a/bot/function/entity/feed_nearest_entity.ts b/bot/function/entity/feed_nearest_entity.ts new file mode 100644 index 0000000..54b2949 --- /dev/null +++ b/bot/function/entity/feed_nearest_entity.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type NearestItemArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'feed_nearest_entity'; + readonly i18n = {lang: 'zh-CN', name: '喂食最近实体', description: '对指定距离内名称匹配的最近实体使用指定手的物品,由服务器判断是否可以喂食'}; + readonly args = entity_arguments(true, true); + async run(bot: Bot, args: NearestItemArgs): Promise { + return run_entity_operation(bot, args, true, 'item', this.name); + } +} diff --git a/bot/function/entity/null_main_hand_right_cursor_entity.ts b/bot/function/entity/null_main_hand_right_cursor_entity.ts new file mode 100644 index 0000000..26c797a --- /dev/null +++ b/bot/function/entity/null_main_hand_right_cursor_entity.ts @@ -0,0 +1,13 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'null_main_hand_right_cursor_entity'; + readonly i18n = {lang: 'zh-CN', name: '空主手右键光标实体', description: '主手为空时右键交互光标实体,可选瞄准,主手有物品则失败'}; + readonly args = entity_arguments(false, false); + + async run(bot: Bot, args: CursorArgs): Promise { + return run_entity_operation(bot, args, false, 'empty_hand'); + } +} diff --git a/bot/function/entity/null_main_hand_right_nearest_entity.ts b/bot/function/entity/null_main_hand_right_nearest_entity.ts new file mode 100644 index 0000000..ace3e03 --- /dev/null +++ b/bot/function/entity/null_main_hand_right_nearest_entity.ts @@ -0,0 +1,13 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'null_main_hand_right_nearest_entity'; + readonly i18n = {lang: 'zh-CN', name: '空主手右键最近实体', description: '主手为空时右键交互指定距离内名称匹配的最近实体,可选瞄准,主手有物品则失败'}; + readonly args = entity_arguments(true, false); + + async run(bot: Bot, args: NearestArgs): Promise { + return run_entity_operation(bot, args, true, 'empty_hand', this.name); + } +} diff --git a/bot/function/entity/open_cursor_entity_container.ts b/bot/function/entity/open_cursor_entity_container.ts new file mode 100644 index 0000000..4377961 --- /dev/null +++ b/bot/function/entity/open_cursor_entity_container.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'open_cursor_entity_container'; + readonly i18n = {lang: 'zh-CN', name: '打开光标实体容器', description: '激活光标实体并等待新容器窗口,已有窗口时失败,最多等待五秒'}; + readonly args = entity_arguments(false, false); + async run(bot: Bot, args: CursorArgs): Promise { + return run_entity_operation(bot, args, false, 'open'); + } +} diff --git a/bot/function/entity/open_nearest_entity_container.ts b/bot/function/entity/open_nearest_entity_container.ts new file mode 100644 index 0000000..49e8666 --- /dev/null +++ b/bot/function/entity/open_nearest_entity_container.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'open_nearest_entity_container'; + readonly i18n = {lang: 'zh-CN', name: '打开最近实体容器', description: '激活指定距离内名称匹配的最近实体并等待新窗口,已有窗口时失败,最多等待五秒'}; + readonly args = entity_arguments(true, false); + async run(bot: Bot, args: NearestArgs): Promise { + return run_entity_operation(bot, args, true, 'open', this.name); + } +} diff --git a/bot/function/entity/shared.ts b/bot/function/entity/shared.ts new file mode 100644 index 0000000..d4e3b9b --- /dev/null +++ b/bot/function/entity/shared.ts @@ -0,0 +1,165 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {FunctionArgument} from '../../../types/bot/function/function.ts'; +import {on_function_cancel} from '../cancellation.ts'; + +type Entity = NonNullable>; +export type EntityOperation = 'attack' | 'item' | 'open' | 'empty_hand'; +export type CursorArgs = [reach: number, ignore_blocks: boolean, need_look?: boolean]; +export type NearestArgs = [name: string, reach: number, lock_target: boolean, need_look?: boolean]; +export type CursorItemArgs = [reach: number, ignore_blocks: boolean, off_hand: boolean, need_look?: boolean]; +export type NearestItemArgs = [name: string, reach: number, off_hand: boolean, lock_target: boolean, need_look?: boolean]; + +const target_locks = new WeakMap; +}>(); + +export function entity_arguments(nearest: boolean, item: boolean, attack = false): FunctionArgument[] { + const arg = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument => + ({name, type, i18n: {lang: 'zh-CN', name: label, description}}); + const reach = arg(nearest && !attack ? 'max_distance' : 'reach', 'number', '最大距离', '眼睛到实体碰撞箱的最大距离,必须为有限正数'); + const args = nearest + ? [arg('name', 'string', '实体类型 ID 或用户名', '不区分大小写的子串匹配,如 zom 或 minecraft:zom;也支持玩家用户名'), reach] + : [reach, arg('ignore_blocks', 'boolean', '忽略方块遮挡', '是否忽略光标射线上的方块遮挡')]; + if (item) args.push(arg('off_hand', 'boolean', '使用副手', 'false 使用主手,true 使用副手,所选手必须持有物品')); + if (nearest) args.push(arg('lock_target', 'boolean', '持续锁定实体', '相同函数和参数持续操作首次选中的实体,直到实体消失;超距时保留锁定但不操作')); + args.push({...arg('need_look', 'boolean', '瞄准实体', '是否先瞄准实体中心,默认 false'), default: false}); + return args; +} + +function valid_entity(bot: Bot, target: Entity): boolean { + return !!target && target !== bot.entity && target.id !== bot.entity?.id + && !(bot.username && target.username === bot.username) + && target.isValid !== false && bot.entities?.[target.id] === target + && Number.isFinite(target.id) && !!target.position + && [target.position.x, target.position.y, target.position.z, target.width, target.height].every(Number.isFinite) + && target.width > 0 && target.height > 0; +} + +function distance(bot: Bot, target: Entity): number { + const self = bot.entity; + const eye_height = (self as Entity & {eyeHeight?: number})?.eyeHeight ?? self?.height; + if (!self?.position || ![self.position.x, self.position.y, self.position.z, eye_height].every(Number.isFinite)) return Infinity; + const half = target.width / 2; + const dx = Math.max(Math.abs(self.position.x - target.position.x) - half, 0); + const dy = Math.max(target.position.y - (self.position.y + eye_height), self.position.y + eye_height - (target.position.y + target.height), 0); + const dz = Math.max(Math.abs(self.position.z - target.position.z) - half, 0); + return Math.hypot(dx, dy, dz); +} + +function has_item(bot: Bot, off_hand: boolean): boolean { + return off_hand ? !bot.supportFeature('doesntHaveOffHandSlot') && !!bot.inventory?.slots[45] : !!bot.heldItem; +} + +function open_container(bot: Bot, target: Entity, active: () => boolean): Promise { + if (bot.currentWindow || !active()) return Promise.resolve(false); + return new Promise(resolve => { + let settled = false; + const finish = (success: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + bot.removeListener('windowOpen', on_open); + bot.removeListener('end', on_end); + unwatch(); + resolve(success); + }; + const on_open = (window: Bot['currentWindow']) => { + finish(!!window && active() && bot.currentWindow === window); + }; + const on_end = () => finish(false); + const timer = setTimeout(() => finish(false), 5000); + const unwatch = on_function_cancel(bot, on_end); + bot.on('windowOpen', on_open); + bot.on('end', on_end); + try { + Promise.resolve(bot.interactEntity(target, {hand: 0})).catch(() => finish(false)); + } catch { + finish(false); + } + }); +} + +export async function run_entity_operation( + bot: Bot, args: unknown[], nearest: boolean, operation: EntityOperation, function_name: string = operation, +): Promise { + const item = operation === 'item'; + const required_count = (item ? 3 : 2) + (nearest ? 1 : 0); + if (!Array.isArray(args) || args.length !== required_count && args.length !== required_count + 1) return false; + if (args.length > required_count && typeof args[required_count] !== 'boolean') return false; + const need_look = args[required_count] === true; + args = args.slice(0, required_count); + const reach = args[nearest ? 1 : 0]; + if (typeof reach !== 'number' || !Number.isFinite(reach) || reach <= 0) return false; + if (nearest ? typeof args[0] !== 'string' || !args[0].trim() : typeof args[1] !== 'boolean') return false; + if (item && typeof args[2] !== 'boolean') return false; + if (nearest && typeof args.at(-1) !== 'boolean') return false; + const self = bot.entity; + const client = bot._client; + let ended = false; + let cancel!: () => void; + const cancelled = new Promise(resolve => { cancel = () => resolve(false); }); + const on_end = () => { ended = true; cancel(); }; + const unwatch = on_function_cancel(bot, on_end); + const active = () => !ended && !!self && bot.entity === self && bot._client === client && self.isValid !== false; + try { + bot.on('end', on_end); + if (operation === 'empty_hand' && bot.heldItem) return false; + if (!active() || operation === 'open' && bot.currentWindow || item && !has_item(bot, args[2] as boolean)) return false; + let target: Entity | undefined; + if (nearest) { + let locks = target_locks.get(bot); + if (!locks || locks.self !== self || locks.client !== client) { + locks = {self, client, targets: new Map()}; + target_locks.set(bot, locks); + } + for (const [key, entity] of locks.targets) { + if (!valid_entity(bot, entity)) locks.targets.delete(key); + } + const lock_target = args.at(-1) === true; + const key = JSON.stringify([function_name, [...args, need_look]]); + target = lock_target ? locks.targets.get(key) : undefined; + if (!target) { + const name = (args[0] as string).trim().toLowerCase(); + const entity_name = name.replace(/^minecraft:/, ''); + target = Object.values(bot.entities).filter(entity => valid_entity(bot, entity) + && (entity_name.length > 0 && typeof entity.name === 'string' + && entity.name.toLowerCase().replace(/^minecraft:/, '').includes(entity_name) + || typeof entity.username === 'string' && entity.username.toLowerCase().includes(name))) + .map(entity => ({entity, distance: distance(bot, entity)})) + .filter(entry => Number.isFinite(entry.distance) && entry.distance <= reach) + .sort((a, b) => a.distance - b.distance)[0]?.entity; + if (lock_target && target) locks.targets.set(key, target); + } + } else { + target = bot.entityAtCursor(reach, true, args[1] as boolean) ?? undefined; + } + const in_reach = () => active() && valid_entity(bot, target!) && distance(bot, target!) <= reach; + if (!target || !in_reach()) return false; + if (need_look) { + if (!await Promise.race([ + bot.lookAt(target.position.offset(0, target.height / 2, 0)).then(() => true, () => false), cancelled, + ])) return false; + if (!in_reach()) return false; + } + if (operation === 'attack') { + bot.attack(target); + return true; + } + if (operation === 'empty_hand') { + if (bot.heldItem) return false; + bot.interactEntity(target, {hand: 0}); + return true; + } + if (item) { + if (!has_item(bot, args[2] as boolean)) return false; + bot.interactEntity(target, {hand: args[2] ? 1 : 0, position: target.position.offset(0, target.height / 2, 0)}); + return true; + } + return await open_container(bot, target, active); + } catch { + return false; + } finally { + unwatch(); + bot.removeListener('end', on_end); + } +} diff --git a/bot/function/entity/use_item_on_cursor_entity.ts b/bot/function/entity/use_item_on_cursor_entity.ts new file mode 100644 index 0000000..e034ca3 --- /dev/null +++ b/bot/function/entity/use_item_on_cursor_entity.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type CursorItemArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'use_item_on_cursor_entity'; + readonly i18n = {lang: 'zh-CN', name: '对光标实体使用物品', description: '对光标实体使用指定手的物品,可选瞄准,不自动移动'}; + readonly args = entity_arguments(false, true); + async run(bot: Bot, args: CursorItemArgs): Promise { + return run_entity_operation(bot, args, false, 'item'); + } +} diff --git a/bot/function/entity/use_item_on_nearest_entity.ts b/bot/function/entity/use_item_on_nearest_entity.ts new file mode 100644 index 0000000..de4c5c7 --- /dev/null +++ b/bot/function/entity/use_item_on_nearest_entity.ts @@ -0,0 +1,12 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction} from '../../../types/bot/function/function.ts'; +import {entity_arguments, run_entity_operation, type NearestItemArgs} from './shared.ts'; + +export class init implements BaseFunction { + readonly name = 'use_item_on_nearest_entity'; + readonly i18n = {lang: 'zh-CN', name: '对最近实体使用物品', description: '对指定距离内名称匹配的最近实体使用指定手的物品,可选瞄准'}; + readonly args = entity_arguments(true, true); + async run(bot: Bot, args: NearestItemArgs): Promise { + return run_entity_operation(bot, args, true, 'item', this.name); + } +} diff --git a/bot/function/init.ts b/bot/function/init.ts new file mode 100644 index 0000000..31f9afa --- /dev/null +++ b/bot/function/init.ts @@ -0,0 +1,284 @@ +import {readdir} from 'node:fs/promises'; +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument, FunctionI18n} from '../../types/bot/function/function.ts'; +import type {data_type} from '../../types/bot/instance/bot_runtime.ts'; +import type {bot_instance_context} from '../instance/bot_instance_init.ts'; +import {create_function_cancellation, on_function_cancel} from './cancellation.ts'; +import {measure_sync} from '../info/resource_metrics.ts'; + +export interface FunctionTemplate { + name: string; + i18n: FunctionI18n; + args: FunctionArgument[]; +} + +export interface FunctionCategory { + path: string; + name: string; + i18n: FunctionI18n[]; + function_list: FunctionTemplate[]; +} + +export type FunctionList = FunctionCategory[]; + +export interface FunctionManager { + get_function_list(): FunctionList; + run(path: string, name: string, args: unknown[], before_run?: () => void, + options?: {signal?: AbortSignal}): Promise; + /** Cancels queued calls and notifies the running call, which drains before rejecting. */ + dispose(): void; +} + +const data_types = new Set([ + 'string', 'boolean', 'number', 'datetime', 'string-select', 'number-select', + 'string-multi-select', 'number-multi-select', 'string-array', 'number-array', +]); +const valid_name = (value: unknown): value is string => typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value); +const valid_i18n = (value: unknown): value is FunctionI18n => { + if (!value || typeof value !== 'object') return false; + const item = value as FunctionI18n; + return typeof item.lang === 'string' && item.lang.length > 0 + && typeof item.name === 'string' && typeof item.description === 'string'; +}; + +async function load_functions(check_active: () => void = () => {}): Promise<{ + list: FunctionList; + functions: Map; +}> { + const list: FunctionList = []; + const functions = new Map(); + const directories = ['']; + const stamp = `${Date.now()}-${process.hrtime.bigint()}`; + const import_module = async (url: URL): Promise> => { + check_active(); + url.searchParams.set('t', stamp); + const module = await import(url.href); + check_active(); + return module; + }; + for (const path of directories) { + check_active(); + const directory = new URL(path ? `./${path}/` : './', import.meta.url); + const entries = await readdir(directory, {withFileTypes: true}); + check_active(); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.isDirectory() && valid_name(entry.name)) directories.push(path ? `${path}/${entry.name}` : entry.name); + } + if (!path) continue; + const files = entries.filter(entry => entry.isFile() && entry.name !== 'define.ts' + && /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)); + const has_define = entries.some(entry => entry.isFile() && entry.name === 'define.ts'); + if (!files.length && !has_define) continue; + if (!has_define) throw new Error(`函数分类 ${path} 缺少 define.ts`); + try { + const {define} = await import_module(new URL('define.ts', directory)); + const config = define as {path?: unknown; name?: unknown; i18n?: unknown} | undefined; + if (config?.path !== path || !Array.isArray(config.i18n) || !config.i18n.length + || !config.i18n.every(valid_i18n) || config.name !== undefined && !valid_name(config.name)) { + throw new Error('分类 define 无效,path 必须与目录路径一致'); + } + const category: FunctionCategory = { + path, name: typeof config.name === 'string' ? config.name : path.split('/').at(-1)!, + i18n: config.i18n.map(item => ({...item})), function_list: [], + }; + for (const file of files) { + const name = file.name.slice(0, -3); + try { + const module = await import_module(new URL(file.name, directory)); + if (!Object.hasOwn(module, 'init')) continue; + if (typeof module.init !== 'function') throw new Error('必须导出 init 类'); + const instance = new (module.init as new () => BaseFunction)(); + if (instance.name !== name || !valid_i18n(instance.i18n) + || !Array.isArray(instance.args) || typeof instance.run !== 'function') { + throw new Error('函数必须满足 BaseFunction 且 name 与文件名一致'); + } + const names = new Set(); + let optional = false; + for (const arg of instance.args) { + if (!arg || !valid_name(arg.name) || names.has(arg.name) + || !data_types.has(arg.type) || !valid_i18n(arg.i18n)) { + throw new Error('函数参数定义无效或名称重复'); + } + if (Object.hasOwn(arg, 'default')) { + if (arg.type !== 'boolean' || typeof arg.default !== 'boolean') throw new Error('默认值必须匹配布尔参数'); + optional = true; + } else if (optional) throw new Error('带默认值的参数必须位于末尾'); + names.add(arg.name); + } + const key = `${path}/${name}`; + if (functions.has(key)) throw new Error('函数重复注册'); + functions.set(key, instance); + category.function_list.push({name, i18n: {...instance.i18n}, + args: instance.args.map(arg => ({name: arg.name, type: arg.type, i18n: {...arg.i18n}, + ...(Object.hasOwn(arg, 'default') ? {default: arg.default} : {})}))}); + } catch (error) { + throw new Error(`函数 ${path}/${name} 加载失败`, {cause: error}); + } + } + list.push(category); + } catch (error) { + throw new Error(`函数分类 ${path} 加载失败`, {cause: error}); + } + } + list.sort((a, b) => a.path.localeCompare(b.path)); + return {list, functions}; +} + +/** Builds grouped metadata without a Bot; constructors must have no side effects. */ +export async function get_function_list(): Promise { + return (await load_functions()).list; +} + +function valid_argument(value: unknown, type: data_type): boolean { + switch (type) { + case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value); + case 'string': case 'string-select': return typeof value === 'string'; + case 'boolean': return typeof value === 'boolean'; + case 'datetime': return typeof value === 'string' + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) && Number.isFinite(Date.parse(value)); + case 'string-array': case 'string-multi-select': + return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string'); + case 'number-array': case 'number-multi-select': + return Array.isArray(value) && Array.from(value).every(item => typeof item === 'number' && Number.isFinite(item)); + } +} + +/** The caller owns disposal; this manager never registers end listeners or follows a replacement Bot. */ +export async function create_function_manager( + context: Pick, 'bot_instance' | 'bot_status'> + & Partial, 'workflow_manager'>>, +): Promise { + const bot: Bot | null = context.bot_instance; + if (!bot) throw new Error('Bot 实例尚未创建'); + const check_instance = () => { + if (context.bot_instance !== bot) throw new Error('Bot 实例已变化'); + }; + const {list, functions} = await load_functions(check_instance); + let disposed = false; + let tail = Promise.resolve(); + const pending = new Set<{reject: (error: unknown) => void; resolve: (value: boolean) => void}>(); + let cancel_running: ((reason: unknown) => void) | undefined; + const check_active = () => { + if (disposed) throw new Error('Function manager 已销毁'); + check_instance(); + }; + return { + get_function_list() { + check_active(); + return structuredClone(list); + }, + async run(path, name, args, before_run, options) { + const signal = options?.signal; + signal?.throwIfAborted(); + check_active(); + const template = list.find(category => category.path === path)?.function_list.find(item => item.name === name); + const instance = template && functions.get(`${path}/${name}`); + if (!instance || !template) throw new Error('函数不存在'); + if (!Array.isArray(args) || args.length > template.args.length + || !template.args.every((arg, index) => index < args.length + ? valid_argument(args[index], arg.type) : Object.hasOwn(arg, 'default'))) { + throw new Error('函数参数数量或类型不正确'); + } + const values = structuredClone(template.args.map((arg, index) => index < args.length ? args[index] : arg.default)); + if (path === 'workflow' && instance.execution === 'workflow_control') { + const workflow = context.workflow_manager; + const check_control = () => { + signal?.throwIfAborted(); + check_active(); + if (context.workflow_manager !== workflow) throw new Error('Workflow manager 已变化'); + }; + before_run?.(); + check_control(); + if (!workflow) return false; + // Bind only the control capability to this dispatch, never the raw Bot context. + let result = false; + try { + result = await measure_sync(bot, () => instance.run(bot, values, {workflow: { + control(request) { + check_control(); + const command = workflow.control(request); + check_control(); + return command; + }, + }})) === true; + } catch { /* Function failures retain the boolean API. */ } + check_control(); + return result; + } + if (context.bot_status !== 'online') throw new Error('Bot 当前不在线'); + if (path === 'bot' && name === 'stop_current_function') { + before_run?.(); + signal?.throwIfAborted(); + check_active(); + for (const task of pending) task.resolve(false); + pending.clear(); + const result = await measure_sync(bot, () => instance.run(bot, values)); + signal?.throwIfAborted(); + return result; + } + if (pending.size >= 64) throw new Error('函数等待队列已满'); + return new Promise((resolve, reject) => { + let cancel_execution: ((reason: unknown) => void) | undefined; + const cleanup = () => signal?.removeEventListener('abort', abort); + const task = { + reject(error: unknown) { cleanup(); reject(error); }, + resolve(value: boolean) { cleanup(); resolve(value); }, + }; + const abort = () => { + if (pending.delete(task)) task.reject(signal!.reason); + else cancel_execution?.(signal!.reason); + }; + pending.add(task); + signal?.addEventListener('abort', abort, {once: true}); + const execute = async () => { + if (!pending.delete(task)) return; + try { + check_active(); + if (context.bot_status !== 'online') throw new Error('Bot 当前不在线'); + signal?.throwIfAborted(); + before_run?.(); + signal?.throwIfAborted(); + check_active(); + const cancellation = create_function_cancellation(bot); + let interrupted = false; + let reason: unknown; + cancel_running = cancel_execution = error => { + if (interrupted) return; + interrupted = true; + reason = error; + cancellation.cancel(); + }; + try { + await cancellation.run(async () => { + // Global stop may answer early, but the queue still drains instance.run. + const unwatch = on_function_cancel(bot, () => { + if (!interrupted) task.resolve(false); + }); + let result = false; + try { result = await measure_sync(bot, () => instance.run(bot, values)) === true; } + catch { /* Function failures retain the boolean API. */ } + finally { unwatch(); } + if (interrupted) task.reject(reason); + else task.resolve(result); + }); + } finally { + cleanup(); + cancel_running = cancel_execution = undefined; + cancellation.dispose(); + } + } catch (error) { task.reject(error); } + }; + tail = tail.then(execute, execute); + }); + }, + dispose() { + if (disposed) return; + disposed = true; + for (const task of pending) task.reject(new Error('Function manager 已销毁,排队调用已取消')); + pending.clear(); + functions.clear(); + cancel_running?.(new Error('Function manager 已销毁,当前调用已取消')); + }, + }; +} diff --git a/bot/function/inventory/change_main_hand_slot.ts b/bot/function/inventory/change_main_hand_slot.ts new file mode 100644 index 0000000..8a21baa --- /dev/null +++ b/bot/function/inventory/change_main_hand_slot.ts @@ -0,0 +1,24 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; + +export class init implements BaseFunction { + readonly name = 'change_main_hand_slot'; + readonly i18n = { + lang: 'zh-CN', name: '切换主手槽位', description: '切换主手选中的快捷栏槽位,编号为 0 到 8', + }; + readonly args: FunctionArgument[] = [{ + name: 'slot', type: 'number', + i18n: {lang: 'zh-CN', name: '快捷栏槽位', description: '0 到 8 的整数,分别对应快捷栏第 1 到第 9 格'}, + }]; + + async run(bot: Bot, args: number[]): Promise { + if (!Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0]) + || args[0] < 0 || args[0] > 8) return false; + try { + bot.setQuickBarSlot(args[0]); + return bot.quickBarSlot === args[0]; + } catch { + return false; + } + } +} diff --git a/bot/function/inventory/close_current_window.ts b/bot/function/inventory/close_current_window.ts new file mode 100644 index 0000000..d594b66 --- /dev/null +++ b/bot/function/inventory/close_current_window.ts @@ -0,0 +1,22 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; + +export class init implements BaseFunction<[]> { + readonly name = 'close_current_window'; + readonly i18n = { + lang: 'zh-CN', name: '关闭当前窗口', description: '关闭当前打开的容器窗口;没有打开窗口时直接成功', + }; + readonly args: FunctionArgument[] = []; + + async run(bot: Bot, args: []): Promise { + if (!Array.isArray(args) || args.length !== 0) return false; + const window = bot.currentWindow; + if (!window) return true; + try { + await bot.closeWindow(window); + return bot.currentWindow == null; + } catch { + return false; + } + } +} diff --git a/bot/function/inventory/define.ts b/bot/function/inventory/define.ts new file mode 100644 index 0000000..f62a076 --- /dev/null +++ b/bot/function/inventory/define.ts @@ -0,0 +1,12 @@ +import {base_define} from "../../../types/bot/plugins/plugin.ts"; + +export const define: base_define = { + path: "inventory", + i18n: [ + { + lang: "zh-CN", + name: "容器操作", + description: "Bot 容器操作, 提供容器相关功能" + } + ] +} \ No newline at end of file diff --git a/bot/function/inventory/left_click_slot.ts b/bot/function/inventory/left_click_slot.ts new file mode 100644 index 0000000..4f29db2 --- /dev/null +++ b/bot/function/inventory/left_click_slot.ts @@ -0,0 +1,25 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; + +export class init implements BaseFunction { + readonly name = 'left_click_slot'; + readonly i18n = { + lang: 'zh-CN', name: '左键点击槽位', description: '在当前窗口左键点击一个物品槽位,使用原生拾取、放置或合并行为', + }; + readonly args: FunctionArgument[] = [{ + name: 'slot', type: 'number', + i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'}, + }]; + + async run(bot: Bot, args: number[]): Promise { + const window = bot.currentWindow ?? bot.inventory; + if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0]) + || args[0] < 0 || args[0] >= window.slots.length) return false; + try { + await bot.clickWindow(args[0], 0, 0); + return (bot.currentWindow ?? bot.inventory) === window; + } catch { + return false; + } + } +} diff --git a/bot/function/inventory/right_click_slot.ts b/bot/function/inventory/right_click_slot.ts new file mode 100644 index 0000000..beae272 --- /dev/null +++ b/bot/function/inventory/right_click_slot.ts @@ -0,0 +1,25 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; + +export class init implements BaseFunction { + readonly name = 'right_click_slot'; + readonly i18n = { + lang: 'zh-CN', name: '右键点击槽位', description: '在当前窗口右键点击一个物品槽位,使用原生半组拾取或单个放置行为', + }; + readonly args: FunctionArgument[] = [{ + name: 'slot', type: 'number', + i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'}, + }]; + + async run(bot: Bot, args: number[]): Promise { + const window = bot.currentWindow ?? bot.inventory; + if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0]) + || args[0] < 0 || args[0] >= window.slots.length) return false; + try { + await bot.clickWindow(args[0], 1, 0); + return (bot.currentWindow ?? bot.inventory) === window; + } catch { + return false; + } + } +} diff --git a/bot/function/inventory/slot_switch_slot.ts b/bot/function/inventory/slot_switch_slot.ts new file mode 100644 index 0000000..27507bf --- /dev/null +++ b/bot/function/inventory/slot_switch_slot.ts @@ -0,0 +1,45 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; +import {Mutex} from 'async-mutex'; +import {cancellation_generation} from '../cancellation.ts'; + +const locks = new WeakMap(); + +export class init implements BaseFunction { + readonly name = 'slot_switch_slot'; + readonly i18n = { + lang: 'zh-CN', name: '物品交换', description: '依次左键点击源槽位、目标槽位,并将鼠标剩余物品放回源槽位;同类物品按原生规则合并', + }; + readonly args: FunctionArgument[] = [ + {name: 'slot', type: 'number', i18n: {lang: 'zh-CN', name: '物品槽位', description: '第一个槽位编号'}}, + {name: 'new_slot', type: 'number', i18n: {lang: 'zh-CN', name: '目标槽位', description: '第二个槽位编号'}}, + ]; + + async run(bot: Bot, args: number[]): Promise { + if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) return false; + const window = bot.currentWindow ?? bot.inventory; + if (!window) return false; + const generation = cancellation_generation(bot); + const active = () => cancellation_generation(bot) === generation; + let lock = locks.get(bot); + if (!lock) locks.set(bot, lock = new Mutex()); + return lock.runExclusive(async () => { + const [slot, new_slot] = args; + if (!active() || (bot.currentWindow ?? bot.inventory) !== window || window.selectedItem + || slot === new_slot || args.some(value => value < 0 || value >= window.slots.length)) return false; + try { + await bot.clickWindow(slot, 0, 0); + if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false; + await bot.clickWindow(new_slot, 0, 0); + if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false; + // An empty cursor means a final click would pick the source item up again. + if (window.selectedItem) { + await bot.clickWindow(slot, 0, 0); + } + return active() && (bot.currentWindow ?? bot.inventory) === window && !window.selectedItem; + } catch { + return false; + } + }); + } +} diff --git a/bot/function/inventory/slot_to_slot.ts b/bot/function/inventory/slot_to_slot.ts new file mode 100644 index 0000000..3f7599c --- /dev/null +++ b/bot/function/inventory/slot_to_slot.ts @@ -0,0 +1,65 @@ +import type {Bot} from "@wp2508/mineflayer"; +import type {BaseFunction, FunctionArgument} from "../../../types/bot/function/function.ts"; +import {Mutex} from "async-mutex"; +import {cancellation_generation} from '../cancellation.ts'; + +const locks = new WeakMap(); + +export class init implements BaseFunction { + public name = "slot_to_slot"; + public i18n = { + lang: "zh-CN", + name: "物品移动", + description: "将物品从一个槽位移动到另一个槽位, 目标槽位必须为空" + } + public args: FunctionArgument[] = [ + { + name: "slot", + type: "number", + i18n: { + lang: "zh-CN", + name: "物品槽位", + description: "物品槽位" + } + }, + { + name: "new_slot", + type: "number", + i18n: { + lang: "zh-CN", + name: "新物品槽位", + description: "新物品槽位" + } + } + ] + + async run(bot: Bot, args: number[]): Promise { + if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) { + return false; + } + const [slot, new_slot] = args; + const window = bot.currentWindow ?? bot.inventory; + if (!window) return false; + const generation = cancellation_generation(bot); + const active = () => cancellation_generation(bot) === generation; + let lock = locks.get(bot); + if (!lock) locks.set(bot, lock = new Mutex()); + return lock.runExclusive(async () => { + if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false; + if ([slot, new_slot].some(value => value < 0 || value >= window.slots.length)) { + return false; + } + if (slot === new_slot || !window.slots[slot] || window.slots[new_slot] || window.selectedItem) return false; + + try { + await bot.clickWindow(slot, 0, 0); + // Never apply the destination click to a replacement window. + if (!active() || (bot.currentWindow ?? bot.inventory) !== window || window.slots[new_slot]) return false; + await bot.clickWindow(new_slot, 0, 0); + return active(); + } catch { + return false; + } + }); + } +} diff --git a/bot/function/item/define.ts b/bot/function/item/define.ts new file mode 100644 index 0000000..dd90273 --- /dev/null +++ b/bot/function/item/define.ts @@ -0,0 +1,12 @@ +import {base_define} from "../../../types/bot/plugins/plugin.ts"; + +export const define: base_define = { + path: "item", + i18n: [ + { + lang: "zh-CN", + name: "物品操作", + description: "Bot 物品操作, 提供物品相关功能" + } + ] +} \ No newline at end of file diff --git a/bot/function/item/drop_slot_all_item.ts b/bot/function/item/drop_slot_all_item.ts new file mode 100644 index 0000000..72c82e2 --- /dev/null +++ b/bot/function/item/drop_slot_all_item.ts @@ -0,0 +1,26 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; + +export class init implements BaseFunction { + readonly name = 'drop_slot_all_item'; + readonly i18n = { + lang: 'zh-CN', name: '丢弃整组物品', description: '丢弃当前窗口指定槽位的整组物品,不主动关闭窗口', + }; + readonly args: FunctionArgument[] = [{ + name: 'slot', type: 'number', + i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'}, + }]; + + async run(bot: Bot, args: number[]): Promise { + const window = bot.currentWindow ?? bot.inventory; + if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0]) + || args[0] < 0 || args[0] >= window.slots.length || !window.slots[args[0]] + || window.selectedItem) return false; + try { + await bot.clickWindow(args[0], 1, 4); + return (bot.currentWindow ?? bot.inventory) === window && !window.slots[args[0]]; + } catch { + return false; + } + } +} diff --git a/bot/function/item/drop_slot_item_number.ts b/bot/function/item/drop_slot_item_number.ts new file mode 100644 index 0000000..7554b16 --- /dev/null +++ b/bot/function/item/drop_slot_item_number.ts @@ -0,0 +1,44 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; + +export class init implements BaseFunction { + readonly name = 'drop_slot_item_number'; + readonly i18n = { + lang: 'zh-CN', name: '丢弃指定数量物品', description: '逐个丢弃指定槽位的物品,数量不超过槽位现有数量', + }; + readonly args: FunctionArgument[] = [ + {name: 'slot', type: 'number', i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'}}, + {name: 'count', type: 'number', i18n: {lang: 'zh-CN', name: '丢弃数量', description: '大于零的整数,超过现有数量时丢弃全部'}}, + ]; + + async run(bot: Bot, args: number[]): Promise { + if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) return false; + const [slot, count] = args; + const window = bot.currentWindow ?? bot.inventory; + if (!window || slot < 0 || slot >= window.slots.length || count <= 0 || window.selectedItem) return false; + const item = window.slots[slot]; + if (!item) return false; + const drop_count = Math.min(count, item.count); + const initial_count = item.count; + const type = item.type; + const metadata = item.metadata; + try { + for (let index = 0; index < drop_count; index++) { + const current = window.slots[slot]; + if ((bot.currentWindow ?? bot.inventory) !== window || window.selectedItem + || !current || current.type !== type || current.metadata !== metadata + || current.count !== initial_count - index) return false; + await bot.clickWindow(slot, 0, 4); + if ((bot.currentWindow ?? bot.inventory) !== window || window.selectedItem) return false; + const remaining = window.slots[slot]; + const expected = initial_count - index - 1; + if (expected === 0 ? remaining !== null && remaining !== undefined + : !remaining || remaining.type !== type || remaining.metadata !== metadata + || remaining.count !== expected) return false; + } + return true; + } catch { + return false; + } + } +} diff --git a/bot/function/item/use_item.ts b/bot/function/item/use_item.ts new file mode 100644 index 0000000..c5cda1a --- /dev/null +++ b/bot/function/item/use_item.ts @@ -0,0 +1,27 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts'; + +export class init implements BaseFunction { + readonly name = 'use_item'; + readonly i18n = { + lang: 'zh-CN', name: '使用手持物品', description: '发起主手或副手物品的使用,不等待使用完成,也不自动停止持续使用', + }; + readonly args: FunctionArgument[] = [{ + name: 'off_hand', type: 'boolean', + i18n: {lang: 'zh-CN', name: '使用副手', description: 'false 使用主手,true 使用副手'}, + }]; + + async run(bot: Bot, args: boolean[]): Promise { + if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'boolean') return false; + try { + const off_hand = args[0]; + if (off_hand) { + if (bot.supportFeature('doesntHaveOffHandSlot') || !bot.inventory?.slots[45]) return false; + } else if (!bot.heldItem) return false; + bot.activateItem(off_hand); + return true; + } catch { + return false; + } + } +} diff --git a/bot/function/workflow/define.ts b/bot/function/workflow/define.ts new file mode 100644 index 0000000..f1d2845 --- /dev/null +++ b/bot/function/workflow/define.ts @@ -0,0 +1,6 @@ +import type {base_define} from '../../../types/bot/plugins/plugin.ts'; + +export const define: base_define = { + path: 'workflow', + i18n: [{lang: 'zh-CN', name: '工作流控制', description: '运行、停止、管理工作流触发器和重新加载工作流'}], +}; diff --git a/bot/function/workflow/reload.ts b/bot/function/workflow/reload.ts new file mode 100644 index 0000000..683bc6e --- /dev/null +++ b/bot/function/workflow/reload.ts @@ -0,0 +1,6 @@ +import {WorkflowControlFunction} from './shared.ts'; + +export class init extends WorkflowControlFunction { + readonly name = 'reload'; + readonly i18n = {lang: 'zh-CN', name: '重新加载工作流', description: '提交重新加载指定工作流的请求,接受后立即返回,不等待加载完成'}; +} diff --git a/bot/function/workflow/run_once.ts b/bot/function/workflow/run_once.ts new file mode 100644 index 0000000..2fea67a --- /dev/null +++ b/bot/function/workflow/run_once.ts @@ -0,0 +1,6 @@ +import {WorkflowControlFunction} from './shared.ts'; + +export class init extends WorkflowControlFunction { + readonly name = 'run_once'; + readonly i18n = {lang: 'zh-CN', name: '运行工作流一次', description: '提交工作流单次运行请求,接受后立即返回,不等待运行完成'}; +} diff --git a/bot/function/workflow/shared.ts b/bot/function/workflow/shared.ts new file mode 100644 index 0000000..8108244 --- /dev/null +++ b/bot/function/workflow/shared.ts @@ -0,0 +1,26 @@ +import {randomUUID} from 'node:crypto'; +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseFunction, FunctionExecutionContext, FunctionI18n} from '../../../types/bot/function/function.ts'; +import type {WorkflowAction} from '../../../types/bot/workflow/runtime.ts'; + +export abstract class WorkflowControlFunction implements BaseFunction<[string]> { + abstract readonly name: WorkflowAction; + abstract readonly i18n: FunctionI18n; + readonly execution = 'workflow_control'; + readonly args = [{name: 'workflow_id', type: 'string' as const, + i18n: {lang: 'zh-CN', name: '工作流 ID', description: '目标工作流的唯一标识'}}]; + + async run(_bot: Bot, args: [string], executionContext?: FunctionExecutionContext): Promise { + if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'string' + || args[0].length < 1 || args[0].length > 100 || /[^a-zA-Z0-9_-]/.test(args[0]) + || ['__proto__', 'prototype', 'constructor'].includes(args[0])) return false; + const workflow = executionContext?.workflow; + if (!workflow) return false; + try { + const command = workflow.control({command_id: randomUUID(), action: this.name, + ...(this.name === 'stop_run' ? {run_id: args[0]} : {workflow_id: args[0]})}); + // Acknowledgement is enough; waiting for completion would deadlock a self-stop. + return command.state === 'accepted' || command.state === 'completed'; + } catch { return false; } + } +} diff --git a/bot/function/workflow/stop.ts b/bot/function/workflow/stop.ts new file mode 100644 index 0000000..e664420 --- /dev/null +++ b/bot/function/workflow/stop.ts @@ -0,0 +1,6 @@ +import {WorkflowControlFunction} from './shared.ts'; + +export class init extends WorkflowControlFunction { + readonly name = 'stop'; + readonly i18n = {lang: 'zh-CN', name: '停止工作流', description: '提交停止指定工作流的请求,接受后立即返回,不等待运行结束'}; +} diff --git a/bot/function/workflow/stop_run.ts b/bot/function/workflow/stop_run.ts new file mode 100644 index 0000000..e2458b0 --- /dev/null +++ b/bot/function/workflow/stop_run.ts @@ -0,0 +1,8 @@ +import {WorkflowControlFunction} from './shared.ts'; + +export class init extends WorkflowControlFunction { + readonly name = 'stop_run'; + readonly i18n = {lang: 'zh-CN', name: '停止工作流运行实例', description: '提交停止指定运行实例的请求,接受后立即返回,不等待实例结束'}; + readonly args = [{name: 'run_id', type: 'string' as const, + i18n: {lang: 'zh-CN', name: '运行实例 ID', description: '目标工作流运行实例的唯一标识'}}]; +} diff --git a/bot/function/workflow/trigger_start.ts b/bot/function/workflow/trigger_start.ts new file mode 100644 index 0000000..62e00bf --- /dev/null +++ b/bot/function/workflow/trigger_start.ts @@ -0,0 +1,6 @@ +import {WorkflowControlFunction} from './shared.ts'; + +export class init extends WorkflowControlFunction { + readonly name = 'trigger_start'; + readonly i18n = {lang: 'zh-CN', name: '启动工作流触发器', description: '提交启动指定工作流触发器的请求,接受后立即返回'}; +} diff --git a/bot/function/workflow/trigger_stop.ts b/bot/function/workflow/trigger_stop.ts new file mode 100644 index 0000000..7d5438b --- /dev/null +++ b/bot/function/workflow/trigger_stop.ts @@ -0,0 +1,6 @@ +import {WorkflowControlFunction} from './shared.ts'; + +export class init extends WorkflowControlFunction { + readonly name = 'trigger_stop'; + readonly i18n = {lang: 'zh-CN', name: '停止工作流触发器', description: '提交停止指定工作流触发器的请求,接受后立即返回'}; +} diff --git a/bot/info/bot/entity.ts b/bot/info/bot/entity.ts new file mode 100644 index 0000000..78ad336 --- /dev/null +++ b/bot/info/bot/entity.ts @@ -0,0 +1,84 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from '../types.ts'; +import type {log} from '../../../types/utils/logger.ts'; +import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts'; + +export interface EntityInfo { + entity_count: number; + player_count: number; + players: log[][]; + entities: log[][]; +} + +export class init implements BaseInfo { + readonly name = 'entity'; + readonly i18n = { + lang: 'zh-CN', name: '附近实体', description: '当前已加载的附近实体和玩家,不包含机器人自身', + fields: { + entity_count: {lang: 'zh-CN', name: '附近实体数量', description: '已加载的实体数量,包含玩家,不包含机器人自身'}, + player_count: {lang: 'zh-CN', name: '附近玩家数量', description: '已加载的其他玩家实体数量'}, + players: {lang: 'zh-CN', name: '附近玩家列表', description: '每个玩家名称返回一组 log 片段,优先使用玩家对象的 displayName,其次 username'}, + entities: {lang: 'zh-CN', name: '附近实体列表', description: '按显示名称合并去重,每个名称返回一组 log 片段并保留首次出现的样式,包含玩家'}, + }, + } satisfies InfoI18n; + + private info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []}; + private disposed = false; + private readonly listeners = new Set<(data: EntityInfo) => void>(); + private readonly events = ['entitySpawn', 'entityGone', 'entityUpdate', 'playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'respawn', 'physicsTick'] as const; + + constructor(private readonly instance: Bot) { + this.handle_entities(); + for (const event of this.events) instance.on(event, this.handle_entities); + } + + private readonly handle_entities = (): void => { + if (this.disposed) return; + + const bot = this.instance; + const info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []}; + const entity_names = new Set(); + const players = new Map(Object.values(bot.players ?? {}) + .filter(player => player.entity) + .map(player => [player.entity.id, player])); + for (const entity of Object.values(bot.entities ?? {}).sort((a, b) => a.id - b.id)) { + if (entity.id === bot.entity?.id || entity.isValid === false) continue; + + const player = players.get(entity.id) ?? (entity.username ? bot.players?.[entity.username] : undefined); + const is_player = entity.type === 'player' || Boolean(player); + const fallback = player?.username || entity.username || entity.name || '未知'; + const display_name = is_player ? player?.displayName ?? entity.displayName : entity.displayName; + const name = MinecraftJsonParser.toLog(display_name ?? fallback); + const segments = name.length ? name : MinecraftJsonParser.toLog(fallback); + info.entity_count++; + const entity_name = segments.map(segment => segment.message).join(''); + if (!entity_names.has(entity_name)) { + entity_names.add(entity_name); + info.entities.push(segments); + } + if (is_player) info.players.push(segments); + } + info.player_count = info.players.length; + + if (JSON.stringify(this.info) === JSON.stringify(info)) return; + this.info = info; + for (const callback of Array.from(this.listeners)) callback(info); + }; + + get_info(): EntityInfo { + return this.info; + } + + on_update(callback: (data: EntityInfo) => void): () => void { + if (this.disposed) throw new Error('EntityInfo 已销毁'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + for (const event of this.events) this.instance.off(event, this.handle_entities); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/food.ts b/bot/info/bot/food.ts new file mode 100644 index 0000000..a8ebeab --- /dev/null +++ b/bot/info/bot/food.ts @@ -0,0 +1,56 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo} from '../types.ts'; + +export class init implements BaseInfo { + readonly name = 'food'; + readonly i18n = { + lang: 'zh-CN', + name: '饥饿值', + description: '机器人的当前饥饿值', + }; + + private food: number; + private disposed = false; + private readonly listeners = new Set<(data: number) => void>(); + + constructor(private readonly instance: Bot) { + this.food = instance.food ?? 0; + instance.on('health', this.handle_food); + } + + private readonly handle_food = (): void => { + this.update_food(this.instance.food ?? 0); + }; + + get_info(): number { + return this.food; + } + + on_update(callback: (data: number) => void): () => void { + if (this.disposed) { + throw new Error('FoodInfo 已销毁'); + } + + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + private update_food(food: number): void { + if (this.disposed || Object.is(this.food, food)) return; + + this.food = food; + for (const callback of Array.from(this.listeners)) { + callback(food); + } + } + + readonly dispose = (): void => { + if (this.disposed) return; + + this.disposed = true; + this.instance.off('health', this.handle_food); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/health.ts b/bot/info/bot/health.ts new file mode 100644 index 0000000..f434ec7 --- /dev/null +++ b/bot/info/bot/health.ts @@ -0,0 +1,56 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo} from '../types.ts'; + +export class init implements BaseInfo { + readonly name = 'health'; + readonly i18n = { + lang: 'zh-CN', + name: '生命值', + description: '机器人的当前生命值', + }; + + private health: number; + private disposed = false; + private readonly listeners = new Set<(data: number) => void>(); + + constructor(private readonly instance: Bot) { + this.health = instance.health ?? 0; + instance.on('health', this.handle_health); + } + + private readonly handle_health = (): void => { + this.update_health(this.instance.health ?? 0); + }; + + get_info(): number { + return this.health; + } + + on_update(callback: (data: number) => void): () => void { + if (this.disposed) { + throw new Error('HealthInfo 已销毁'); + } + + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + private update_health(health: number): void { + if (this.disposed || Object.is(this.health, health)) return; + + this.health = health; + for (const callback of Array.from(this.listeners)) { + callback(health); + } + } + + readonly dispose = (): void => { + if (this.disposed) return; + + this.disposed = true; + this.instance.off('health', this.handle_health); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/inventory.ts b/bot/info/bot/inventory.ts new file mode 100644 index 0000000..262c579 --- /dev/null +++ b/bot/info/bot/inventory.ts @@ -0,0 +1,317 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from '../types.ts'; +import type {log} from '../../../types/utils/logger.ts'; +import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts'; +import {parse_inventory_item, type InventoryItem} from '../../../utils/inventory_item.ts'; +export type {InventoryItem} from '../../../utils/inventory_item.ts'; + +type WireValue = null | boolean | number | string | WireValue[] | {[key: string]: WireValue}; +type Item = NonNullable; +type Window = Bot['inventory']; +type WindowKind = 'inventory' | 'chest' | 'large_chest' | 'crafting' | 'enchantment' + | 'stonecutter' | 'furnace' | 'merchant' | 'lectern' | 'generic'; + +export interface InventoryInfo { + window: { + id: number; + kind: WindowKind; + type: string | number; + title: log[]; + inventoryStart: number | null; + inventoryEnd: number | null; + hotbarStart: number | null; + hasPlayerInventory: boolean; + craftingResultSlot: number | null; + slots: (InventoryItem | null)[]; + properties: Record; + } | null; + cursorItem: InventoryItem | null; + handSlot: number | null; + special: Record; +} + +function to_wire(value: unknown, ancestors = new Set()): WireValue { + if (value == null) return null; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (Buffer.isBuffer(value)) return Array.from(value); + if (typeof value === 'object') { + if (ancestors.has(value)) return null; + ancestors.add(value); + try { + if (Array.isArray(value)) return Array.from(value, entry => to_wire(entry, ancestors)); + return Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(value)) + .filter(([, descriptor]) => descriptor.enumerable) + .map(([key, descriptor]) => [key, 'value' in descriptor ? to_wire(descriptor.value, ancestors) : null])); + } finally { + ancestors.delete(value); + } + } + return null; +} + +function window_kind(window: Window): WindowKind { + const type = String(window.type).replace(/^minecraft:/, ''); + if (window.id === 0 || type === 'inventory') return 'inventory'; + if (['furnace', 'blast_furnace', 'smoker'].includes(type)) return 'furnace'; + if (['crafting', 'crafting_table', 'crafting3x3'].includes(type)) return 'crafting'; + if (['enchantment', 'enchanting_table'].includes(type)) return 'enchantment'; + if (type === 'stonecutter' || type === 'lectern') return type; + if (type === 'merchant' || type === 'villager') return 'merchant'; + if (['container', 'chest'].includes(type) || /^generic_9x[1-6]$/.test(type)) { + if (window.inventoryStart === 54) return 'large_chest'; + if (window.inventoryStart === 27) return 'chest'; + } + return 'generic'; +} + +const field = (name: string, description: string): InfoI18n => ({lang: 'zh-CN', name, description}); +const item_fields = { + slot: field('槽位', '当前窗口槽位编号,鼠标物品和交易物品为 null'), + type: field('物品 ID', '当前版本的物品数值 ID,不包含图标'), + name: field('物品标识', '物品英文标识'), displayName: field('物品名称', '优先自定义名称,其次物品名称组件和默认名称,返回 log 片段'), + i18n: {...field('物品翻译', '按方块、物品翻译键依次查询中文名称,缺失时使用英文标识'), fields: { + lang: field('语言', '翻译语言标识'), name: field('中文名称', '物品默认中文名称,不覆盖自定义昵称'), + description: field('描述', '翻译描述,没有时为空字符串'), + }}, + count: field('数量', '物品堆叠数量'), metadata: field('元数据', '旧版本物品元数据'), + lore: field('物品描述', '每行是一组 log 片段,保留空行'), + enchants: {...field('附魔', '已解析的附魔列表'), fields: { + id: field('附魔 ID', '版本内数值标识,未知时省略'), name: field('附魔标识', '英文附魔标识,未知时省略'), + i18n: {...field('附魔翻译', '查询中文附魔名称,缺失时回退到附魔标识或 ID'), fields: { + lang: field('语言', '翻译语言标识'), name: field('中文名称', '附魔中文名称'), description: field('描述', '翻译描述,没有时为空字符串'), + }}, + level: field('附魔等级', '实际附魔等级'), hidden: field('隐藏附魔提示', '服务器是否要求隐藏此附魔提示'), + }}, + durability: {...field('耐久', '耐久信息,没有耐久时为 null'), fields: { + used: field('已用耐久', '已消耗耐久'), max: field('最大耐久', '最大耐久'), remaining: field('剩余耐久', '剩余可用耐久'), + }}, + unbreakable: field('不可破坏', '物品是否不会损耗耐久'), glint: field('附魔光效', '强制光效开关,未指定为 null'), + book: {...field('书籍', '已解析的现代或旧版书籍,没有时为 null'), fields: { + title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页是一组 log 片段'), + }}, + details: {...field('特殊属性', '已解析特殊数据及解除 NBT 包装的未知字段'), fields: { + potion: {...field('药水', '药水类型、颜色和效果'), fields: { + type: field('药水类型', '版本内药水数值 ID 或旧版标识'), color: field('药水颜色', '自定义 RGB 整数颜色,未指定为 null'), + effects: {...field('药水效果', '自定义状态效果列表'), fields: { + id: field('效果 ID', '版本内数值 ID 或英文标识'), name: field('效果名称', '已识别效果名称'), + amplifier: field('效果增幅', '从 0 开始'), duration: field('持续时间', '游戏刻数'), + ambient: field('环境效果', '是否为环境效果'), showParticles: field('显示粒子', '是否显示效果粒子'), + }}, + }}, + attributes: {...field('属性修饰符', '属性及提示可见性'), fields: { + hidden: field('隐藏属性提示', '是否隐藏属性修饰符提示'), modifiers: {...field('修饰符列表', '已解析属性记录'), fields: { + type: field('属性类型', '版本内数值 ID 或英文标识'), name: field('修饰符名称', '修饰符名称或标识'), + amount: field('数值', '属性修饰数值'), operation: field('运算方式', '加法或乘法运算标识'), slot: field('生效槽位', '属性生效的装备槽'), + hidden: field('隐藏此项', '是否单独隐藏此属性'), displayName: field('显示文本', '覆盖显示文本的 log 片段'), + }}, + }}, + container: field('容器物品', '嵌套物品,使用相同物品结构'), bundle: field('收纳袋物品', '嵌套物品,使用相同物品结构'), + tooltip: {...field('提示可见性', '隐藏全部或附加提示及旧版标志'), fields: { + hideAll: field('隐藏全部提示', '隐藏全部物品提示'), hideAdditional: field('隐藏附加提示', '隐藏附加提示'), + hiddenComponents: field('隐藏组件', '被隐藏的组件名称,无法识别时保留 ID'), legacyFlags: field('旧版隐藏标志', '原始 HideFlags 数值'), + }}, + extra: field('其他标签', '未识别标签解除 NBT 包装后保留'), + }}, + truncated: field('数据已裁剪', '物品数据超出解析预算或存在循环引用、访问器等无法表示的数据'), +}; + +export class init implements BaseInfo { + readonly name = 'inventory'; + readonly i18n: InfoI18n = { + ...field('背包与窗口', '只读查询当前窗口及物品,支持背包和各类容器,不提供操作'), + fields: { + window: {...field('当前窗口', '未打开容器时返回玩家背包,尚未初始化时为 null'), fields: { + id: field('窗口 ID', '服务器窗口编号'), kind: field('窗口类别', '背包、箱子、合成、附魔、切石、熔炉、交易、讲台或通用窗口'), + type: field('窗口类型', 'Mineflayer 原始窗口类型'), title: field('窗口标题', '标题的 log 片段'), + inventoryStart: field('背包起点', '当前窗口玩家背包起始槽,讲台为 null'), + inventoryEnd: field('背包终点', '玩家背包结束槽,不包含该槽,讲台为 null'), + hotbarStart: field('快捷栏起点', '当前窗口快捷栏起始槽,讲台为 null'), + hasPlayerInventory: field('包含玩家背包', '讲台不包含玩家背包'), + craftingResultSlot: field('结果槽', '窗口结果槽,没有时为 null'), + slots: {...field('物品槽', '按槽号排列,空槽为 null,fields 描述物品'), fields: item_fields}, + properties: field('窗口属性', '服务器下发的属性 ID 到数值的映射,未收到属性不填充'), + }}, + cursorItem: {...field('鼠标物品', '鼠标携带物品,没有时为 null'), fields: item_fields}, + handSlot: field('主手槽', '当前窗口坐标系中的主手槽,讲台或未初始化时为 null'), + special: {...field('特殊窗口状态', '仅包含当前窗口对应状态,未知值为 null'), fields: { + furnace: {...field('熔炉', '熔炉、高炉和烟熏炉状态'), fields: { + burnRemaining: field('剩余燃烧', '剩余燃烧游戏刻'), burnTotal: field('总燃烧时间', '燃料总游戏刻'), + cookProgress: field('烧炼进度', '已烧炼游戏刻'), cookTotal: field('总烧炼时间', '所需游戏刻'), + fuelRatio: field('燃料比例', '剩余燃料比例,范围 0 至 1'), progressRatio: field('烧炼比例', '烧炼进度比例,范围 0 至 1'), + }}, + enchantment: {...field('附魔台', '附魔选项与种子'), fields: { + seed: field('附魔种子', '服务器下发的附魔种子'), + options: {...field('附魔选项', '三个候选项,fields 描述每项'), fields: { + index: field('选项索引', '从 0 开始'), requiredLevel: field('所需等级', '选择该项需要达到的等级'), + levelCost: field('等级消耗', '消耗 1、2 或 3 级'), lapisCost: field('青金石消耗', '消耗 1、2 或 3 个青金石'), + enchantmentId: field('提示附魔 ID', '提示的附魔 ID'), enchantmentLevel: field('提示附魔等级', '提示的附魔等级'), + }}, + }}, + merchant: {...field('村民交易', '由 Mineflayer 专用交易 API 初始化的报价'), fields: { + ready: field('报价就绪', '尚未初始化交易列表时为 false'), + offers: {...field('报价列表', 'fields 描述每项报价'), fields: { + index: field('报价索引', '从 0 开始'), + input1: {...field('第一材料', '交易的第一种材料'), fields: item_fields}, + input2: {...field('第二材料', '没有时为 null'), fields: item_fields}, + output: {...field('交易产物', '交易结果物品'), fields: item_fields}, + realPrice: field('实际价格', '第一材料实际数量'), disabled: field('不可交易', '服务器报告的交易禁用状态'), + uses: field('已交易次数', '当前报价已使用次数'), maxUses: field('最多交易次数', '报价最大使用次数'), + }}, + }}, + lectern: {...field('讲台', '讲台上的书及页码'), fields: { + page: field('当前页码', '服务器下发的页码,从 0 开始,未知为 null'), + book: {...field('书籍', '支持现代组件与旧版 NBT,未收到书籍时为 null'), fields: { + title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页为一组 log 片段'), + }}, + }}, + }}, + }, + }; + + private info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}}; + private disposed = false; + private timer: ReturnType | undefined; + private attached_inventory: Window | undefined; + private attached_window: Window | null = null; + private readonly properties = new Map>(); + private readonly listeners = new Set<(data: InventoryInfo) => void>(); + private readonly events = ['heldItemChanged', 'spawn', 'physicsTick'] as const; + private readonly packets = ['trade_list', 'MC|TrList', 'minecraft:trader_list', 'window_items', 'set_slot', 'set_player_inventory'] as const; + + constructor(private readonly instance: Bot) { + this.attach(); + this.update(); + instance.on('windowOpen', this.handle_open); + instance.on('windowClose', this.handle_close); + for (const event of this.events) instance.on(event, this.refresh); + for (const packet of this.packets) instance._client.on(packet, this.refresh); + instance._client.on('craft_progress_bar', this.handle_property); + } + + private attach(): void { + const inventory = this.instance.inventory; + if (inventory !== this.attached_inventory) { + this.attached_inventory?.off('updateSlot', this.refresh); + this.attached_inventory = inventory; + inventory?.on('updateSlot', this.refresh); + } + const window = this.instance.currentWindow as Window | null; + if (window !== this.attached_window) { + this.attached_window?.off('updateSlot', this.refresh); + this.attached_window = window; + if (window) this.properties.delete(window.id); + window?.on('updateSlot', this.refresh); + } + } + + private readonly handle_open = (): void => { + this.refresh(); + }; + + private readonly handle_close = (window: Bot['inventory']): void => { + if (window) this.properties.delete(window.id); + this.refresh(); + }; + + private readonly handle_property = (packet: {windowId: number; property: number; value: number}): void => { + const window = this.instance.currentWindow; + if (this.disposed || !window || window.id !== packet.windowId) return; + this.attach(); + let values = this.properties.get(window.id); + if (!values) this.properties.set(window.id, values = new Map()); + values.set(packet.property, packet.value); + this.refresh(); + }; + + private readonly refresh = (): void => { + if (this.disposed) return; + this.attach(); + if (this.timer) return; + this.timer = setTimeout(() => { + this.timer = undefined; + this.update(); + }, 50); + this.timer.unref(); + }; + + private update(): void { + if (this.disposed) return; + const bot = this.instance; + const read_item = (item: Item | null | undefined, slot: number | null = null) => parse_inventory_item(item, slot, bot.registry); + const window = bot.currentWindow ?? bot.inventory; + const info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}}; + if (window) { + const kind = window_kind(window); + const hasPlayerInventory = kind !== 'lectern'; + const values = this.properties.get(window.id); + const property = (id: number) => values?.get(id) ?? null; + const ratio = (value: number | null, total: number | null) => value === null || total === null || total <= 0 + ? null : Math.max(0, Math.min(1, value / total)); + info.window = { + id: window.id, kind, type: window.type, + title: MinecraftJsonParser.toLog(window.title), + inventoryStart: hasPlayerInventory ? window.inventoryStart : null, + inventoryEnd: hasPlayerInventory ? window.inventoryEnd : null, + hotbarStart: hasPlayerInventory ? window.hotbarStart : null, + hasPlayerInventory, + craftingResultSlot: window.craftingResultSlot >= 0 ? window.craftingResultSlot : null, + slots: Array.from(kind === 'lectern' ? window.slots.slice(0, 1) : window.slots, (item, slot) => read_item(item, slot)), + properties: Object.fromEntries(values ?? []), + }; + info.cursorItem = read_item(window.selectedItem); + info.handSlot = hasPlayerInventory && Number.isInteger(bot.quickBarSlot) ? window.hotbarStart + bot.quickBarSlot : null; + if (kind === 'furnace') { + info.special.furnace = { + burnRemaining: property(0), burnTotal: property(1), cookProgress: property(2), cookTotal: property(3), + fuelRatio: ratio(property(0), property(1)), progressRatio: ratio(property(2), property(3)), + }; + } else if (kind === 'enchantment') { + info.special.enchantment = { + seed: property(3), options: [0, 1, 2].map(index => ({ + index, requiredLevel: property(index), levelCost: index + 1, lapisCost: index + 1, + enchantmentId: property(4 + index), enchantmentLevel: property(7 + index), + })), + }; + } else if (kind === 'merchant') { + const trades = (window as Window & {trades?: Awaited>['trades']}).trades; + info.special.merchant = to_wire({ready: Array.isArray(trades), offers: (trades ?? []).map((trade, index) => ({ + index, input1: read_item(trade.inputItem1), input2: trade.hasItem2 ? read_item(trade.inputItem2) : null, + output: read_item(trade.outputItem), realPrice: trade.realPrice ?? null, + disabled: Boolean(trade.tradeDisabled), uses: trade.nbTradeUses ?? null, maxUses: trade.maximumNbTradeUses ?? null, + }))}); + } else if (kind === 'lectern') { + info.special.lectern = {page: property(0), book: to_wire(read_item(window.slots[0])?.book)}; + } + } + if (JSON.stringify(this.info) === JSON.stringify(info)) return; + this.info = info; + for (const callback of Array.from(this.listeners)) callback(info); + } + + get_info(): InventoryInfo { + return this.info; + } + + on_update(callback: (data: InventoryInfo) => void): () => void { + if (this.disposed) throw new Error('InventoryInfo 已销毁'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + clearTimeout(this.timer); + this.attached_inventory?.off('updateSlot', this.refresh); + this.attached_window?.off('updateSlot', this.refresh); + this.instance.off('windowOpen', this.handle_open); + this.instance.off('windowClose', this.handle_close); + for (const event of this.events) this.instance.off(event, this.refresh); + for (const packet of this.packets) this.instance._client.off(packet, this.refresh); + this.instance._client.off('craft_progress_bar', this.handle_property); + this.properties.clear(); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/player_list.ts b/bot/info/bot/player_list.ts new file mode 100644 index 0000000..e769c64 --- /dev/null +++ b/bot/info/bot/player_list.ts @@ -0,0 +1,76 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from '../types.ts'; +import type {log} from '../../../types/utils/logger.ts'; +import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts'; + +export interface PlayerListEntry { + displayName: log[]; + ping: number; +} + +export class init implements BaseInfo { + readonly name = 'player_list'; + readonly i18n = { + lang: 'zh-CN', name: '玩家列表', description: '服务器玩家列表,包含机器人自身,fields 描述每个数组元素', + fields: { + displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失、为空或解析失败时使用用户名,返回 log 片段'}, + ping: {lang: 'zh-CN', name: '玩家延迟', description: '玩家延迟,单位为毫秒,缺失、无效或读取失败时返回 0'}, + }, + } satisfies InfoI18n; + + private players: PlayerListEntry[] = []; + private disposed = false; + private readonly listeners = new Set<(data: PlayerListEntry[]) => void>(); + private readonly events = ['playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'physicsTick'] as const; + + constructor(private readonly instance: Bot) { + this.handle_players(); + for (const event of this.events) instance.on(event, this.handle_players); + } + + private readonly handle_players = (): void => { + if (this.disposed) return; + + const players = Object.entries(this.instance.players ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([username, player]): PlayerListEntry => { + let displayName: log[] = []; + try { + displayName = MinecraftJsonParser.toLog(player.displayName); + } catch { + // A malformed nickname must not prevent the player list from updating. + } + if (!displayName.length) displayName = MinecraftJsonParser.toLog(player.username || username); + + let ping = 0; + try { + const value = player.ping; + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) ping = value; + } catch { + // Keep the default when latency cannot be read. + } + return {displayName, ping}; + }); + + if (JSON.stringify(this.players) === JSON.stringify(players)) return; + this.players = players; + for (const callback of Array.from(this.listeners)) callback(players); + }; + + get_info(): PlayerListEntry[] { + return this.players; + } + + on_update(callback: (data: PlayerListEntry[]) => void): () => void { + if (this.disposed) throw new Error('PlayerListInfo 已销毁'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + for (const event of this.events) this.instance.off(event, this.handle_players); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/plugin_states.ts b/bot/info/bot/plugin_states.ts new file mode 100644 index 0000000..724ae22 --- /dev/null +++ b/bot/info/bot/plugin_states.ts @@ -0,0 +1,62 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoContext, InfoI18n} from '../types.ts'; +import type {PluginSnapshot} from '../../plugins/init.ts'; + +export interface PluginStatesInfo { + available: boolean; + plugins: Omit[]; +} + +export class init implements BaseInfo { + readonly name = 'plugin_states'; + readonly i18n: InfoI18n = { + lang: 'zh-CN', name: '插件状态', description: '插件启用情况、生命周期和内部状态,不包含配置', + fields: { + available: {lang: 'zh-CN', name: '状态源可用', description: '是否提供插件管理器状态源'}, + plugins: {lang: 'zh-CN', name: '插件列表', description: '包含已禁用和失败记录', fields: { + name: {lang: 'zh-CN', name: '插件名称', description: '稳定的插件标识'}, + path: {lang: 'zh-CN', name: '插件路径', description: '插件分类路径'}, + enabled: {lang: 'zh-CN', name: '已启用', description: '仅初始化成功且尚未开始清理时为 true'}, + lifecycle: {lang: 'zh-CN', name: '生命周期', description: 'initializing / enabled / disabling / disabled / error'}, + states: {lang: 'zh-CN', name: '内部状态', description: '插件发布的内部状态;禁用记录保留清理完成时的最终快照'}, + }}, + }, + }; + private value: PluginStatesInfo; + private readonly listeners = new Set<(value: PluginStatesInfo) => void>(); + private unsubscribe?: () => void; + private disposed = false; + + constructor(_bot: Bot, options?: InfoContext) { + const source = options?.plugin_source; + const read = (): PluginStatesInfo => ({available: !!source, + plugins: (source?.get_all_plugins() ?? []).map(({name, path, enabled, lifecycle, states}) => + structuredClone({name, path, enabled, lifecycle, states})), + }); + this.value = read(); + this.unsubscribe = source?.on_update(() => { + if (this.disposed) return; + this.value = read(); + for (const listener of Array.from(this.listeners)) { + if (!this.listeners.has(listener)) continue; + try { listener(this.get_info()); } catch {} + } + }); + } + + get_info(): PluginStatesInfo { return structuredClone(this.value); } + + on_update(callback: (value: PluginStatesInfo) => void): () => void { + if (this.disposed) throw new Error('Plugin states info has been disposed'); + const listener = (value: PluginStatesInfo) => callback(value); + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.unsubscribe?.(); + this.listeners.clear(); + } +} diff --git a/bot/info/bot/position.ts b/bot/info/bot/position.ts new file mode 100644 index 0000000..9eb1b4d --- /dev/null +++ b/bot/info/bot/position.ts @@ -0,0 +1,139 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from '../types.ts'; + +export interface PositionInfo { + location?: {x: number; y: number; z: number}; + yaw?: number; + face_direction?: 'south' | 'west' | 'north' | 'east'; + pitch?: number; + velocity?: {x: number; y: number; z: number}; +} + +export class init implements BaseInfo { + readonly name = 'position'; + readonly i18n = { + lang: 'zh-CN', + name: '位置', + description: '机器人的当前位置、朝向和速度', + fields: { + location: { + lang: 'zh-CN', + name: '坐标', + description: '机器人在世界中的坐标', + fields: { + x: {lang: 'zh-CN', name: 'X 坐标', description: '东西方向的坐标'}, + y: {lang: 'zh-CN', name: 'Y 坐标', description: '垂直方向的坐标'}, + z: {lang: 'zh-CN', name: 'Z 坐标', description: '南北方向的坐标'}, + }, + }, + yaw: { + lang: 'zh-CN', + name: '偏航角', + description: '机器人水平方向的朝向角度,单位为弧度', + }, + face_direction: { + lang: 'zh-CN', + name: '朝向', + description: '机器人面向的水平方位', + values: { + south: {lang: 'zh-CN', name: '南', description: '朝向南方'}, + west: {lang: 'zh-CN', name: '西', description: '朝向西方'}, + north: {lang: 'zh-CN', name: '北', description: '朝向北方'}, + east: {lang: 'zh-CN', name: '东', description: '朝向东方'}, + }, + }, + pitch: { + lang: 'zh-CN', + name: '俯仰角', + description: '机器人垂直方向的朝向角度,单位为弧度', + }, + velocity: { + lang: 'zh-CN', + name: '速度', + description: '机器人在各坐标轴上的速度分量,单位为格每物理刻', + fields: { + x: {lang: 'zh-CN', name: 'X 轴速度', description: '东西方向的速度分量'}, + y: {lang: 'zh-CN', name: 'Y 轴速度', description: '垂直方向的速度分量'}, + z: {lang: 'zh-CN', name: 'Z 轴速度', description: '南北方向的速度分量'}, + }, + }, + }, + } satisfies InfoI18n; + + private position: PositionInfo = {}; + private disposed = false; + private readonly listeners = new Set<(data: PositionInfo) => void>(); + + constructor(private readonly instance: Bot) { + this.handle_position(); + instance.on('move', this.handle_position); + instance.on('forcedMove', this.handle_position); + instance.on('physicsTick', this.handle_position); + instance.on('spawn', this.handle_position); + } + + private readonly handle_position = (): void => { + if (this.disposed) return; + + const entity = this.instance.entity; + const position: PositionInfo = {}; + const pos = entity?.position; + if (pos) { + position.location = { + x: parseFloat(pos.x.toFixed(2)), + y: parseFloat(pos.y.toFixed(2)), + z: parseFloat(pos.z.toFixed(2)), + }; + } + if (typeof entity?.yaw === 'number') { + position.yaw = parseFloat(entity.yaw.toFixed(3)); + // Mineflayer yaw: 0 = south, PI / 2 = west. + const directions = ['south', 'west', 'north', 'east'] as const; + const index = ((Math.round(entity.yaw / (Math.PI / 2)) % 4) + 4) % 4; + position.face_direction = directions[index]; + } + if (typeof entity?.pitch === 'number') { + position.pitch = parseFloat(entity.pitch.toFixed(3)); + } + const vel = entity?.velocity; + if (vel) { + position.velocity = { + x: parseFloat(vel.x.toFixed(3)), + y: parseFloat(vel.y.toFixed(3)), + z: parseFloat(vel.z.toFixed(3)), + }; + } + + if (JSON.stringify(this.position) === JSON.stringify(position)) return; + this.position = position; + for (const callback of Array.from(this.listeners)) { + callback(position); + } + }; + + get_info(): PositionInfo { + return this.position; + } + + on_update(callback: (data: PositionInfo) => void): () => void { + if (this.disposed) { + throw new Error('PositionInfo 已销毁'); + } + + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + + this.disposed = true; + this.instance.off('move', this.handle_position); + this.instance.off('forcedMove', this.handle_position); + this.instance.off('physicsTick', this.handle_position); + this.instance.off('spawn', this.handle_position); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/scoreboard.ts b/bot/info/bot/scoreboard.ts new file mode 100644 index 0000000..6d1163f --- /dev/null +++ b/bot/info/bot/scoreboard.ts @@ -0,0 +1,106 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from '../types.ts'; +import type {log} from '../../../types/utils/logger.ts'; +import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts'; + +export interface ScoreboardInfo { + scoreboard_title: log[]; + scoreboard: log[][]; + header: log[]; + footer: log[]; +} + +export class init implements BaseInfo { + readonly name = 'scoreboard'; + readonly i18n = { + lang: 'zh-CN', name: '计分板与 TAB', description: '当前侧边栏计分板及 TAB 页眉、页脚,以 log 片段返回', + fields: { + scoreboard_title: {lang: 'zh-CN', name: '计分板标题', description: '当前侧边栏标题的 log 片段,无侧边栏时为空数组'}, + scoreboard: {lang: 'zh-CN', name: '计分板内容', description: '按分数降序排列,每行是一组 log 片段,保留空行,不混入隐藏计分板'}, + header: {lang: 'zh-CN', name: 'TAB 页眉', description: 'TAB 页眉的 log 片段,保留换行和文本样式'}, + footer: {lang: 'zh-CN', name: 'TAB 页脚', description: 'TAB 页脚的 log 片段,保留换行和文本样式'}, + }, + } satisfies InfoI18n; + + private info: ScoreboardInfo = {scoreboard_title: [], scoreboard: [], header: [], footer: []}; + private disposed = false; + private timer: ReturnType | undefined; + private last_update = -Infinity; + private readonly listeners = new Set<(data: ScoreboardInfo) => void>(); + private readonly events = ['scoreboardCreated', 'scoreboardDeleted', 'scoreboardTitleChanged', 'scoreboardPosition', 'scoreUpdated', 'scoreRemoved', 'teamCreated', 'teamRemoved', 'teamUpdated', 'teamMemberAdded', 'teamMemberRemoved', 'spawn', 'respawn', 'physicsTick'] as const; + + constructor(private readonly instance: Bot) { + this.update(); + for (const event of this.events) instance.on(event, this.schedule_update); + instance._client.on('playerlist_header', this.schedule_update); + } + + private readonly schedule_update = (): void => { + if (this.disposed || this.timer) return; + // Some Mineflayer events fire before their state mutation finishes. + this.timer = setTimeout(() => { + this.timer = undefined; + this.update(); + }, Math.max(0, 2000 - (performance.now() - this.last_update))); + this.timer.unref(); + }; + + private clean_text(component: Parameters[0]): log[] { + let line_start = true; + return MinecraftJsonParser.toLog(component).flatMap(segment => { + let message = ''; + for (const character of String(segment.message)) { + if (character === '\n' || character === '\r') { + message += character; + line_start = true; + } else if (!line_start || !/[\s\u200B-\u200D\u2060]/u.test(character)) { + message += character; + line_start = false; + } + } + return message ? [{...segment, message}] : []; + }); + } + + private update(): void { + if (this.disposed) return; + this.last_update = performance.now(); + const bot = this.instance; + const board = bot.scoreboard?.sidebar; + const info: ScoreboardInfo = { + scoreboard_title: this.clean_text(board?.title), + scoreboard: board ? board.items.map(item => { + try { + return this.clean_text(item.displayName); + } catch { + return this.clean_text(item.name); + } + }) : [], + header: this.clean_text(bot.tablist?.header), + footer: this.clean_text(bot.tablist?.footer), + }; + if (JSON.stringify(this.info) === JSON.stringify(info)) return; + this.info = info; + for (const callback of Array.from(this.listeners)) callback(info); + } + + get_info(): ScoreboardInfo { + return this.info; + } + + on_update(callback: (data: ScoreboardInfo) => void): () => void { + if (this.disposed) throw new Error('ScoreboardInfo 已销毁'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + clearTimeout(this.timer); + this.timer = undefined; + for (const event of this.events) this.instance.off(event, this.schedule_update); + this.instance._client.off('playerlist_header', this.schedule_update); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/self.ts b/bot/info/bot/self.ts new file mode 100644 index 0000000..9236222 --- /dev/null +++ b/bot/info/bot/self.ts @@ -0,0 +1,156 @@ +import type {Bot} from '@wp2508/mineflayer'; +import minecraftData from '@wp2508/minecraft-data'; +import type {BaseInfo, InfoI18n} from '../types.ts'; +import type {log} from '../../../types/utils/logger.ts'; +import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts'; + +export interface SelfInfo { + displayName?: log[]; + level?: number; + experience?: {points: number; progress: number}; + effects?: {id: number; name?: string; display_name: string; amplifier: number; duration: number}[]; + armor?: number; + game_mode?: Bot['game']['gameMode']; + difficulty?: Bot['game']['difficulty']; +} + +interface Attribute { + value: number; + modifiers: {operation: number; amount: number}[]; +} + +export class init implements BaseInfo { + readonly name = 'self'; + readonly i18n = { + lang: 'zh-CN', name: '自身信息', description: '机器人的等级、经验、药水效果、护甲、游戏模式和难度', + fields: { + displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失或为空时使用用户名,返回 log 片段'}, + level: {lang: 'zh-CN', name: '等级', description: '机器人当前的经验等级'}, + experience: { + lang: 'zh-CN', name: '经验值', description: '服务器同步的经验值和升级进度', + fields: { + points: {lang: 'zh-CN', name: '总经验值', description: '服务器同步的累计经验点数'}, + progress: {lang: 'zh-CN', name: '等级进度', description: '当前等级的经验条进度,范围为 0 至 1'}, + }, + }, + effects: { + lang: 'zh-CN', name: '药水效果', description: '机器人当前具有的状态效果,fields 描述每个数组元素', + fields: { + id: {lang: 'zh-CN', name: '效果 ID', description: '当前版本的状态效果数值标识'}, + name: {lang: 'zh-CN', name: '效果名称', description: '状态效果的英文标识,未知效果省略'}, + display_name: {lang: 'zh-CN', name: '效果中文昵称', description: '按机器人版本查询的中文名称,没有中文名称时返回未知'}, + amplifier: {lang: 'zh-CN', name: '效果增幅', description: '效果增幅等级,从 0 开始,0 表示一级'}, + duration: {lang: 'zh-CN', name: '持续时间', description: '服务器最近下发的持续游戏刻数,-1 表示无限,不在本地倒计时'}, + }, + }, + armor: {lang: 'zh-CN', name: '护甲值', description: '应用属性修饰符后的护甲点数,2 点对应一个护甲图标'}, + game_mode: { + lang: 'zh-CN', name: '游戏模式', description: '机器人当前的游戏模式', + values: { + survival: {lang: 'zh-CN', name: '生存', description: '生存模式'}, + creative: {lang: 'zh-CN', name: '创造', description: '创造模式'}, + adventure: {lang: 'zh-CN', name: '冒险', description: '冒险模式'}, + spectator: {lang: 'zh-CN', name: '旁观', description: '旁观模式'}, + }, + }, + difficulty: { + lang: 'zh-CN', name: '游戏难度', description: '服务器当前的游戏难度', + values: { + peaceful: {lang: 'zh-CN', name: '和平', description: '和平难度'}, + easy: {lang: 'zh-CN', name: '简单', description: '简单难度'}, + normal: {lang: 'zh-CN', name: '普通', description: '普通难度'}, + hard: {lang: 'zh-CN', name: '困难', description: '困难难度'}, + }, + }, + }, + } satisfies InfoI18n; + + private self: SelfInfo = {}; + private disposed = false; + private readonly listeners = new Set<(data: SelfInfo) => void>(); + private readonly events = ['experience', 'game', 'spawn', 'respawn', 'playerJoined', 'playerUpdated', 'playerLeft', 'physicsTick'] as const; + private readonly entity_events = ['entityEffect', 'entityEffectEnd', 'entityAttributes'] as const; + + constructor(private readonly instance: Bot) { + this.handle_self(); + for (const event of this.events) instance.on(event, this.handle_self); + for (const event of this.entity_events) instance.on(event, this.handle_entity); + } + + private readonly handle_entity = (entity: Bot['entity']): void => { + if (entity === this.instance.entity) this.handle_self(); + }; + + private readonly handle_self = (): void => { + if (this.disposed) return; + + const bot = this.instance; + const self: SelfInfo = {}; + const player = bot.players?.[bot.username] ?? bot.player; + const username = player?.username || bot.username; + const displayName = MinecraftJsonParser.toLog(player?.displayName ?? username); + if (displayName.length) self.displayName = displayName; + else if (username) self.displayName = MinecraftJsonParser.toLog(username); + if (typeof bot.experience?.level === 'number') self.level = bot.experience.level; + if (typeof bot.experience?.points === 'number' && typeof bot.experience.progress === 'number') { + self.experience = {points: bot.experience.points, progress: bot.experience.progress}; + } + const entity = bot.entity; + if (entity) { + const data = bot.version ? minecraftData(bot.version) : null; + self.effects = Object.values(entity.effects ?? {}).map(effect => { + const name = bot.registry?.effects?.[effect.id]?.name; + const display_name = data?.effects[effect.id]?.displayName; + return { + id: effect.id, + ...(name ? {name} : {}), + display_name: display_name && /\p{Script=Han}/u.test(display_name) ? display_name : '未知', + amplifier: effect.amplifier, + duration: effect.duration, + }; + }).sort((a, b) => a.id - b.id); + // Mineflayer populates attributes at runtime, but its Entity type omits them. + const attributes = (entity as typeof entity & {attributes?: Record}).attributes; + const armor = attributes?.['minecraft:armor'] ?? attributes?.armor + ?? attributes?.['minecraft:generic.armor'] ?? attributes?.['generic.armor']; + if (armor) { + let base = armor.value; + for (const modifier of armor.modifiers ?? []) { + if (modifier.operation === 0) base += modifier.amount; + } + let value = base; + for (const modifier of armor.modifiers ?? []) { + if (modifier.operation === 1) value += base * modifier.amount; + } + for (const modifier of armor.modifiers ?? []) { + if (modifier.operation === 2) value *= 1 + modifier.amount; + } + self.armor = Math.min(30, Math.max(0, value)); + } + } + if (bot.game?.gameMode != null) self.game_mode = bot.game.gameMode; + if (bot.game?.difficulty != null) self.difficulty = bot.game.difficulty; + + if (JSON.stringify(this.self) === JSON.stringify(self)) return; + this.self = self; + for (const callback of Array.from(this.listeners)) callback(self); + }; + + get_info(): SelfInfo { + return this.self; + } + + on_update(callback: (data: SelfInfo) => void): () => void { + if (this.disposed) throw new Error('SelfInfo 已销毁'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + for (const event of this.events) this.instance.off(event, this.handle_self); + for (const event of this.entity_events) this.instance.off(event, this.handle_entity); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/server.ts b/bot/info/bot/server.ts new file mode 100644 index 0000000..a723578 --- /dev/null +++ b/bot/info/bot/server.ts @@ -0,0 +1,101 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from '../types.ts'; + +export interface ServerInfo { + host?: string; + brand?: string; + version?: string; + protocol?: number; + compression_threshold?: number; + online_seconds: number; + online_time: string; + ping: number; +} + +export class init implements BaseInfo { + readonly name = 'server'; + readonly i18n = { + lang: 'zh-CN', name: '服务器信息', description: '当前连接的服务器、协议、在线时长和延迟', + fields: { + host: {lang: 'zh-CN', name: '主机', description: '连接配置中的服务器主机和端口'}, + brand: {lang: 'zh-CN', name: 'Brand', description: '服务器报告的品牌字符串'}, + version: {lang: 'zh-CN', name: '版本', description: '当前连接使用的 Minecraft 版本'}, + protocol: {lang: 'zh-CN', name: '协议', description: '当前连接使用的协议版本号'}, + compression_threshold: {lang: 'zh-CN', name: '压缩阈值', description: '触发数据包压缩的字节数,负数表示未启用压缩'}, + online_seconds: {lang: 'zh-CN', name: '本次在线秒数', description: '从本次首次登录成功起累计的秒数'}, + online_time: {lang: 'zh-CN', name: '本次在线', description: '本次在线时长,格式为小时和分钟,例如 3h 05m'}, + ping: {lang: 'zh-CN', name: '服务器延迟', description: '服务器下发的当前机器人玩家延迟,单位毫秒,缺失或读取失败时为 0'}, + }, + } satisfies InfoI18n; + + private info: ServerInfo = {online_seconds: 0, online_time: '0h 00m', ping: 0}; + private login_at: number | undefined; + private disposed = false; + private readonly listeners = new Set<(data: ServerInfo) => void>(); + private readonly events = ['game', 'playerJoined', 'playerUpdated'] as const; + private readonly timer: ReturnType; + + constructor(private readonly instance: Bot) { + this.handle_server(); + instance.on('login', this.handle_login); + for (const event of this.events) instance.on(event, this.handle_server); + this.timer = setInterval(this.handle_server, 1000); + this.timer.unref(); + } + + private readonly handle_login = (): void => { + // Backend switches can emit login again without starting a new connection. + this.login_at ??= performance.now(); + this.handle_server(); + }; + + private readonly handle_server = (): void => { + if (this.disposed) return; + const bot = this.instance; + const client = bot._client; + const online_seconds = this.login_at === undefined ? 0 + : Math.max(0, Math.floor((performance.now() - this.login_at) / 1000)); + const info: ServerInfo = { + online_seconds, + online_time: `${Math.floor(online_seconds / 3600)}h ${String(Math.floor(online_seconds / 60) % 60).padStart(2, '0')}m`, + ping: 0, + }; + const host = (bot as Bot & {server_address?: string}).server_address; + if (host) info.host = host; + if (typeof bot.game?.serverBrand === 'string') info.brand = bot.game.serverBrand; + if (bot.version) info.version = bot.version; + if (typeof client?.protocolVersion === 'number') info.protocol = client.protocolVersion; + // The protocol library exposes a number at runtime despite its string declaration. + const threshold = client?.compressionThreshold as unknown; + if (typeof threshold === 'number') info.compression_threshold = threshold; + try { + const ping = (bot.players?.[bot.username] ?? bot.player)?.ping; + if (typeof ping === 'number' && Number.isFinite(ping) && ping >= 0) info.ping = ping; + } catch { + // Keep zero when player latency is unavailable. + } + + if (JSON.stringify(this.info) === JSON.stringify(info)) return; + this.info = info; + for (const callback of Array.from(this.listeners)) callback(info); + }; + + get_info(): ServerInfo { + return this.info; + } + + on_update(callback: (data: ServerInfo) => void): () => void { + if (this.disposed) throw new Error('ServerInfo 已销毁'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + clearInterval(this.timer); + this.instance.off('login', this.handle_login); + for (const event of this.events) this.instance.off(event, this.handle_server); + this.listeners.clear(); + }; +} diff --git a/bot/info/bot/workflow_states.ts b/bot/info/bot/workflow_states.ts new file mode 100644 index 0000000..7b63893 --- /dev/null +++ b/bot/info/bot/workflow_states.ts @@ -0,0 +1,84 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoContext} from '../types.ts'; +import type {WorkflowRuntimeSnapshot} from '../../../types/bot/workflow/runtime.ts'; +import {summarize_run, type RunSummary} from '../../workflow/runtime/summary.ts'; + +interface WorkflowStates extends WorkflowRuntimeSnapshot {available: boolean; runs: RunSummary[]} +const push_interval_ms = 250; + +export class init implements BaseInfo { + readonly name = 'workflow_states'; + readonly i18n = {lang: 'zh-CN', name: '工作流状态', description: '工作流运行状态、当前节点与结束原因'}; + private readonly listeners = new Set<(value: WorkflowStates) => void>(); + private unsubscribe?: () => void; + private value: WorkflowStates; + private disposed = false; + private dirty = false; + private timer?: ReturnType; + private last_sent: string; + private readonly read: () => WorkflowStates; + + constructor(_bot: Bot, options?: InfoContext) { + const source = options?.workflow_source; + this.read = (): WorkflowStates => { + const runtime = source?.get_runtime_snapshot?.() ?? {source_config_revision: 0, workflows: [], commands: []}; + // Older command results remain queryable by ID; do not resend all 100 on each live update. + const recent = new Set(runtime.commands.filter(command => command.state !== 'accepted').slice(-20).map(command => command.command_id)); + return {available: !!source, ...runtime, + commands: runtime.commands.filter(command => command.state === 'accepted' || recent.has(command.command_id)), + runs: (source?.get_snapshots() ?? []).map(summarize_run)}; + }; + this.value = this.read(); + this.last_sent = JSON.stringify(this.value); + this.unsubscribe = source?.on_update(event => { + if (this.disposed || event.type !== 'run_updated' && event.type !== 'runtime_updated') return; + this.dirty = true; + this.schedule(); + }); + } + private schedule() { + if (this.disposed || this.timer !== undefined || !this.listeners.size) return; + // A fixed window bounds traffic without starving updates during continuous activity. + this.timer = setTimeout(() => { + this.timer = undefined; + if (this.disposed || !this.dirty || !this.listeners.size) return; + this.value = this.read(); + this.dirty = false; + const serialized = JSON.stringify(this.value); + if (serialized === this.last_sent) return; + this.last_sent = serialized; + const snapshot = this.value; + for (const listener of [...this.listeners]) { + if (this.disposed || !this.listeners.has(listener)) continue; + try {listener(structuredClone(snapshot));} catch { /* Observers own their failures. */ } + } + }, push_interval_ms); + this.timer.unref?.(); + } + get_info() { + if (!this.disposed && this.dirty) this.value = this.read(); + return structuredClone(this.value); + } + on_update(callback: (value: WorkflowStates) => void) { + if (this.disposed) throw new Error('Workflow info has been disposed'); + if (!this.listeners.size) this.last_sent = JSON.stringify(this.get_info()); + this.listeners.add(callback); + if (this.dirty) this.schedule(); + return () => { + this.listeners.delete(callback); + if (!this.listeners.size && this.timer !== undefined) { + clearTimeout(this.timer); + this.timer = undefined; + } + }; + } + dispose() { + if (this.disposed) return; + if (this.dirty) this.value = this.read(); + this.disposed = true; + if (this.timer !== undefined) clearTimeout(this.timer); + this.timer = undefined; + this.unsubscribe?.(); + this.listeners.clear(); + } +} diff --git a/bot/info/bot/world.ts b/bot/info/bot/world.ts new file mode 100644 index 0000000..6082779 --- /dev/null +++ b/bot/info/bot/world.ts @@ -0,0 +1,126 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from '../types.ts'; + +export interface WorldInfo { + time?: {time: number; time_of_day: number; day: number; is_day: boolean}; + biome?: {id: number; name: string}; + dimension?: string; + weather?: 'clear' | 'rain' | 'thunder'; + light?: {block: number; sky: number}; +} + +export class init implements BaseInfo { + readonly name = 'world'; + readonly i18n = { + lang: 'zh-CN', + name: '世界信息', + description: '机器人所在世界的时间、群系、维度、天气和光照', + fields: { + time: { + lang: 'zh-CN', name: '世界时间', description: '服务器同步的世界时间', + fields: { + time: {lang: 'zh-CN', name: '总时间', description: '世界累计昼夜时间,单位为游戏刻'}, + time_of_day: {lang: 'zh-CN', name: '日内时间', description: '一天内的时间,范围为 0 至 23999 游戏刻'}, + day: {lang: 'zh-CN', name: '天数', description: '世界时间对应的天数,从 0 开始'}, + is_day: {lang: 'zh-CN', name: '是否白天', description: '日内时间是否处于 0 至 12999 游戏刻'}, + }, + }, + biome: { + lang: 'zh-CN', name: '当前群系', description: '机器人当前位置的生物群系', + fields: { + id: {lang: 'zh-CN', name: '群系 ID', description: '当前版本的群系数值标识'}, + name: {lang: 'zh-CN', name: '群系名称', description: '群系的英文标识'}, + }, + }, + dimension: { + lang: 'zh-CN', name: '当前维度', description: '机器人所在的世界维度', + values: { + overworld: {lang: 'zh-CN', name: '主世界', description: '主世界维度'}, + the_nether: {lang: 'zh-CN', name: '地狱', description: '下界维度'}, + the_end: {lang: 'zh-CN', name: '末地', description: '末地维度'}, + }, + }, + weather: { + lang: 'zh-CN', name: '当前天气', description: '世界天气状态,局部降水表现由群系决定', + values: { + clear: {lang: 'zh-CN', name: '晴天', description: '当前没有降水'}, + rain: {lang: 'zh-CN', name: '降水', description: '当前正在降水,寒冷群系表现为降雪'}, + thunder: {lang: 'zh-CN', name: '雷暴', description: '当前正在发生雷暴'}, + }, + }, + light: { + lang: 'zh-CN', name: '当前位置光照', description: '当前位置的原始光照等级,不包含昼夜和天气衰减', + fields: { + block: {lang: 'zh-CN', name: '方块光照', description: '方块光源产生的光照等级,范围为 0 至 15'}, + sky: {lang: 'zh-CN', name: '天空光照', description: '天空光照等级,范围为 0 至 15'}, + }, + }, + }, + } satisfies InfoI18n; + + private world: WorldInfo = {}; + private disposed = false; + private readonly listeners = new Set<(data: WorldInfo) => void>(); + private readonly events = ['time', 'rain', 'game', 'spawn', 'respawn', 'move', 'forcedMove', 'physicsTick', 'chunkColumnLoad', 'chunkColumnUnload', 'blockUpdate'] as const; + + constructor(private readonly instance: Bot) { + this.handle_world(); + for (const event of this.events) instance.on(event, this.handle_world); + } + + private readonly handle_world = (): void => { + if (this.disposed) return; + + const bot = this.instance; + const world: WorldInfo = {}; + if (typeof bot.time?.time === 'number') { + world.time = { + time: bot.time.time, + time_of_day: bot.time.timeOfDay, + day: bot.time.day, + is_day: bot.time.isDay, + }; + } + if (typeof bot.game?.dimension === 'string') { + world.dimension = bot.game.dimension.replace(/^minecraft:/, ''); + } + if (typeof bot.isRaining === 'boolean') { + // Nether and End have no weather, even if the server retains rain state. + const no_weather = world.dimension === 'the_nether' || world.dimension === 'the_end'; + world.weather = no_weather || !bot.isRaining ? 'clear' + : bot.thunderState > 0 ? 'thunder' : 'rain'; + } + const pos = bot.entity?.position; + const block = pos && bot.blockAt(pos, false); + if (block) { + if (block.biome) { + world.biome = { + id: block.biome.id, + name: block.biome.name, + }; + } + world.light = {block: block.light, sky: block.skyLight}; + } + + if (JSON.stringify(this.world) === JSON.stringify(world)) return; + this.world = world; + for (const callback of Array.from(this.listeners)) callback(world); + }; + + get_info(): WorldInfo { + return this.world; + } + + on_update(callback: (data: WorldInfo) => void): () => void { + if (this.disposed) throw new Error('WorldInfo 已销毁'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + + readonly dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + for (const event of this.events) this.instance.off(event, this.handle_world); + this.listeners.clear(); + }; +} diff --git a/bot/info/init.ts b/bot/info/init.ts new file mode 100644 index 0000000..91dadb8 --- /dev/null +++ b/bot/info/init.ts @@ -0,0 +1,143 @@ +import {readdir} from 'node:fs/promises'; +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoConstructor, InfoContext, InfoI18n} from './types.ts'; + +export interface InfoSnapshot { + readonly name: string; + readonly i18n: InfoI18n; + readonly value: unknown; +} + +export interface InfoUpdate { + readonly name: string; + readonly value: unknown; +} + +export interface InfoManager { + get_info(name: string): InfoSnapshot | undefined; + get_all_info(): InfoSnapshot[]; + /** Subscribes to future changes; returns an idempotent unsubscribe function. */ + on_update(callback: (event: InfoUpdate) => void): () => void; + /** Safe to call repeatedly, including before the bot emits end. */ + dispose(): void; +} + +/** Scans bot/ and instance/ recursively and creates fresh modules for this bot. */ +export async function create_info_manager(bot: Bot, options?: InfoContext): Promise { + const infos = new Map>(); + const subscriptions = new Set<() => void>(); + const listeners = new Set<(event: InfoUpdate) => void>(); + let disposed = false; + + const assert_active = () => { + if (disposed) throw new Error('Info manager has been disposed'); + }; + const cleanup = (callback: () => void) => { + try { callback(); } + catch (error) { console.error('Info manager cleanup failed', error); } + }; + const snapshot = (info: BaseInfo): InfoSnapshot => ({ + name: info.name, + i18n: {...info.i18n}, + value: structuredClone(info.get_info()), + }); + const manager: InfoManager = { + get_info(name) { + const info = infos.get(name); + return info ? snapshot(info) : undefined; + }, + get_all_info() { + return Array.from(infos.values(), snapshot); + }, + on_update(callback) { + assert_active(); + // Each subscription owns its cancellation, even for the same callback. + const listener = (event: InfoUpdate) => callback(event); + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + dispose() { + if (disposed) return; + disposed = true; + bot.off('end', manager.dispose); + listeners.clear(); + for (const unsubscribe of subscriptions) cleanup(unsubscribe); + subscriptions.clear(); + for (const info of infos.values()) cleanup(() => info.dispose()); + infos.clear(); + }, + }; + + // Install before the first await so an end during loading also cancels creation. + bot.once('end', manager.dispose); + try { + const directories = [new URL('./bot/', import.meta.url), new URL('./instance/', import.meta.url)]; + const modules: URL[] = []; + for (const directory of directories) { + const entries = await readdir(directory, {withFileTypes: true}); + assert_active(); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(entry.name)) { + directories.push(new URL(`${entry.name}/`, directory)); + } else if (entry.isFile() && /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) { + modules.push(new URL(entry.name, directory)); + } + } + } + modules.sort((a, b) => a.href.localeCompare(b.href)); + for (const url of modules) { + let info: BaseInfo | undefined; + let unsubscribe: (() => void) | undefined; + try { + assert_active(); + url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`); + const module = await import(url.href) as {init?: unknown}; + assert_active(); + if (typeof module.init !== 'function') { + throw new Error('Info module must export an init constructor'); + } + const candidate = new (module.init as InfoConstructor)(bot, options); + if (!candidate || typeof candidate.dispose !== 'function') { + throw new Error('Info module must implement dispose()'); + } + // Keep ownership locally until validation and subscription both succeed. + info = candidate; + assert_active(); + if (typeof info.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(info.name) + || !info.i18n || typeof info.i18n.lang !== 'string' + || typeof info.i18n.name !== 'string' || typeof info.i18n.description !== 'string' + || typeof info.get_info !== 'function' || typeof info.on_update !== 'function') { + throw new Error('Info module does not implement BaseInfo'); + } + const name = info.name; + if (infos.has(name)) throw new Error(`Duplicate info name: ${name}`); + const cancel = info.on_update(value => { + if (disposed) return; + for (const listener of Array.from(listeners)) { + if (disposed) break; + if (!listeners.has(listener)) continue; + try { listener({name, value: structuredClone(value)}); } + catch (error) { console.error(`Info update listener failed: ${name}`, error); } + } + }); + if (typeof cancel !== 'function') { + throw new Error('Info on_update() must return an unsubscribe function'); + } + unsubscribe = cancel; + assert_active(); + infos.set(name, info); + subscriptions.add(unsubscribe); + } catch (error) { + if (unsubscribe) cleanup(unsubscribe); + if (info) cleanup(() => info!.dispose()); + throw new Error(`Info module ${url.pathname} initialization failed`, {cause: error}); + } + } + assert_active(); + return manager; + } catch (error) { + manager.dispose(); + throw error; + } +} diff --git a/bot/info/instance/used_cpu.ts b/bot/info/instance/used_cpu.ts new file mode 100644 index 0000000..7b34431 --- /dev/null +++ b/bot/info/instance/used_cpu.ts @@ -0,0 +1,53 @@ +import {performance} from 'node:perf_hooks'; +import type {Bot} from '@wp2508/mineflayer'; +import {instrument_bot, metadata, ResourceInfo} from '../resource_metrics.ts'; + +export interface CpuInfo { + scope: 'bot_context'; + estimated: true; + method: 'instrumented_sync_time'; + usage_percent: number | null; + sampled_time_ms: number; + interval_ms: number; + coverage: string; +} + +const coverage = '计量 Bot.emit 和初始 _client.emit 监听器的同步执行(包括数据包监听器和 physicsTick),' + + '以及函数 run 的同步调用阶段;嵌套调用去重。不包含 emit 前的协议解析、异步等待及后续执行、' + + '无关定时器、上述入口之外的插件执行和 BotContext 通知。' + + '同步经过时间包含垃圾回收和操作系统调度暂停,因此该指标不是进程 CPU 使用率。'; + +export class init extends ResourceInfo { + readonly name = 'used_cpu'; + readonly i18n = metadata('上下文 CPU 占用估算', coverage, { + scope: ['归属范围', '指标归属于机器人上下文,固定为 bot_context'], + estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'], + method: ['计量方法', '通过插桩计量同步经过时间,不使用操作系统 CPU 计数器'], + usage_percent: ['占用比例', '采样同步耗时除以实际经过时间再乘以 100,单位为百分比;首次采样前为 null'], + sampled_time_ms: ['采样同步耗时', '本次采样间隔内去重后的同步执行耗时,单位为毫秒'], + interval_ms: ['采样间隔', '通过单调时钟测得的实际经过时间,单位为毫秒'], + coverage: ['计量覆盖范围', '同步执行计量包含的入口及未覆盖的执行阶段'], + }); + private readonly meter: ReturnType; + constructor(bot: Bot) { + super({scope: 'bot_context', estimated: true, method: 'instrumented_sync_time', + usage_percent: null, sampled_time_ms: 0, interval_ms: 0, coverage}); + this.meter = instrument_bot(bot); + let time = performance.now(); + let total = this.meter.total(); + this.start(1000, () => { + const now = performance.now(); + const next = this.meter.total(); + const elapsed = now - time; + const sampled = Math.max(0, next - total); + time = now; + total = next; + this.publish({...this.info, usage_percent: elapsed > 0 ? sampled / elapsed * 100 : null, + sampled_time_ms: sampled, interval_ms: elapsed}); + }); + } + override dispose(): void { + super.dispose(); + this.meter.dispose(); + } +} diff --git a/bot/info/instance/used_memory.ts b/bot/info/instance/used_memory.ts new file mode 100644 index 0000000..d831301 --- /dev/null +++ b/bot/info/instance/used_memory.ts @@ -0,0 +1,188 @@ +import {setImmediate as yield_batch} from 'node:timers/promises'; +import {performance} from 'node:perf_hooks'; +import type {Bot} from '@wp2508/mineflayer'; +import type {InfoI18n} from '../types.ts'; +import {metadata, ResourceInfo} from '../resource_metrics.ts'; + +export interface MemoryInfo { + estimated: true; + total_bytes: number; + chunks_bytes: number; + entities_bytes: number; + inventory_bytes: number; + other_bytes: number; + partial: boolean; + categories_complete: Record; +} + +type Category = 'chunks' | 'entities' | 'inventory' | 'other'; +const excluded = new Set(['registry', 'world', '_client', 'client', 'bot', 'global', 'globalThis', + '_events', '_eventsCount', '_maxListeners', '__proto__', 'constructor', 'prototype']); +const own = (object: unknown, key: PropertyKey): unknown => object && typeof object === 'object' + ? Object.getOwnPropertyDescriptor(object, key)?.value : undefined; +const typed_buffer = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'buffer')!.get!; +const data_buffer = Object.getOwnPropertyDescriptor(DataView.prototype, 'buffer')!.get!; +const array_length = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')!.get!; +const shared_length = Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')!.get!; + +/** Bounded live-data estimate. Objects/slots use approximate JS overhead; backing stores use real byteLength. */ +export async function estimate_memory(bot: Bot, cancelled = () => false, + limits = {nodes: 50000, properties: 200000, milliseconds: 200}): Promise { + const info: MemoryInfo = {estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0, + inventory_bytes: 0, other_bytes: 0, partial: true, + categories_complete: {chunks: false, entities: false, inventory: false, other: false}}; + const start = performance.now(); + const seen = new WeakSet(); + const blocked = new WeakSet(); + for (const key of ['registry', 'world', '_client']) { + const value = own(bot, key); + if (value && typeof value === 'object') blocked.add(value); + } + blocked.add(bot); + blocked.add(globalThis); + type Task = {value: unknown} | {object: object; keys: PropertyKey[]; index: number; columns?: boolean} + | {load_columns: true}; + const categories: Category[] = ['chunks', 'entities', 'inventory', 'other']; + const roots = {entities: ['entity', 'entities', 'players'], inventory: ['inventory', 'currentWindow'], + other: ['game', 'time', 'settings', 'scoreboards', 'teams', 'tablist']}; + // Fixed category order and DFS order make first-visit ownership deterministic, including backing stores. + const states = categories.map((category, index) => ({category, + stack: (category === 'chunks' ? [{load_columns: true}] + : [{object: bot, keys: roots[category], index: 0}]) as Task[], + nodes: 0, properties: 0, elapsed: 0, incomplete: false, + node_limit: Math.floor(limits.nodes / 4) + (index < limits.nodes % 4 ? 1 : 0), + property_limit: Math.floor(limits.properties / 4) + (index < limits.properties % 4 ? 1 : 0), + })); + let steps = 0; + let processed = 0; + while (states.some(state => state.stack.length)) { + if (cancelled() || performance.now() - start >= limits.milliseconds) break; + const state = states[steps++ % states.length]; + if (!state.stack.length) continue; + if (state.elapsed >= limits.milliseconds / 4) { + state.incomplete = true; + state.stack.length = 0; + continue; + } + const turn = performance.now(); + const {stack, category} = state; + const add = (bytes: number) => { info[`${category}_bytes`] += bytes; }; + const cap = () => { state.incomplete = true; stack.length = 0; }; + const task = stack.pop()!; + try { + if ('load_columns' in task) { + // WorldSync exposes getColumns on its prototype; resolve descriptors without invoking getters. + const world = own(bot, 'world'); + const world_descriptor = Object.getOwnPropertyDescriptor(bot, 'world'); + if (world_descriptor && !('value' in world_descriptor)) state.incomplete = true; + let target = world; + let descriptor: PropertyDescriptor | undefined; + for (let depth = 0; target && depth < 16; depth++, target = Object.getPrototypeOf(target)) { + descriptor = Object.getOwnPropertyDescriptor(target, 'getColumns'); + if (descriptor) break; + } + if (world && typeof descriptor?.value !== 'function') state.incomplete = true; + else if (world) { + const columns = Reflect.apply(descriptor!.value, world, []); + const length = own(columns, 'length'); + if (!Array.isArray(columns) || typeof length !== 'number') state.incomplete = true; + else { + if (length > 4096) state.incomplete = true; + // Extraction is incremental and charged to the chunks property budget. + stack.push({object: columns, + keys: Array.from({length: Math.min(length, 4096)}, (_, i) => String(i)), + index: 0, columns: true}); + } + } + } else if ('object' in task) { + if (task.index < task.keys.length) { + if (state.properties >= state.property_limit) cap(); + else { + state.properties++; + const key = task.keys[task.index++]; + if (task.index < task.keys.length) stack.push(task); + if (typeof key !== 'string' || !excluded.has(key)) { + const descriptor = Object.getOwnPropertyDescriptor(task.object, key); + if (descriptor && !('value' in descriptor)) state.incomplete = true; + else if (descriptor) { + if (task.columns) { + const column = own(descriptor.value, 'column'); + if (column && typeof column === 'object') stack.push({value: column}); + else state.incomplete = true; + } else { + if (task.object !== bot) add(8 + (typeof key === 'string' ? key.length * 2 : 0)); + if (typeof descriptor.value !== 'function') stack.push({value: descriptor.value}); + } + } + } + } + } + } else { + const {value} = task; + if (typeof value === 'string') add(value.length * 2); + else if (typeof value === 'number' || typeof value === 'bigint') add(8); + else if (typeof value === 'boolean') add(4); + else if (value && typeof value === 'object' && !seen.has(value) && !blocked.has(value)) { + if (state.nodes >= state.node_limit) { cap(); continue; } + seen.add(value); + state.nodes++; + add(32); + if (ArrayBuffer.isView(value)) { + // Includes a pooled Buffer's full backing allocation once, even for overlapping views. + stack.push({value: Reflect.apply(value instanceof DataView ? data_buffer : typed_buffer, value, [])}); + } else if (value instanceof ArrayBuffer || value instanceof SharedArrayBuffer) { + add(Reflect.apply(value instanceof ArrayBuffer ? array_length : shared_length, value, [])); + } else { + // Never read property values directly: accessors and prototypes are deliberately omitted. + const keys = Reflect.ownKeys(value); + const remaining = Math.max(0, state.property_limit - state.properties); + if (keys.length > remaining) { keys.length = remaining; state.incomplete = true; } + if (keys.length) stack.push({object: value, keys, index: 0}); + if (value instanceof Map || value instanceof Set) state.incomplete = true; + } + } + } + } catch { state.incomplete = true; } + finally { state.elapsed += performance.now() - turn; } + if (++processed % 256 === 0) await yield_batch(); + } + for (const state of states) info.categories_complete[state.category] = !state.incomplete && !state.stack.length; + info.partial = Object.values(info.categories_complete).some(complete => !complete); + info.total_bytes = info.chunks_bytes + info.entities_bytes + info.inventory_bytes + info.other_bytes; + return info; +} + +export class init extends ResourceInfo { + readonly name = 'used_memory'; + readonly i18n: InfoI18n = {...metadata('自有数据内存估算', '仅通过自身属性描述符分批估算当前数据,不代表堆内存或进程常驻内存。' + + '扫描根为已加载的区块列对象(最多 4096 个)、entity/entities/players 和 inventory/currentWindow。' + + '其他分类仅扫描本实例的 game、time、settings、scoreboards、teams、tablist 自有数据根。' + + '排除注册表、世界对象遍历、客户端、全局对象、监听器、函数和原型;不调用属性 getter。' + + '按区块、实体、物品栏、其他的固定顺序逐步轮转,共享对象及底层缓冲区归首次访问分类且只计一次,池化缓冲区按完整底层分配大小计入。' + + '对象和字符串开销为近似值,不统计 Map/Set 内部数据。上限为 50000 个节点、200000 个属性和 200 毫秒,' + + '节点、属性和同步处理时间各分类至多使用四分之一,总时限包含让出事件循环的时间。' + + '每轮只处理一个值或属性,每 256 步让出事件循环;getColumns 和 ownKeys 枚举本身仍同步执行,单次调用可能超时。' + + '完成状态仅针对上述有限范围;未完成分类的零值不表示没有内存占用。', {}), fields: { + ...metadata('', '', { + estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'], + total_bytes: ['估算总内存', '各分类已扫描部分内存估算值之和,单位为字节;partial 为 true 时不是完整总量'], + chunks_bytes: ['区块内存', '已加载区块列自身数据的内存估算,单位为字节'], + entities_bytes: ['实体内存', '实体和玩家自身数据的内存估算,单位为字节'], + inventory_bytes: ['物品栏内存', '物品栏和当前窗口自身数据的内存估算,单位为字节'], + other_bytes: ['其他内存', '仅 game、time、settings、scoreboards、teams、tablist 自有数据的内存估算,单位为字节'], + partial: ['结果是否不完整', '首次采样尚未完成,或因扫描上限、取消、不支持或无法访问而遗漏了数据'], + }).fields, + categories_complete: metadata('分类完成状态', '各分类是否完成约定范围的扫描;初始均为 false,false 时字节数仅为已扫描部分,零值不能说明实际无占用。', { + chunks: ['区块扫描完成', '已扫描约定的全部区块列且未触及上限或遗漏数据时为 true'], + entities: ['实体扫描完成', '已扫描 entity、entities、players 且未触及上限或遗漏数据时为 true'], + inventory: ['物品栏扫描完成', '已扫描 inventory、currentWindow 且未触及上限或遗漏数据时为 true'], + other: ['其他扫描完成', '已扫描 game、time、settings、scoreboards、teams、tablist 且未触及上限或遗漏数据时为 true'], + }), + }}; + constructor(bot: Bot) { + super({estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0, + inventory_bytes: 0, other_bytes: 0, partial: true, + categories_complete: {chunks: false, entities: false, inventory: false, other: false}}); + this.start(5000, async () => { this.publish(await estimate_memory(bot, () => this.disposed)); }, true); + } +} diff --git a/bot/info/instance/used_network.ts b/bot/info/instance/used_network.ts new file mode 100644 index 0000000..4634c5c --- /dev/null +++ b/bot/info/instance/used_network.ts @@ -0,0 +1,62 @@ +import {performance} from 'node:perf_hooks'; +import type {Bot} from '@wp2508/mineflayer'; +import {metadata, ResourceInfo} from '../resource_metrics.ts'; + +export interface NetworkInfo { + available: boolean; + received_bytes: number; + sent_bytes: number; + received_bytes_per_second: number | null; + sent_bytes_per_second: number | null; + interval_ms: number; +} + +/** Totals include the first observed counters of every new socket. Unobserved old-socket tails are unknowable. */ +export class NetworkCounter { + private socket: object | undefined; + private read = 0; + private written = 0; + private time: number | undefined; + private received = 0; + private sent = 0; + sample(socket: {bytesRead?: number; bytesWritten?: number} | undefined, now: number): NetworkInfo { + const read = socket?.bytesRead; + const written = socket?.bytesWritten; + const available = typeof read === 'number' && Number.isFinite(read) && read >= 0 + && typeof written === 'number' && Number.isFinite(written) && written >= 0; + const elapsed = this.time === undefined ? 0 : now - this.time; + const continuous = available && socket === this.socket && this.time !== undefined + && read >= this.read && written >= this.written && elapsed > 0; + const dr = available ? (socket === this.socket && read >= this.read ? read - this.read : read) : 0; + const dw = available ? (socket === this.socket && written >= this.written ? written - this.written : written) : 0; + this.received += dr; + this.sent += dw; + if (available) { + this.socket = socket; + this.read = read; + this.written = written; + } + this.time = available ? now : undefined; + return {available, received_bytes: this.received, sent_bytes: this.sent, + received_bytes_per_second: continuous ? dr * 1000 / elapsed : null, + sent_bytes_per_second: continuous ? dw * 1000 / elapsed : null, interval_ms: continuous ? elapsed : 0}; + } +} + +export class init extends ResourceInfo { + readonly name = 'used_network'; + readonly i18n = metadata('机器人网络流量', '读取传输层套接字的字节计数,包含协议开销;' + + '更换套接字后保留累计观察量,不包含旧套接字被替换前未观察到的尾部流量。', { + available: ['计数是否可用', '套接字是否同时提供有效的接收和发送字节计数'], + received_bytes: ['累计接收量', '累计观察到的接收流量,单位为字节'], + sent_bytes: ['累计发送量', '累计观察到的发送流量,单位为字节'], + received_bytes_per_second: ['接收速率', '接收流量速率,单位为字节每秒;缺少连续采样基线时为 null'], + sent_bytes_per_second: ['发送速率', '发送流量速率,单位为字节每秒;缺少连续采样基线时为 null'], + interval_ms: ['速率采样间隔', '通过单调时钟测得的实际速率采样间隔,单位为毫秒;缺少连续采样基线时为 0'], + }); + constructor(bot: Bot) { + const counter = new NetworkCounter(); + super(counter.sample(bot._client?.socket, performance.now())); + this.start(1000, () => { this.publish(counter.sample(bot._client?.socket, performance.now())); }); + } +} diff --git a/bot/info/instance/used_storage.ts b/bot/info/instance/used_storage.ts new file mode 100644 index 0000000..368bb8b --- /dev/null +++ b/bot/info/instance/used_storage.ts @@ -0,0 +1,75 @@ +import {lstat, opendir} from 'node:fs/promises'; +import path from 'node:path'; +import {performance} from 'node:perf_hooks'; +import type {Bot} from '@wp2508/mineflayer'; +import {get_project_root_path} from '../../../utils/path_utils.ts'; +import type {InfoContext} from '../types.ts'; +import {metadata, ResourceInfo} from '../resource_metrics.ts'; + +export interface StorageInfo { + available: boolean; + total_bytes: number | null; + file_count: number; + partial: boolean; +} + +/** lstat and streamed directory enumeration never intentionally follow symlinks. Live mutation can race a scan. */ +export async function scan_storage(directory: string, cancelled = () => false, + limits = {entries: 20000, depth: 32, milliseconds: 2000}): Promise { + const info: StorageInfo = {available: false, total_bytes: null, file_count: 0, partial: false}; + const start = performance.now(); + let entries = 0; + const pending: [string, number][] = [[directory, 0]]; + while (pending.length) { + if (cancelled() || entries >= limits.entries || performance.now() - start >= limits.milliseconds) { + info.partial = true; break; + } + const [current, depth] = pending.pop()!; + entries++; + try { + const stat = await lstat(current); + if (current === directory) { + if (!stat.isDirectory() || stat.isSymbolicLink()) { info.partial = true; break; } + info.available = true; + info.total_bytes = 0; + } + if (stat.isSymbolicLink()) continue; + if (stat.isFile()) { + info.total_bytes! += stat.size; + info.file_count++; + } else if (stat.isDirectory()) { + if (depth >= limits.depth) { info.partial = true; continue; } + const dir = await opendir(current); + for await (const entry of dir) { + if (cancelled() || entries + pending.length >= limits.entries + || performance.now() - start >= limits.milliseconds) { info.partial = true; break; } + if (!entry.isSymbolicLink()) pending.push([path.join(current, entry.name), depth + 1]); + } + } + } catch (error) { + if (current === directory && (error as NodeJS.ErrnoException).code === 'ENOENT') { + info.available = true; + info.total_bytes = 0; + } else info.partial = true; + } + } + return info; +} + +export class init extends ResourceInfo { + readonly name = 'used_storage'; + readonly i18n = metadata('机器人数据存储', '统计 data/bot/ 下文件的逻辑大小,跳过符号链接。' + + '每 30 秒尽力扫描一次,不重叠执行;上限为 20000 个条目、32 层深度和 2 秒。' + + '扫描期间的并发文件变动可能影响结果;该指标不代表实际分配的磁盘空间。', { + available: ['存储统计是否可用', '目录可访问或不存在时为 true,不存在按 0 统计;缺少有效机器人编号或根目录不可访问时为 false'], + total_bytes: ['文件总大小', '已观察到的普通文件逻辑大小之和,单位为字节;不可用时为 null'], + file_count: ['文件数量', '已观察到的普通文件数量'], + partial: ['结果是否不完整', '因扫描上限、取消或错误而未完成扫描;保留已读取文件的统计小计'], + }); + constructor(_bot: Bot, options?: InfoContext) { + super({available: false, total_bytes: null, file_count: 0, partial: true}); + if (!Number.isSafeInteger(options?.bot_id) || options!.bot_id! <= 0) return; + const directory = path.join(get_project_root_path(), 'data', 'bot', String(options!.bot_id)); + this.start(30000, async () => { this.publish(await scan_storage(directory, () => this.disposed)); }, true); + } +} diff --git a/bot/info/resource_metrics.ts b/bot/info/resource_metrics.ts new file mode 100644 index 0000000..6847469 --- /dev/null +++ b/bot/info/resource_metrics.ts @@ -0,0 +1,104 @@ +import {performance} from 'node:perf_hooks'; +import type {Bot} from '@wp2508/mineflayer'; +import type {BaseInfo, InfoI18n} from './types.ts'; + +type Meter = {depth: number; total: number}; +const meters = new WeakMap(); + +/** Only the synchronous invocation is timed, even when it returns a Promise. */ +export function measure_sync(bot: object, invoke: () => T): T { + const meter = meters.get(bot); + if (!meter) return invoke(); + const outer = meter.depth++ === 0; + const start = outer ? performance.now() : 0; + try { return invoke(); } + finally { + meter.depth--; + if (outer) meter.total += performance.now() - start; + } +} + +export function instrument_bot(bot: Bot): {total: () => number; dispose: () => void} { + if (meters.has(bot)) throw new Error('Bot resource meter already installed'); + const meter: Meter = {depth: 0, total: 0}; + meters.set(bot, meter); + const restore: (() => void)[] = []; + try { + for (const target of new Set([bot, bot._client])) { + if (!target || typeof target.emit !== 'function') continue; + const descriptor = Object.getOwnPropertyDescriptor(target, 'emit'); + const original = target.emit; + const wrapper = function(this: typeof target, ...args: Parameters) { + return measure_sync(bot, () => Reflect.apply(original, this, args)); + }; + Object.defineProperty(target, 'emit', {configurable: true, writable: true, + enumerable: descriptor?.enumerable ?? false, value: wrapper}); + restore.push(() => { + if (Object.getOwnPropertyDescriptor(target, 'emit')?.value !== wrapper) return; + if (descriptor) Object.defineProperty(target, 'emit', descriptor); + else delete (target as unknown as {emit?: unknown}).emit; + }); + } + } catch (error) { + for (const undo of restore.reverse()) undo(); + meters.delete(bot); + throw error; + } + let disposed = false; + return {total: () => meter.total, dispose: () => { + if (disposed) return; + disposed = true; + meters.delete(bot); + for (const undo of restore.reverse()) undo(); + }}; +} + +export function metadata(name: string, description: string, fields: Record): InfoI18n { + return {lang: 'zh-CN', name, description, fields: Object.fromEntries(Object.entries(fields).map( + ([key, [name, description]]) => [key, {lang: 'zh-CN', name, description}], + ))}; +} + +/** No module owns bot end listeners; InfoManager owns disposal. */ +export abstract class ResourceInfo implements BaseInfo { + abstract readonly name: string; + abstract readonly i18n: InfoI18n; + protected disposed = false; + private timer?: ReturnType; + private running = false; + private readonly listeners = new Set<(value: T) => void>(); + constructor(protected info: T) {} + get_info(): T { return this.info; } + on_update(callback: (value: T) => void): () => void { + if (this.disposed) throw new Error('Resource info disposed'); + this.listeners.add(callback); + return () => { this.listeners.delete(callback); }; + } + protected publish(info: T): void { + if (this.disposed) return; + this.info = info; + for (const listener of Array.from(this.listeners)) { + if (this.disposed) break; + try { listener(info); } + catch (error) { console.error('Resource info listener failed', error); } + } + } + protected start(interval: number, sample: () => void | Promise, immediate = false): void { + const run = async () => { + if (this.running || this.disposed) return; + this.running = true; + try { await sample(); } + catch (error) { if (!this.disposed) console.error('Resource sampling failed', error); } + finally { this.running = false; } + }; + this.timer = setInterval(() => { void run(); }, interval); + this.timer.unref?.(); + if (immediate) void run(); + } + dispose(): void { + if (this.disposed) return; + this.disposed = true; + clearInterval(this.timer); + this.listeners.clear(); + } +} diff --git a/bot/info/types.ts b/bot/info/types.ts new file mode 100644 index 0000000..5b9b627 --- /dev/null +++ b/bot/info/types.ts @@ -0,0 +1,49 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {PluginSnapshot} from '../plugins/init.ts'; +import type {RunSnapshot, WorkflowRunEvent} from '../../types/bot/workflow/runtime.ts'; + +export interface InfoI18n { + readonly lang: string; + readonly name: string; + readonly description: string; + readonly fields?: Readonly>; + readonly values?: Readonly>; +} + +/** 一个信息对象绑定一个 Bot 实例;由信息管理器统一监听 end 并销毁,单独使用时由调用方负责销毁。 */ +export interface BaseInfo { + /** 稳定的模块标识,不使用翻译后的显示名称。 */ + readonly name: string; + readonly i18n: InfoI18n; + + /** 同步读取当前值;销毁后仍可读取最后保存的值。 */ + get_info(): T; + + /** + * 订阅后续变化,不立即回调;初始值通过 get_info() 获取。 + * 返回的取消函数可重复调用,销毁后禁止新增订阅并抛出错误。 + * 回调同步执行,订阅者应自行处理异常。 + */ + on_update(callback: (data: T) => void): () => void; + + /** 移除 Bot 事件监听和全部订阅;可重复调用,销毁后不再发布更新。 */ + dispose(): void; +} + +/** 构造签名单独约束类本身,不属于 BaseInfo 的实例成员。 */ +export interface InfoConstructor { + new (instance: Bot, options?: InfoContext): BaseInfo; +} + +export interface InfoContext { + readonly bot_id?: number; + readonly plugin_source?: { + get_all_plugins(): PluginSnapshot[]; + on_update(callback: (event: PluginSnapshot) => void): () => void; + }; + readonly workflow_source?: { + get_snapshots(): RunSnapshot[]; + get_runtime_snapshot?(): import('../../types/bot/workflow/runtime.ts').WorkflowRuntimeSnapshot; + on_update(callback: (event: WorkflowRunEvent) => void): () => void; + }; +} diff --git a/bot/instance/bot_instance_init.ts b/bot/instance/bot_instance_init.ts new file mode 100755 index 0000000..93c7e01 --- /dev/null +++ b/bot/instance/bot_instance_init.ts @@ -0,0 +1,626 @@ +import { createBot } from "@wp2508/mineflayer"; +import type { Bot, BotEvents } from "@wp2508/mineflayer"; +import {bot_instance_config, bot_status} from "../../types/bot/instance/bot_runtime.ts"; +import { + validate_host, + validate_port, + validate_proxy, + validate_username, + validate_version +} from "../../utils/validate.ts"; +import {type bot_operation_code, type validate_code, validate_code_map} from "../../utils/validate_code_map.ts"; +import { EventEmitter } from "node:events"; +import { Mutex } from "async-mutex"; +import {Logger} from "../../utils/logger.ts"; +import {get_project_root_path} from "../../utils/path_utils.ts"; +import * as path from 'path'; +import {log} from "../../types/utils/logger.ts"; +import {MinecraftJsonParser} from "../../utils/message_json_parser.ts"; +import {remember_command_tree} from '../../utils/command_completion.ts'; +import {create_info_manager, type InfoManager, type InfoUpdate} from '../info/init.ts'; +import {create_function_manager, type FunctionManager} from '../function/init.ts'; +import {PluginManager} from '../plugins/init.ts'; +import type {WorkflowManager, WorkflowRunEvent} from '../../types/bot/workflow/runtime.ts'; + +// 深度清理的最大递归深度与最大节点数,避免遍历无关的深层对象图 +const CLEANUP_MAX_DEPTH = 8; +const CLEANUP_MAX_VISITED = 2000; +// 共享 / 全局对象,禁止递归进入,避免误清理缓存或全局状态 +const CLEANUP_SKIP_KEYS = new Set(['registry', 'supportFeature', 'constructor']); +// 需要显式清理的定时器与待发队列字段名 +const CLEANUP_TIMER_KEYS = ['closeTimer', '_keepAliveTimer', 'keepAliveTimer', 'doPhysicsTimer']; +const CLEANUP_QUEUE_KEYS = ['_writeQueue', '_priorityWriteQueue', '_mcBundle']; +// 全局对象,绝不能被遍历清理 +const CLEANUP_PROTECTED: unknown[] = [globalThis, process, console]; +// 'end' 事件未按时派发时的兜底清理延时(毫秒) +const CLEANUP_FALLBACK_DELAY = 1000; + +// 事件表:mineflayer 的全部事件 + 实例自有的 log 事件 +export interface bot_instance_event extends BotEvents { + log: (log: log[]) => void; + status_update: (status: bot_status) => void; + instance_created: (instance: Bot) => void; + info_update: (event: InfoUpdate) => void; + connection_lost: () => void; + connection_requested: () => void; + reconnect_attempt: () => void; + workflow_update: (event: WorkflowRunEvent) => void; +} + +export type bot_event_name = keyof bot_instance_event; +// 单个事件的回调参数元组,例如 chat -> [username, message, translate, jsonMsg, matches] +export type bot_event_args = Parameters; +export type bot_event_handler = (...args: bot_event_args) => void; +// onAny / onceAny 在事件名之后接收对应事件的全部参数; +// 事件名受联合类型约束,参数保持宽松以便统一处理任意事件 +export type bot_event_any_handler = (event: bot_event_name, ...args: any[]) => void; + +export type bot_instance_context = { + readonly bot_instance: Bot | null, + readonly info_manager: InfoManager | null, + readonly function_manager: FunctionManager | null, + workflow_manager: WorkflowManager | null, + bot_config: bot_instance_config, + readonly bot_status: bot_status, + readonly plugin_manager: PluginManager, + event_emitter: EventEmitter, + logger: Logger | null, + action_lock: boolean, + instance_lock: Mutex, + validate_config: (config: bot_instance_config) => validate_code, + log: (log: log[]) => void, + on: (event: E, callback: bot_event_handler) => void, + onAny: (callback: bot_event_any_handler) => void, + once: (event: E, callback: bot_event_handler) => void, + onceAny: (callback: bot_event_any_handler) => void, + off: (event: E, callback: bot_event_handler) => void, + emit: (event: E, ...args: bot_event_args) => void, + + connect: () => Promise, + create_instance: () => Promise, + disconnect: () => Promise, + schedule_reconnect: (delay: number) => Promise, + cancel_reconnect: () => void, + inject: (callback: (context: bot_instance_context) => void) => void, + clear_this_context: () => Promise +} + +export class BotContext implements bot_instance_context { + get is_disposed(): boolean {return this.disposal_requested;} + public workflow_manager: WorkflowManager | null = null; + private bot_instance_origin: Bot | null = null; + public get bot_instance(): Bot | null { + return this.bot_instance_origin; + } + private info_manager_origin: InfoManager | null = null; + public get info_manager(): InfoManager | null { + return this.info_manager_origin; + } + private info_unsubscribe: (() => void) | undefined; + private function_manager_origin: FunctionManager | null = null; + public get function_manager(): FunctionManager | null { + return this.function_manager_origin; + } + private cleanup_task: Promise | undefined; + private dispose_task: Promise | undefined; + private disposal_requested = false; + private operation_sequence = 0; + private reconnect_timer: ReturnType | undefined; + public readonly bot_config: bot_instance_config = { + username: '', + host: '', + port: 0, + version: '', + user_id: 0, + bot_id: 0, + proxy: null + }; + private bot_status_origin: bot_status = 'offline'; + public get bot_status(): bot_status { + return this.bot_status_origin; + } + + private set_status(value: bot_status) { + if (this.bot_status_origin === value) { + return; + } + const transitions: Record = { + offline: ['ready', 'reconnecting'], + ready: ['connecting', 'disconnecting'], + connecting: ['online', 'disconnecting'], + online: ['disconnecting'], + disconnecting: ['offline'], + reconnecting: ['ready', 'offline'], + }; + if (!transitions[this.bot_status_origin].includes(value)) { + throw new Error(`无效的机器人状态转换: ${this.bot_status_origin} -> ${value}`); + } + this.bot_status_origin = value; + this.notify('status_update', value); + } + + private notify(event: E, ...args: bot_event_args) { + try { + this.emit(event, ...args); + } catch (error) { + this.logger?.log([this.logger.error(`生命周期事件 ${String(event)} 处理失败: ${String(error)}`)]); + } + } + public readonly plugin_manager: PluginManager; + public event_emitter = new EventEmitter() + // 记录每个原始回调对应的包装函数,off 时才能找回并移除同一个监听器 + private readonly event_wrappers = new Map< + string, + Map<(...args: any[]) => void, ((...args: any[]) => void)[]> + >(); + public logger: Logger | null = null; + public action_lock: boolean = false; + // 串行化 create_instance / connect / disconnect,防止并发操作实例 + public readonly instance_lock : Mutex = new Mutex(); + + constructor(config: bot_instance_config) { + this.bot_config = config; + const log_path = path.join(get_project_root_path(), 'data/bot/', this.bot_config.bot_id.toString(), '/log'); + this.logger = new Logger(log_path); + this.plugin_manager = new PluginManager(this); + } + + public log(log: log[]) { + this.logger.log(log); + this.emit('log', log); + } + + public create_instance(): Promise { + if (this.bot_status === 'reconnecting' || this.reconnect_timer !== undefined) this.cancel_reconnect(); + const sequence = this.operation_sequence; + return this.instance_lock.runExclusive(async () => { + await this.cleanup_task; + if (this.disposal_requested) return validate_code_map.bot_context.disposed; + if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled; + if (this.bot_status === 'online') return validate_code_map.bot_context.instance_connected; + if (this.bot_status === 'connecting') return validate_code_map.bot_context.instance_connecting; + if (this.bot_instance) return validate_code_map.bot_context.ok; + const result = await this.create_bot_instance(sequence); + if (result !== validate_code_map.bot_context.ok) await this.cleanup_task; + if (this.disposal_requested) return validate_code_map.bot_context.disposed; + if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled; + return result; + }); + } + + private async create_bot_instance(sequence: number): Promise { + const result = this.validate_config(this.bot_config); + if (result !== validate_code_map.username.ok) return result; + let instance: Bot | undefined; + let pending_manager: InfoManager | undefined; + let pending_functions: FunctionManager | undefined; + let initialized = false; + const failure = (): bot_operation_code | undefined => { + if (this.disposal_requested) return validate_code_map.bot_context.disposed; + if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled; + if (this.bot_instance !== instance) return validate_code_map.bot_context.error; + }; + try { + const {port, plugins, workflow, config_revision, ...options} = this.bot_config; + instance = createBot({...options, ...(port != null ? {port} : {}), autoConnect: false, hideErrors: true}); + (instance as Bot & {server_address: string}).server_address = `${options.host.includes(':') ? `[${options.host}]` : options.host}:${port ?? 25565}`; + this.bot_instance_origin = instance; + instance._client.on('declare_commands', packet => remember_command_tree(instance, packet)); + const terminate = (event: E, ...args: bot_event_args) => { + if (this.bot_instance !== instance || this.disposal_requested) return; + const reason = args[0]; + if (event === 'error') { + this.log([this.logger.error(`连接错误: ${String(reason)}`)]); + } else if (event === 'kicked') { + let kickReason: Parameters[0] = args[0] as Parameters[0]; + if (typeof kickReason === 'string') { + try { kickReason = JSON.parse(kickReason); } + catch { /* Plain-text disconnect reasons are valid too. */ } + } + const reasonLog = MinecraftJsonParser.toLog(kickReason); + if (!reasonLog.length) { + let fallback = '未知原因'; + try { fallback = JSON.stringify(kickReason) ?? fallback; } + catch { /* Keep a readable fallback for malformed data. */ } + reasonLog.push(this.logger.error(fallback)); + } + this.log([ + this.logger.custom('[被踢出] ', '#cf8e8e', ['bold']), + ...reasonLog, + ]); + } else { + this.log([this.logger.warning(`连接已断开: ${String(reason ?? '未知原因')}`)]); + } + const sequence = this.operation_sequence; + // Initialization failures are handled by the caller, including retry attempts. + const report_connection_lost = initialized; + const cleanup = this.clear_bot_instance(event === 'end'); + this.notify(event, ...args); + void cleanup.then(() => { + if (report_connection_lost && !this.disposal_requested && sequence === this.operation_sequence) { + this.notify('connection_lost'); + } + }).catch(error => { + this.logger?.log([this.logger.error(`清理实例失败: ${String(error)}`)]); + }); + }; + instance.on('end', reason => terminate('end', reason)); + instance.on('kicked', (reason, logged_in) => terminate('kicked', reason, logged_in)); + instance.on('error', (error: Error) => terminate('error', error)); + instance.on('initialLogin', () => { + if (this.bot_instance !== instance || this.bot_status !== 'connecting' || this.disposal_requested) return; + this.set_status('online'); + }); + instance.on("message", (jsonMsg, position) => { + const channel = { + chat: {label: '聊天', color: '#8fb0c9'}, + system: {label: '系统', color: '#d1b478'}, + game_info: {label: '提示', color: '#8fb98f'}, + }[position] ?? {label: String(position), color: '#8fb0c9'}; + this.log([ + this.logger.custom(`[${channel.label}] `, channel.color, ['bold']), + ...MinecraftJsonParser.parse(jsonMsg).log, + ]); + }) + instance.onAny((event: string, ...args: any[]) => { + if (this.bot_instance === instance && !this.disposal_requested) this.emit(event, ...args); + }); + this.set_status('ready'); + const before_loading = failure(); + if (before_loading !== undefined) return before_loading; + const manager = pending_manager = await create_info_manager(instance, { + bot_id: this.bot_config.bot_id, plugin_source: this.plugin_manager, + workflow_source: this.workflow_manager, + }); + const after_loading = failure(); + if (after_loading !== undefined) return after_loading; + this.info_manager_origin = manager; + pending_manager = undefined; + this.info_unsubscribe = manager.on_update(event => { + if (this.bot_instance === instance && this.info_manager === manager + && !this.disposal_requested) { + this.notify('info_update', event); + } + }); + pending_functions = await create_function_manager(this); + const after_functions = failure(); + if (after_functions !== undefined) return after_functions; + this.function_manager_origin = pending_functions; + pending_functions = undefined; + this.notify('instance_created', instance); + const after_notification = failure(); + if (after_notification !== undefined) return after_notification; + this.log([this.logger.custom('[NODE] 创建机器人实例成功', '#8fb98f', ['bold'])]); + const after_log = failure(); + if (after_log !== undefined) return after_log; + initialized = true; + return validate_code_map.bot_context.ok; + } catch (error) { + this.logger?.log([this.logger.error(`创建实例失败: ${String(error)}`)]); + if (this.disposal_requested) return validate_code_map.bot_context.disposed; + if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled; + return validate_code_map.bot_context.error; + } finally { + if (!initialized) { + pending_functions?.dispose(); + try { pending_manager?.dispose(); } + catch (error) { this.logger?.log([this.logger.error(`清理信息管理器失败: ${String(error)}`)]); } + if (instance && this.bot_instance === instance) await this.clear_bot_instance(); + else await this.cleanup_task; + } + } + } + + public inject(callback: (context: bot_instance_context) => void) { + callback(this as bot_instance_context); + } + + public connect(): Promise { + if (this.bot_status === 'reconnecting' || this.reconnect_timer !== undefined) this.cancel_reconnect(); + const sequence = this.operation_sequence; + if (!this.disposal_requested) this.notify('connection_requested'); + return this.connect_instance(sequence); + } + + private connect_instance(sequence: number): Promise { + return this.instance_lock.runExclusive(async () => { + await this.cleanup_task; + if (this.disposal_requested) return validate_code_map.bot_context.disposed; + if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled; + if (this.bot_status === 'online') return validate_code_map.bot_context.instance_connected; + if (this.bot_status === 'connecting') return validate_code_map.bot_context.instance_connecting; + if (this.bot_status === 'reconnecting') this.set_status('offline'); + if (!this.bot_instance) { + const result = await this.create_bot_instance(sequence); + if (result !== validate_code_map.bot_context.ok) { + await this.cleanup_task; + return result; + } + } + if (this.disposal_requested) return validate_code_map.bot_context.disposed; + if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled; + const instance = this.bot_instance; + if (!instance) return validate_code_map.bot_context.error; + this.set_status('connecting'); + if (this.disposal_requested || sequence !== this.operation_sequence) { + await this.clear_bot_instance(); + return validate_code_map.bot_context.cancelled; + } + try { + instance.connect(); + if (this.bot_instance !== instance) { + await this.cleanup_task; + return validate_code_map.bot_context.error; + } + return validate_code_map.bot_context.ok; + } catch (error) { + this.logger?.log([this.logger.error(`连接实例失败: ${String(error)}`)]); + await this.clear_bot_instance(); + return validate_code_map.bot_context.error; + } + }); + } + + public disconnect(): Promise { + this.cancel_reconnect(); + return this.instance_lock.runExclusive(async () => { + if (this.disposal_requested) return validate_code_map.bot_context.disposed; + await this.clear_bot_instance(); + return validate_code_map.bot_context.ok; + }); + } + + public cancel_reconnect(): void { + this.operation_sequence++; + clearTimeout(this.reconnect_timer); + this.reconnect_timer = undefined; + if (this.bot_status === 'reconnecting') this.set_status('offline'); + } + + public async schedule_reconnect(delay: number): Promise { + if (!Number.isInteger(delay) || delay < 0 || delay > 2_147_483_647) { + throw new Error('重连延时必须是有效的毫秒整数'); + } + const sequence = this.operation_sequence; + await this.instance_lock.runExclusive(() => { + if (this.disposal_requested || sequence !== this.operation_sequence || this.bot_status !== 'offline') return; + this.reconnect_timer = setTimeout(() => { + this.reconnect_timer = undefined; + if (this.disposal_requested || sequence !== this.operation_sequence) return; + this.notify('reconnect_attempt'); + void this.connect_instance(sequence).then(result => { + if (result !== validate_code_map.bot_context.ok && !this.disposal_requested + && sequence === this.operation_sequence && this.bot_status === 'offline') { + this.notify('connection_lost'); + } + }).catch(error => { + this.logger?.log([this.logger.error(`自动重连失败: ${String(error)}`)]); + }); + }, delay); + this.set_status('reconnecting'); + }); + } + + public on(event: E, callback: bot_event_handler) { + const wrapper: bot_event_handler = (...args) => callback(...args); + this.remember_wrapper(event, callback, wrapper); + this.event_emitter.on(event, wrapper); + } + + public onAny(callback: bot_event_any_handler) { + const wrapper = (event: string, ...args: any[]) => callback(event as bot_event_name, ...args); + this.remember_wrapper('any', callback, wrapper); + this.event_emitter.on('any', wrapper); + } + + public once(event: E, callback: bot_event_handler) { + const wrapper: bot_event_handler = (...args) => callback(...args); + this.remember_wrapper(event, callback, wrapper); + this.event_emitter.once(event, wrapper); + } + + public onceAny(callback: bot_event_any_handler) { + const wrapper = (event: string, ...args: any[]) => callback(event as bot_event_name, ...args); + this.remember_wrapper('any', callback, wrapper); + this.event_emitter.once('any', wrapper); + } + + public off(event: E, callback: bot_event_handler) { + const callbacks = this.event_wrappers.get(event); + const wrappers = callbacks?.get(callback); + if (!callbacks || !wrappers) return; + const wrapper = wrappers.pop(); + if (!wrapper) return; + this.event_emitter.off(event, wrapper); + if (wrappers.length === 0) { + callbacks.delete(callback); + } + } + + // 记录原始回调 -> 包装函数栈,off 时才能找回并移除同一个监听器; + // 同一回调可多次注册,off 从栈顶移除最近一次 + private remember_wrapper( + event: string, + callback: (...args: any[]) => void, + wrapper: (...args: any[]) => void, + ) { + let callbacks = this.event_wrappers.get(event); + if (!callbacks) { + callbacks = new Map(); + this.event_wrappers.set(event, callbacks); + } + let wrappers = callbacks.get(callback); + if (!wrappers) { + wrappers = []; + callbacks.set(callback, wrappers); + } + wrappers.push(wrapper); + } + + public emit(event: E, ...args: bot_event_args): void; + public emit(event: string, ...args: any[]): void; + public emit(event: string, ...args: any[]) { + // 'error' 无监听器时 EventEmitter 会抛出;转发前先兜底,避免 bot 的错误 + // 变成未捕获异常,同时保证 'any' / onAny 仍能观察到该事件 + if (event !== 'error' || this.event_emitter.listenerCount('error') > 0) { + this.event_emitter.emit(event, ...args); + } + this.event_emitter.emit('any', event, ...args); + } + + private clear_bot_instance(ended = false): Promise { + if (this.cleanup_task) return this.cleanup_task; + const instance = this.bot_instance; + if (!instance) return Promise.resolve(); + this.bot_instance_origin = null; + const manager = this.info_manager_origin; + const functions = this.function_manager_origin; + this.function_manager_origin = null; + const unsubscribe = this.info_unsubscribe; + this.info_manager_origin = null; + this.info_unsubscribe = undefined; + let cleaned = false; + let finish!: () => void; + const task = new Promise(resolve => { finish = resolve; }); + this.cleanup_task = task.then(() => { + this.cleanup_task = undefined; + if (!this.disposal_requested) this.set_status('offline'); + }); + const cleanup_task = this.cleanup_task; + const run_cleanup = () => { + if (cleaned) return; + cleaned = true; + clearTimeout(fallback_timer); + try { + this.deep_cleanup(instance, new Set(), 0); + this.log([this.logger.custom('[NODE] 机器人实例已关闭', '#cf8e8e', ['bold'])]); + } catch (error) { + this.logger?.log([this.logger.error(`清理实例资源失败: ${String(error)}`)]); + } finally { + finish(); + } + }; + // 等 end 的所有监听器执行完毕后再摘除监听器,保留 mineflayer 的内部资源清理。 + const on_end = () => { queueMicrotask(run_cleanup); }; + const fallback_timer = setTimeout(run_cleanup, CLEANUP_FALLBACK_DELAY); + for (const cleanup of [unsubscribe, manager ? () => manager.dispose() : undefined, functions ? () => functions.dispose() : undefined]) { + try { cleanup?.(); } + catch (error) { this.logger?.log([this.logger.error(`清理信息管理器失败: ${String(error)}`)]); } + } + this.set_status('disconnecting'); + if (ended) { + on_end(); + } else { + instance.once('end', on_end); + try { + instance.end(validate_code_map.bot_context.instance_end_by_user.toString()); + } catch { + on_end(); + } + } + return cleanup_task; + } + + // 深度遍历对象图,释放监听器 / 定时器 / 流句柄等残留状态; + // seen 去重,depth 限制递归深度,visited 限制遍历规模,避免开销失控 + private deep_cleanup(target: unknown, seen: Set, depth: number, visited = { count: 0 }) { + if (target === null || typeof target !== 'object') return; + if (depth > CLEANUP_MAX_DEPTH || visited.count > CLEANUP_MAX_VISITED) return; + const object = target as Record; + if (CLEANUP_PROTECTED.includes(object)) return; + if (seen.has(object)) return; + seen.add(object); + visited.count++; + + this.release_own_state(object); + + for (const key of Object.keys(object)) { + if (CLEANUP_SKIP_KEYS.has(key)) continue; + let value: unknown; + try { + value = object[key]; + } catch { + continue; + } + if (value === null || typeof value !== 'object') continue; + this.deep_cleanup(value, seen, depth + 1, visited); + } + } + + // 释放单个对象自身持有的监听器 / 定时器 / 流句柄 / 待发队列 + private release_own_state(object: Record) { + for (const key of CLEANUP_TIMER_KEYS) { + const timer = object[key]; + if (timer === null || timer === undefined) continue; + clearInterval(timer as NodeJS.Timeout); + clearTimeout(timer as NodeJS.Timeout); + try { + object[key] = null; + } catch { + // 只读 / 冻结对象无法赋值,忽略 + } + } + for (const key of CLEANUP_QUEUE_KEYS) { + if (Array.isArray(object[key])) { + (object[key] as unknown[]).length = 0; + } + } + // 仅销毁流 / socket 类句柄,避免误调用无关对象的 destroy + if (typeof object.destroy === 'function' && object.destroyed !== true + && (typeof object.pipe === 'function' || typeof object.write === 'function')) { + try { + object.destroy(); + } catch { + // 忽略已关闭句柄在销毁时的异常 + } + } + if (typeof object.removeAllListeners === 'function') { + object.removeAllListeners(); + } + } + + public validate_config(config: bot_instance_config): validate_code { + const username_validate = validate_username(config.username); + if (username_validate !== validate_code_map.username.ok) return username_validate; + const host_validate = validate_host(config.host); + if (host_validate !== validate_code_map.host.ok) return host_validate; + if (config.port != null) { + const port_validate = validate_port(config.port); + if (port_validate !== validate_code_map.port.ok) return port_validate; + } + const version_validate = validate_version(config.version); + if (version_validate !== validate_code_map.version.ok) return version_validate; + if (config.proxy != null) { + const proxy_validate = validate_proxy(config.proxy); + if (proxy_validate !== validate_code_map.proxy.ok) return proxy_validate; + } + return validate_code_map.username.ok; + } + + public clear_this_context(): Promise { + if (this.dispose_task) return this.dispose_task; + this.disposal_requested = true; + this.cancel_reconnect(); + const plugins_disposed = this.plugin_manager.dispose().catch(error => { + this.logger?.log([this.logger.error(`插件卸载失败: ${String(error)}`)]); + }); + const workflow_disposed = this.workflow_manager?.dispose().catch(error => { + this.logger?.log([this.logger.error(`Workflow cleanup failed: ${String(error)}`)]); + }); + this.dispose_task = Promise.all([plugins_disposed, workflow_disposed]).then(() => this.instance_lock.runExclusive(async () => { + await this.clear_bot_instance(); + this.action_lock = false; + if (this.bot_status === 'offline') { + this.notify('status_update', 'offline'); + } else { + this.set_status('offline'); + } + this.event_emitter.removeAllListeners(); + this.event_wrappers.clear(); + this.workflow_manager = null; + await this.logger?.close(); + })); + return this.dispose_task; + } + +} diff --git a/bot/instance/index.ts b/bot/instance/index.ts new file mode 100644 index 0000000..36de7a5 --- /dev/null +++ b/bot/instance/index.ts @@ -0,0 +1,181 @@ +import {bot_instance_context, BotContext} from "./bot_instance_init"; +import {bot_config, bot_status} from "../../types/bot/instance/bot_runtime"; +import {validate_code_map} from "../../utils/validate_code_map"; +import {init_workflow} from "../workflow/init"; +import {update_one_bot_status} from "../../service/server/update/bot_status.ts"; +import type {Socket} from "socket.io-client"; + + +const bot_context_map: Map> = new Map(); +const bot_action_lock_map = new Map(); +type ContextObserver = (context: bot_instance_context | undefined) => void; +const bot_context_observers = new Map>(); + +export function get_bot_context(bot_id: number): bot_instance_context | undefined { + return bot_context_map.get(bot_id); +} + +function notify_context_observer(callback: ContextObserver, context: bot_instance_context | undefined) { + try { + callback(context); + } catch { + console.error('Bot context observer failed'); + } +} + +function notify_bot_context(bot_id: number) { + for (const callback of Array.from(bot_context_observers.get(bot_id) ?? [])) { + notify_context_observer(callback, bot_context_map.get(bot_id)); + } +} + +export function observe_bot_context(bot_id: number, callback: ContextObserver): () => void { + let observers = bot_context_observers.get(bot_id); + if (!observers) { + observers = new Set(); + bot_context_observers.set(bot_id, observers); + } + observers.add(callback); + notify_context_observer(callback, bot_context_map.get(bot_id)); + return () => { + observers.delete(callback); + if (observers.size === 0 && bot_context_observers.get(bot_id) === observers) { + bot_context_observers.delete(bot_id); + } + }; +} + +function remove_bot_context(bot_id: number, context: bot_instance_context) { + if (bot_context_map.get(bot_id) === context) { + bot_context_map.delete(bot_id); + notify_bot_context(bot_id); + } +} + +export async function start_bot_context(main_server_socket: Socket, bot_config: bot_config, node_id: number) { + if (!Number.isSafeInteger(node_id) || node_id <= 0 + || !bot_config || typeof bot_config !== "object" || Array.isArray(bot_config) + || !bot_config.instance_config || typeof bot_config.instance_config !== "object" + || Array.isArray(bot_config.instance_config) + || !Number.isSafeInteger(bot_config.instance_config.bot_id) || bot_config.instance_config.bot_id <= 0 + || !Number.isSafeInteger(bot_config.instance_config.user_id) || bot_config.instance_config.user_id <= 0 + || !Array.isArray(bot_config.plugins) || !Array.isArray(bot_config.workflow) + || !bot_config.plugins.every(item => item && typeof item === "object" && !Array.isArray(item)) + || !bot_config.workflow.every(item => item && typeof item === "object" && !Array.isArray(item))) { + return validate_code_map.web.invalid_parameter; + } + const bot_id = bot_config.instance_config.bot_id; + if (bot_action_lock_map.get(bot_id) ?? false) { + return validate_code_map.bot_context.processing; + } + bot_action_lock_map.set(bot_id, true); + let new_context: BotContext | undefined; + let context_started = false; + try { + const temp_context = bot_context_map.get(bot_id); + if (temp_context !== undefined) { + if (temp_context.bot_status === "online" || temp_context.bot_status === "connecting" + || temp_context.bot_status === "reconnecting" || temp_context.bot_status === "ready") { + const result = await temp_context.connect(); + return result === validate_code_map.bot_context.ok ? validate_code_map.bot_context.context_created : result; + } else if (temp_context.bot_status === "disconnecting") { + return validate_code_map.bot_context.processing; + } else { + await temp_context.clear_this_context(); + remove_bot_context(bot_id, temp_context); + } + } + + const context = new_context = new BotContext(bot_config.instance_config); + context.bot_config.plugins = structuredClone(bot_config.plugins); + context.bot_config.workflow = structuredClone(bot_config.workflow); + context.on("status_update", (status) => { + void update_one_bot_status(main_server_socket, node_id, bot_id, bot_config.instance_config.user_id, status) + .catch(error => console.error(`Bot ${bot_id} status update failed`, error)); + }); + const origin_clean = context.clear_this_context.bind(context); + context.clear_this_context = async () => { + try { + await origin_clean(); + } finally { + remove_bot_context(bot_id, context); + } + }; + const validation_result = context.validate_config(bot_config.instance_config); + if (validation_result !== validate_code_map.bot_context.ok) { + console.error(`Bot ${bot_id} configuration validation failed, code: ${validation_result}`); + return validation_result; + } + await context.plugin_manager.init(bot_config.plugins); + await init_workflow(context, bot_config.workflow); + bot_context_map.set(bot_id, context); + notify_bot_context(bot_id); + const result = await context.connect(); + if (result !== validate_code_map.bot_context.ok + && result !== validate_code_map.bot_context.instance_connected + && result !== validate_code_map.bot_context.instance_connecting) { + console.error(`Bot ${bot_id} startup failed, code: ${result}`); + return result; + } + context_started = true; + return result === validate_code_map.bot_context.ok ? validate_code_map.bot_context.context_created : result; + } catch (error) { + console.error(`Bot ${bot_id} startup failed: ${error instanceof Error ? error.message : String(error)}`); + return validate_code_map.bot_context.error; + } finally { + if (new_context && !context_started) { + try { + await new_context.clear_this_context(); + } catch (error) { + console.error(`Bot ${bot_id} cleanup failed`, error); + } + } + bot_action_lock_map.delete(bot_id); + } +} + +export async function get_bot_context_status() : Promise<{ + bot_id: number; + status: bot_status; +}[]> { + return Array.from(bot_context_map).map(([bot_id, context]) => { + return { + bot_id, + status: context.bot_status, + } + }); +} + +export async function get_bot_context_status_by_ids(bot_id: number[]) : Promise<{ + bot_id: number; + status: bot_status; +}[]> { + return bot_id.map((id) => { + return { + bot_id: id, + status: bot_context_map.get(id)?.bot_status ?? "offline", + } + }); +} + +export async function stop_bot_context(bot_id: number) { + if (bot_action_lock_map.get(bot_id) ?? false) { + return validate_code_map.bot_context.processing; + } + bot_action_lock_map.set(bot_id, true); + try { + if (!bot_context_map.has(bot_id)) { + return validate_code_map.bot_context.instance_end_by_user; + } + const context = bot_context_map.get(bot_id); + if (context !== undefined) { + await context.clear_this_context(); + } + if (context) remove_bot_context(bot_id, context); + return validate_code_map.bot_context.instance_end_by_user; + } catch (error) { + return validate_code_map.bot_context.error; + } finally { + bot_action_lock_map.delete(bot_id); + } +} diff --git a/bot/plugins/error.ts b/bot/plugins/error.ts new file mode 100644 index 0000000..f597b38 --- /dev/null +++ b/bot/plugins/error.ts @@ -0,0 +1,14 @@ +export type PluginErrorReason = 'INVALID_CONFIG' | 'ALREADY_INSTALLED' | 'NOT_INSTALLED' + | 'MANAGER_DISPOSED' | 'LOAD_FAILED' | 'INIT_FAILED' | 'DISPOSE_FAILED'; + +export class PluginError extends Error { + constructor(readonly reason: PluginErrorReason) { + super({ + INVALID_CONFIG: 'Invalid plugin config', ALREADY_INSTALLED: 'Duplicate plugin', + NOT_INSTALLED: 'Plugin not installed', MANAGER_DISPOSED: 'Plugin manager disposed', + LOAD_FAILED: 'Plugin load failed', INIT_FAILED: 'Plugin initialization failed', + DISPOSE_FAILED: 'Plugin disposal failed', + }[reason]); + this.name = 'PluginError'; + } +} diff --git a/bot/plugins/init.ts b/bot/plugins/init.ts new file mode 100644 index 0000000..19ea71a --- /dev/null +++ b/bot/plugins/init.ts @@ -0,0 +1,346 @@ +import {readdir} from 'node:fs/promises'; +import {isDeepStrictEqual} from 'node:util'; +import type {BasePlugin, PluginContext, base_define, bot_config_plugin, plugin} from '../../types/bot/plugins/plugin.ts'; +import type {data_type} from '../../types/bot/instance/bot_runtime.ts'; +import {PluginError} from './error.ts'; +import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from '../../utils/plugin_validation.ts'; +export {PluginError} from './error.ts'; + +const valid_name = valid_plugin_name; +const data_types = new Set(['string', 'boolean', 'number', 'datetime', 'string-select', 'number-select', + 'string-multi-select', 'number-multi-select', 'string-array', 'number-array']); +const valid_i18n = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every(item => + item && typeof item.lang === 'string' && item.lang.length > 0 && typeof item.name === 'string' && typeof item.description === 'string'); + +function valid_value(value: unknown, type: data_type): boolean { + switch (type) { + case 'string': case 'string-select': return typeof value === 'string'; + case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value); + case 'boolean': return typeof value === 'boolean'; + case 'datetime': return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) && Number.isFinite(Date.parse(value)); + case 'string-array': case 'string-multi-select': return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string'); + case 'number-array': case 'number-multi-select': return Array.isArray(value) && Array.from(value).every(item => typeof item === 'number' && Number.isFinite(item)); + } +} + +function validate_item(item: bot_config_plugin) { + if (!plain_plugin_object(item) || !valid_name(item.name) || !valid_plugin_path(item.path) + || item.config !== undefined && !valid_plugin_config(item.config)) { + throw new PluginError('INVALID_CONFIG'); + } +} + +async function import_module(url: URL): Promise> { + url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`); + return import(url.href); +} + +function instantiate(module: Record, name: string): BasePlugin { + if (typeof module.init !== 'function') throw new Error(`Plugin ${name} must export an init class`); + const instance = new (module.init as new () => BasePlugin)(); + const template = instance.config_template; + if (instance.name !== name || template?.name !== name || !valid_i18n(template.i18n) + || typeof template.version !== 'string' || !template.version.length || !Array.isArray(template.config) + || typeof instance.init !== 'function' || typeof instance.get_status !== 'function' + || typeof instance.on_states_update !== 'function' || typeof instance.dispose !== 'function') { + throw new Error(`Invalid plugin metadata: ${name}`); + } + const names = new Set(); + for (const field of template.config) { + if (!field || !valid_name(field.name) || names.has(field.name) || !valid_i18n(field.i18n) + || typeof field.required !== 'boolean' || !data_types.has(field.type) || !Object.hasOwn(field, 'default') + || field.default != null && !valid_value(field.default, field.type)) throw new Error(`Invalid plugin config metadata: ${name}`); + names.add(field.name); + } + return instance; +} + +function resolve_config(template: plugin, input: Record): Record { + const names = new Set(template.config.map(field => field.name)); + for (const key of Object.keys(input)) if (!names.has(key)) throw new PluginError('INVALID_CONFIG'); + const config: Record = {}; + for (const field of template.config) { + const value = Object.hasOwn(input, field.name) ? input[field.name] : field.default; + if (value == null ? field.required : !valid_value(value, field.type)) throw new PluginError('INVALID_CONFIG'); + Object.defineProperty(config, field.name, {value: structuredClone(value), enumerable: true, writable: true, configurable: true}); + } + return config; +} + +export interface PluginSnapshot { + name: string; + path: string; + config: Record; + enabled: boolean; + lifecycle: 'initializing' | 'enabled' | 'disabling' | 'disabled' | 'error'; + /** Last observed internal state; disabled records retain the final disposal snapshot. */ + states: unknown; + error?: {code: string; message: string}; +} + +type Entry = {instance: BasePlugin; path: string; config: Record; unsubscribe?: () => void; quarantined?: boolean}; + +export class PluginManager { + private readonly entries = new Map(); + private readonly records = new Map(); + private readonly listeners = new Set<(event: PluginSnapshot) => void>(); + private tail: Promise = Promise.resolve(); + private disposed = false; + private dispose_task?: Promise; + + constructor(private readonly context: PluginContext) {} + + on_update(callback: (event: PluginSnapshot) => void): () => void { + this.check_active(); + const listener = (event: PluginSnapshot) => callback(event); + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + private publish(record: PluginSnapshot) { + this.records.set(record.name, structuredClone(record)); + for (const listener of Array.from(this.listeners)) { + if (!this.listeners.has(listener)) continue; + try { listener(structuredClone(record)); } catch {} + } + } + + private capture(entry: Entry): unknown { + try { return structuredClone(entry.instance.get_status()); } + catch { return null; } + } + + private transition(entry: Entry, lifecycle: PluginSnapshot['lifecycle']) { + this.publish({name: entry.instance.name, path: entry.path, config: entry.config, + enabled: lifecycle === 'enabled', lifecycle, states: this.capture(entry), + ...(entry.quarantined ? {error: {code: 'DISPOSE_FAILED', message: 'Plugin disposal failed'}} : {})}); + } + + private check_active() { + if (this.disposed) throw new PluginError('MANAGER_DISPOSED'); + } + + private enqueue(operation: () => Promise): Promise { + if (this.disposed) return Promise.reject(new PluginError('MANAGER_DISPOSED')); + const task = this.tail.then(async () => { this.check_active(); await operation(); }); + this.tail = task.catch(() => {}); + return task; + } + + private async prepare(item: bot_config_plugin): Promise { + validate_item(item); + if (this.entries.get(item.name)?.quarantined) throw new PluginError('DISPOSE_FAILED'); + let instance: BasePlugin; + try { + const module = await import_module(new URL(`./${item.path}/${item.name}.ts`, import.meta.url)); + this.check_active(); + instance = instantiate(module, item.name); + } catch (error) { + if (error instanceof PluginError) throw error; + throw new PluginError('LOAD_FAILED'); + } + return {instance, path: item.path, config: resolve_config(instance.config_template, (item.config ?? {}) as Record)}; + } + + private async install(entry: Entry) { + try { + this.check_active(); + this.transition(entry, 'initializing'); + entry.unsubscribe = entry.instance.on_states_update(() => { + const record = this.records.get(entry.instance.name); + if (record) this.transition(entry, record.lifecycle); + }); + this.check_active(); + await entry.instance.init(this.context, structuredClone(entry.config)); + this.check_active(); + this.entries.set(entry.instance.name, entry); + this.transition(entry, 'enabled'); + } catch (error) { + this.entries.delete(entry.instance.name); + this.transition(entry, 'disabling'); + let cleanup_failed = false; + try { await entry.instance.dispose(); } + catch { cleanup_failed = true; } + finally { + try { entry.unsubscribe?.(); } catch { cleanup_failed = true; } + if (cleanup_failed) { + entry.quarantined = true; + this.entries.set(entry.instance.name, entry); + } + this.transition(entry, 'error'); + } + if (cleanup_failed) throw new PluginError('DISPOSE_FAILED'); + throw error instanceof PluginError ? error : new PluginError('INIT_FAILED'); + } + } + + apply_desired(plugins: bot_config_plugin[]): Promise { + return this.enqueue(async () => { + if (!Array.isArray(plugins)) throw new PluginError('INVALID_CONFIG'); + const desired = new Map>(); + for (const item of plugins) { + validate_item(item); + if (desired.has(item.name)) throw new PluginError('ALREADY_INSTALLED'); + const current = this.entries.get(item.name); + if (current && current.path !== item.path) throw new PluginError('INVALID_CONFIG'); + desired.set(item.name, structuredClone(item)); + } + for (const entry of this.entries.values()) { + if (entry.quarantined) throw new PluginError('DISPOSE_FAILED'); + } + const candidates = new Map(); + // Constructors own no resources. Prepare every replacement before disposing live plugins. + for (const item of desired.values()) { + const current = this.entries.get(item.name); + if (current && isDeepStrictEqual(current.config, + resolve_config(current.instance.config_template, (item.config ?? {}) as Record))) continue; + try { candidates.set(item.name, await this.prepare(item)); } + catch (error) { + if (!current && error instanceof PluginError && error.reason === 'LOAD_FAILED') { + this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record, + enabled: false, lifecycle: 'error', states: null}); + } + throw error; + } + } + this.check_active(); + for (const name of Array.from(this.entries.keys())) { + if (!desired.has(name) || candidates.has(name)) await this.remove(name); + this.check_active(); + } + for (const candidate of candidates.values()) await this.install(candidate); + }); + } + + init(plugins: bot_config_plugin[]): Promise { + return this.enqueue(async () => { + const names = new Set(this.entries.keys()); + for (const item of plugins) { + validate_item(item); + if (names.has(item.name)) throw new PluginError('ALREADY_INSTALLED'); + names.add(item.name); + } + const installed: string[] = []; + try { + for (const item of plugins) { + let candidate: Entry; + try { candidate = await this.prepare(item); } + catch (error) { + if (error instanceof PluginError && error.reason === 'LOAD_FAILED') { + this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record, + enabled: false, lifecycle: 'error', states: null}); + } + throw error; + } + await this.install(candidate); + installed.push(item.name); + } + } catch (error) { + const errors = [error]; + for (const name of installed.reverse()) { + try { await this.remove(name); } catch (cleanup) { errors.push(cleanup); } + } + if (errors.length > 1) throw new PluginError('DISPOSE_FAILED'); + throw error; + } + }); + } + + get_plugin(name: string): PluginSnapshot | undefined { + const record = this.records.get(name); + return record && structuredClone(record); + } + + get_all_plugins(): PluginSnapshot[] { + return Array.from(this.records.values(), record => structuredClone(record)); + } + + inject(item: bot_config_plugin): Promise { return this.init([item]); } + + re_inject(item: bot_config_plugin): Promise { + return this.enqueue(async () => { + const candidate = await this.prepare(item); + await this.remove(item.name); + await this.install(candidate); + }); + } + + update_config(name: string, config: Record): Promise { + return this.enqueue(async () => { + if (!valid_name(name) || !valid_plugin_config(config)) throw new PluginError('INVALID_CONFIG'); + const previous = this.entries.get(name); + if (!previous || !this.records.get(name)?.enabled) throw new PluginError('NOT_INSTALLED'); + const candidate = await this.prepare({name, path: previous.path, config}); + this.check_active(); + await this.remove(name); + await this.install(candidate); + }); + } + + private async remove(name: string) { + const entry = this.entries.get(name); + if (!entry) return; + if (entry.quarantined) throw new PluginError('DISPOSE_FAILED'); + this.transition(entry, 'disabling'); + let failed = false; + try { await entry.instance.dispose(); } + catch { failed = true; } + finally { + try { entry.unsubscribe?.(); } catch { failed = true; } + if (failed) entry.quarantined = true; + else this.entries.delete(name); + this.transition(entry, failed ? 'error' : 'disabled'); + } + if (failed) throw new PluginError('DISPOSE_FAILED'); + } + + uninject(name: string): Promise { return this.enqueue(() => this.remove(name)); } + + dispose(): Promise { + if (this.dispose_task) return this.dispose_task; + this.disposed = true; + this.dispose_task = this.tail.then(async () => { + const errors: unknown[] = []; + for (const name of Array.from(this.entries.keys()).reverse()) { + try { await this.remove(name); } catch (error) { errors.push(error); } + } + this.listeners.clear(); + if (errors.length) throw new PluginError('DISPOSE_FAILED'); + }); + return this.dispose_task; + } +} + +export async function create_plugin_manager(context: PluginContext, plugins: bot_config_plugin[]): Promise { + const manager = new PluginManager(context); + try { await manager.init(plugins); return manager; } + catch (error) { + try { await manager.dispose(); } catch { throw new PluginError('DISPOSE_FAILED'); } + throw error; + } +} + +export async function get_plugin_list(): Promise<{path: string; name: string; path_config: base_define; plugin_config_template: plugin}[]> { + const result: Awaited> = []; + const directories = ['']; + const names = new Set(); + for (const path of directories) { + const directory = new URL(path ? `./${path}/` : './', import.meta.url); + const entries = (await readdir(directory, {withFileTypes: true})).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) if (entry.isDirectory() && valid_name(entry.name)) directories.push(path ? `${path}/${entry.name}` : entry.name); + if (!path || !entries.some(entry => entry.isFile() && entry.name === 'define.ts')) continue; + const {define} = await import_module(new URL('define.ts', directory)) as {define: base_define}; + if (define?.path !== path || !valid_i18n(define.i18n)) throw new Error(`Invalid plugin category: ${path}`); + for (const entry of entries) { + if (!entry.isFile() || entry.name === 'define.ts' || !/^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) continue; + const module = await import_module(new URL(entry.name, directory)); + if (!Object.hasOwn(module, 'init')) continue; + const name = entry.name.slice(0, -3); + const instance = instantiate(module, name); + if (names.has(name)) throw new Error(`Duplicate plugin: ${name}`); + names.add(name); + result.push({path, name, path_config: structuredClone(define), plugin_config_template: structuredClone(instance.config_template)}); + } + } + return result.sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name)); +} diff --git a/bot/plugins/runtime/auto_reconnect.ts b/bot/plugins/runtime/auto_reconnect.ts new file mode 100644 index 0000000..2e603a8 --- /dev/null +++ b/bot/plugins/runtime/auto_reconnect.ts @@ -0,0 +1,80 @@ +import type {bot_status} from "../../../types/bot/instance/bot_runtime"; +import {BasePlugin, type PluginContext, type plugin} from "../../../types/bot/plugins/plugin"; + +const config_template = { + name: 'auto_reconnect' as const, + i18n: [{lang: 'zh-CN', name: '自动重连', description: 'Bot 连接断开后自动重连'}], + version: '1.0.0', + config: [ + { + name: 'reconnect_delay' as const, + i18n: [{lang: 'zh-CN', name: '重连延时', description: '重连时的延时时间(单位:毫秒)'}], + required: false, + default: 1000, + type: 'number', + }, + { + name: 'reconnect_max_attempts' as const, + i18n: [{lang: 'zh-CN', name: '最大重连次数', description: '最大重连次数,默认 10 次'}], + required: false, + default: 10, + type: 'number', + }, + ], +} satisfies plugin; + +type auto_reconnect_status = {attempts: number}; +type auto_reconnect_config = { + [K in typeof config_template['config'][number]['name']]: number; +}; + +export class init extends BasePlugin { + readonly name = 'auto_reconnect'; + readonly config_template = config_template; + private cleanup?: () => void; + + constructor() { + super(); + this.set_states({attempts: 0}); + } + + async init(context: PluginContext, config: auto_reconnect_config): Promise { + const delay = Number.isInteger(config.reconnect_delay) && config.reconnect_delay >= 0 + && config.reconnect_delay <= 2_147_483_647 ? config.reconnect_delay : 1000; + const max_attempts = Number.isInteger(config.reconnect_max_attempts) && config.reconnect_max_attempts >= 0 + && config.reconnect_max_attempts < 100 ? config.reconnect_max_attempts : 10; + let uninjected = false; + + const lost = () => { + if (uninjected || this.get_status()!.attempts >= max_attempts) return; + void context.schedule_reconnect(delay).catch(error => { + if (context.logger) context.log([context.logger.error(`安排自动重连失败: ${String(error)}`)]); + }); + }; + const attempt = () => { this.set_states({attempts: this.get_status()!.attempts + 1}); }; + const reset = () => { + if (this.get_status()!.attempts !== 0) this.set_states({attempts: 0}); + }; + const updated = (value: bot_status) => { + if (value === 'online') reset(); + }; + this.cleanup = () => { + if (uninjected) return; + uninjected = true; + context.cancel_reconnect(); + context.off('connection_lost', lost); + context.off('reconnect_attempt', attempt); + context.off('connection_requested', reset); + context.off('status_update', updated); + }; + context.on('connection_lost', lost); + context.on('reconnect_attempt', attempt); + context.on('connection_requested', reset); + context.on('status_update', updated); + } + + async dispose(): Promise { + this.cleanup?.(); + this.cleanup = undefined; + } +} diff --git a/bot/plugins/runtime/define.ts b/bot/plugins/runtime/define.ts new file mode 100644 index 0000000..14d253b --- /dev/null +++ b/bot/plugins/runtime/define.ts @@ -0,0 +1,12 @@ +import {base_define} from "../../../types/bot/plugins/plugin.ts"; + +export const define: base_define = { + path: "runtime", + i18n: [ + { + lang: "zh-CN", + name: "运行时插件", + description: "Bot 运行时的插件,提供一些基础功能" + } + ] +} \ No newline at end of file diff --git a/bot/workflow/catalog.ts b/bot/workflow/catalog.ts new file mode 100644 index 0000000..3b43a50 --- /dev/null +++ b/bot/workflow/catalog.ts @@ -0,0 +1,58 @@ +import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts'; +import {node_registry, query_registry, start_registry} from './registry.ts'; +import {event_registry} from './node_action/event_node/registry.ts'; +import {describe, edge_input_output, result_output} from './metadata.ts'; +import {node_fields, policy_fields} from './common_template.ts'; +import {assert_json} from './runtime/value.ts'; +import {validate_fields, validate_i18n, validate_output} from './runtime/template.ts'; + +export function validate_workflow_catalog(catalog: WorkflowCatalog): void { + assert_json(catalog); + if (catalog.catalog_version !== 1) throw new Error('Invalid workflow catalog version'); + for (const group of [catalog.nodes, catalog.start_nodes, catalog.events, catalog.queries]) { + const names = new Set(); + for (const item of group) { + if (!/^[a-zA-Z0-9_-]{1,100}$/.test(item.name) || names.has(item.name) || !/^\d+\.\d+\.\d+$/.test(item.version)) { + throw new Error('Invalid or duplicate workflow catalog entry'); + } + names.add(item.name); validate_i18n(item.i18n); validate_fields(item.config); validate_output(item.output); + } + } + for (const node of catalog.nodes) { + const ports = new Set(); + for (const port of node.ports) { + if (!/^[a-zA-Z0-9_-]+$/.test(port.name) || ports.has(port.name)) throw new Error('Invalid workflow port'); + ports.add(port.name); validate_i18n(port.i18n); + } + if (!ports.has('failure')) throw new Error('Workflow node needs failure port'); + } + validate_fields(catalog.common.node_fields); validate_fields(catalog.common.policy_fields); + validate_output(catalog.common.result); validate_output(catalog.common.edge_input); + for (const reference of catalog.common.references) validate_i18n(reference.i18n); +} + +/** Pure catalog inspection: no Bot, actor, event subscriptions or user code execution. */ +export function get_workflow_catalog(options: {allow_code?: boolean} = {}): WorkflowCatalog { + const catalog: WorkflowCatalog = structuredClone({ + catalog_version: 1, + nodes: Object.values(node_registry).map(module => module.config_template), + start_nodes: Object.values(start_registry).map(module => module.config_template), + events: Object.values(event_registry).map(module => module.config_template), + queries: Object.values(query_registry).map(module => module.config_template), + common: { + node_fields, policy_fields, result: result_output, edge_input: edge_input_output, + references: [ + {root: 'trigger', i18n: describe('触发参数', '本次运行的事件、手动输入或定时触发数据。')}, + {root: 'vars', i18n: describe('运行变量', '当前分支变量,合并后包含无冲突的分支更新。')}, + {root: 'outputs', i18n: describe('上游节点结果', '沿已执行连线传入的节点结果;条件分支和 any 合并不保证所有祖先都存在。')}, + {root: 'last_result', i18n: describe('最近结果', '当前路径最近一个节点的执行结果和节点 ID。')}, + {root: 'inputs', i18n: describe('当前入线', '以连线 ID 为键的直接上游结果;all 合并包含全部匹配入线。')}, + ], + }, + }); + const code = catalog.nodes.find(node => node.name === 'code'); + if (code) code.availability = options.allow_code + ? {available: true} : {available: false, reason: 'CODE_EXECUTION_DISABLED'}; + validate_workflow_catalog(catalog); + return catalog; +} diff --git a/bot/workflow/common_template.ts b/bot/workflow/common_template.ts new file mode 100644 index 0000000..7606991 --- /dev/null +++ b/bot/workflow/common_template.ts @@ -0,0 +1,22 @@ +import type {WorkflowField} from '../../types/bot/workflow/metadata.ts'; +import {describe} from './metadata.ts'; + +export const node_fields: WorkflowField[] = [{ + name: 'timeout_ms', type: 'number', required: false, default: 30_000, integer: true, min: 1, max: 2_147_483_647, + i18n: describe('节点超时', '节点最长执行时间,单位毫秒;超时会请求取消并等待操作清理。'), +}]; +export const policy_fields: WorkflowField[] = [ + {name: 'max_concurrent_runs', type: 'number', required: false, default: 1, integer: true, min: 1, max: 256, + i18n: describe('并行实例上限', '同一工作流同时运行的最大实例数,还受 bot 总并行上限限制。')}, + {name: 'on_busy', type: 'select', required: false, default: 'skip', + i18n: describe('繁忙时策略', '达到 bot 总并行上限或本工作流实例上限时如何处理新触发。'), options: [ + {value: 'skip', i18n: describe('忽略', '直接忽略本次触发。')}, + {value: 'queue', i18n: describe('排队', '在等待队列未满时保存触发,空出名额后执行。')}, + ]}, + {name: 'max_queue', type: 'number', required: false, default: 10, integer: true, min: 0, max: 1000, + i18n: describe('等待队列上限', '本工作流最多保存的等待触发数量,0 表示不保存。')}, + {name: 'max_steps', type: 'number', required: false, default: 1000, integer: true, min: 1, max: 100000, + i18n: describe('最大执行步数', '一次运行内所有分支累计执行的节点数上限。')}, + {name: 'timeout_ms', type: 'number', required: false, default: 60_000, integer: true, min: 1, max: 2_147_483_647, + i18n: describe('运行超时', '从实际启动开始计算的整次运行时限,单位毫秒。')}, +]; diff --git a/bot/workflow/init.ts b/bot/workflow/init.ts new file mode 100644 index 0000000..424c87b --- /dev/null +++ b/bot/workflow/init.ts @@ -0,0 +1,18 @@ +import type {bot_instance_context} from '../instance/bot_instance_init.ts'; +import type {workflow_config} from '../../types/bot/workflow/workflow.ts'; +import {WorkflowManager} from './runtime/states_manager.ts'; + +export async function init_workflow(context: bot_instance_context, workflow: workflow_config[]): Promise { + if (context.workflow_manager) throw new Error('Workflow manager is already initialized'); + const manager = new WorkflowManager(context, workflow, {source_config_revision: context.bot_config.config_revision ?? 0}); + context.workflow_manager = manager; + try { manager.initialize(); } + catch (error) { + if (context.workflow_manager === manager) context.workflow_manager = null; + await manager.dispose(); + throw error; + } +} + +export {create_workflow_manager, WorkflowManager} from './runtime/states_manager.ts'; +export {get_workflow_catalog} from './catalog.ts'; diff --git a/bot/workflow/metadata.ts b/bot/workflow/metadata.ts new file mode 100644 index 0000000..8719b3d --- /dev/null +++ b/bot/workflow/metadata.ts @@ -0,0 +1,35 @@ +import type {WorkflowI18n, WorkflowOutputTemplate, WorkflowPortTemplate} from '../../types/bot/workflow/metadata.ts'; + +export const describe = (name: string, description: string): WorkflowI18n[] => [{lang: 'zh-CN', name, description}]; +export const output = (type: WorkflowOutputTemplate['type'], name: string, description: string): WorkflowOutputTemplate => + ({type, i18n: describe(name, description)}); +export const port = (name: string, label: string, description: string): WorkflowPortTemplate => + ({name, i18n: describe(label, description)}); +export const failure_port = port('failure', '失败', '参数解析、执行或节点超时失败时执行此分支。'); +export const success_port = port('success', '成功', '节点执行成功后继续执行。'); +export const result_output: WorkflowOutputTemplate = { + ...output('object', '执行结果', '节点执行状态、输出和可选错误。'), + properties: { + status: output('string', '执行状态', 'success 或 failure。'), + output: output('json', '节点输出', '具体结构由对应节点描述定义。'), + execution: {...output('number', '执行序号', 'outputs 中保存的本次运行节点执行序号。'), optional: true}, + node_id: {...output('string', '节点 ID', 'last_result 中提供的最近执行节点 ID。'), optional: true}, + error: {...output('object', '错误', '失败时可能提供的错误详情。'), optional: true, properties: { + code: output('string', '错误代码', '用于异常分流的稳定错误代码。'), + message: output('string', '错误说明', '本次失败的可读说明。'), + node_id: output('string', '错误节点', '产生错误的节点实例 ID。'), + }}, + }, +}; +export const edge_input_output: WorkflowOutputTemplate = { + ...output('object', '入线数据', '一条连线传递的来源与执行结果。'), + properties: { + source: output('string', '来源节点', '上游节点实例 ID。'), + port: output('string', '来源出口', '上游节点选择的出口名称。'), + result: result_output, + }, +}; +export const event_args_output: WorkflowOutputTemplate = { + ...output('array', '原始参数', '按事件参数顺序复制的安全 JSON 数据,不可序列化的参数为 null。'), + items: {...output('json', '参数值', '事件原始参数的 JSON 副本。'), nullable: true}, +}; diff --git a/bot/workflow/node/code_execute_node.ts b/bot/workflow/node/code_execute_node.ts new file mode 100644 index 0000000..40f89c5 --- /dev/null +++ b/bot/workflow/node/code_execute_node.ts @@ -0,0 +1,132 @@ +import {Worker} from 'node:worker_threads'; +import type {BranchState, NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import type {Json} from '../../../types/bot/workflow/workflow.ts'; +import {describe, failure_port, output, success_port} from '../metadata.ts'; +import {assert_json} from '../runtime/value.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'code', version: '1.0.0', + i18n: describe('执行代码', '执行 JavaScript 异步函数体,通过 state 读取状态副本,并返回 JSON 值;是否可用由宿主配置决定。'), + config: [ + {name: 'source', type: 'code', language: 'javascript', required: true, max_length: 100_000, + i18n: describe('JavaScript 代码', '最多 100000 个字符,可使用 await;通过 return 返回有效 JSON 值,修改 state 副本不会写回工作流状态。')}, + ], + output: output('json', '代码返回值', '代码通过 return 返回并经校验的 JSON 值。'), + ports: [success_port, failure_port], + availability: {available: false, reason: 'CODE_EXECUTION_DISABLED'}, +}; + +const worker_source = ` +const {parentPort, workerData} = require('node:worker_threads'); +function serialize_output(output) { + const ancestors = new Set(); + let entries = 0; + let bytes = 0; + function visit(value, depth) { + if (depth > 64 || ++entries > 100000) throw new Error('Code output exceeds JSON limits'); + if (value === null || typeof value === 'boolean') bytes += 5; + else if (typeof value === 'number' && Number.isFinite(value)) bytes += String(value).length; + else if (typeof value === 'string') { + if (value.length > 1048576) throw new Error('Code output exceeds JSON limits'); + bytes += Buffer.byteLength(JSON.stringify(value)); + } else if (typeof value === 'object') { + const array = Array.isArray(value); + if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + throw new Error('Code output must contain plain JSON objects'); + if (ancestors.has(value)) throw new Error('Code output contains a cycle'); + ancestors.add(value); + const keys = Reflect.ownKeys(value); + if (keys.length > 100000 || (array && value.length > 100000)) throw new Error('Code output exceeds JSON limits'); + let count = 0; + bytes += 2; + for (const key of keys) { + if (array && key === 'length') continue; + if (typeof key !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(key)) + throw new Error('Code output contains an unsafe key'); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !('value' in descriptor)) throw new Error('Code output must contain data properties'); + if (array) { + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= value.length) + throw new Error('Code output contains an invalid array'); + count++; + } else { + if (key.length > 1048576) throw new Error('Code output exceeds JSON limits'); + bytes += Buffer.byteLength(JSON.stringify(key)) + 1; + } + bytes++; + visit(descriptor.value, depth + 1); + } + if (array && count !== value.length) throw new Error('Code output contains an array hole'); + ancestors.delete(value); + } else throw new Error('Code output is not JSON'); + if (bytes > 1048576) throw new Error('Code output exceeds JSON limits'); + } + try { visit(output, 0); } + catch (error) { error.code = 'INVALID_JSON'; throw error; } + return JSON.stringify(output); +} +(async () => { + try { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const execute = new AsyncFunction('state', '"use strict";\\n' + workerData.source); + const output = await execute(JSON.parse(workerData.state)); + parentPort.postMessage({status: 'success', output: serialize_output(output)}); + } catch (error) { + parentPort.postMessage({status: 'failure', + code: typeof error?.code === 'string' ? error.code.slice(0, 256) : 'CODE_EXECUTION_FAILED', + message: (typeof error?.message === 'string' ? error.message : String(error)).slice(0, 4096)}); + } +})(); +`; + +export const execute: NodeHandler = async (node, state, signal, deps) => { + if (node.type !== 'code') throw new Error('Expected a code node'); + signal.throwIfAborted(); + assert_json(state); + const serialized = JSON.stringify(state); + let output: Json; + if (deps.execute_code) { + output = await deps.execute_code(node.config.source, JSON.parse(serialized) as BranchState, signal); + } else { + const worker = new Worker(worker_source, {eval: true, execArgv: [], + workerData: {source: node.config.source, state: serialized}, + stdout: true, stderr: true, + resourceLimits: {maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 8, stackSizeMb: 4}}); + // Capture and discard worker output instead of forwarding it to the bot process. + worker.stdout.resume(); + worker.stderr.resume(); + let on_abort: () => void; + try { + output = await new Promise((resolve, reject) => { + on_abort = () => reject(signal.reason); + worker.once('message', message => { + if (message?.status === 'success') { + try { + if (typeof message.output !== 'string' || Buffer.byteLength(message.output) > 1_048_576) { + throw Object.assign(new Error('Invalid code worker output'), {code: 'INVALID_JSON'}); + } + resolve(JSON.parse(message.output)); + } catch (error) { reject(error); } + } + else reject(Object.assign(new Error(typeof message?.message === 'string' + ? message.message : 'Code execution failed'), + {code: typeof message?.code === 'string' ? message.code : 'CODE_EXECUTION_FAILED'})); + }); + worker.once('error', reject); + worker.once('exit', code => reject(Object.assign( + new Error(`Code worker exited before returning a result (${code})`), {code: 'CODE_WORKER_EXITED'}))); + signal.addEventListener('abort', on_abort, {once: true}); + if (signal.aborted) on_abort(); + }); + } finally { + signal.removeEventListener('abort', on_abort!); + await worker.terminate(); + worker.removeAllListeners(); + } + } + signal.throwIfAborted(); + assert_json(output); + return {port: 'success', result: {status: 'success', output}}; +}; diff --git a/bot/workflow/node/condition_node.ts b/bot/workflow/node/condition_node.ts new file mode 100644 index 0000000..d8b61f8 --- /dev/null +++ b/bot/workflow/node/condition_node.ts @@ -0,0 +1,23 @@ +import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, failure_port, output, port} from '../metadata.ts'; +import {evaluate_condition} from '../runtime/value.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'condition', version: '1.0.0', + i18n: describe('条件判断', '计算条件表达式,根据布尔结果选择为真或为假分支。'), + config: [ + {name: 'condition', type: 'condition', required: true, + i18n: describe('判断条件', '支持多个条件全部满足(and/且)、任一满足(or/或)及嵌套分组、取反;每项可使用 eq、contains、gt、lt 比较字面量或状态引用。')}, + ], + output: {...output('boolean', '判断结果', '条件表达式的布尔结果,true 和 false 均为成功执行;计算失败时输出 null。'), nullable: true}, + ports: [port('true', '为真', '条件结果为 true 时执行。'), + port('false', '为假', '条件结果为 false 时执行。'), failure_port], +}; + +export const execute: NodeHandler = async (node, state, signal) => { + if (node.type !== 'condition') throw new Error('Expected a condition node'); + signal.throwIfAborted(); + const output = evaluate_condition(node.config.condition, state); + return {port: output ? 'true' : 'false', result: {status: 'success', output}}; +}; diff --git a/bot/workflow/node/error_node.ts b/bot/workflow/node/error_node.ts new file mode 100644 index 0000000..2365f76 --- /dev/null +++ b/bot/workflow/node/error_node.ts @@ -0,0 +1,32 @@ +import type {NodeHandler, NodeResult} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, failure_port, output, port} from '../metadata.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'error', version: '1.0.0', + i18n: describe('异常分流', '检查上一节点的执行状态和错误代码,保留原执行结果并选择分支。'), + config: [ + {name: 'codes', type: 'array', required: false, + i18n: describe('错误代码', '省略此字段时匹配全部失败结果;提供列表时仅匹配其中的错误代码,显式空列表不匹配任何错误。'), + item: {name: 'code', type: 'string', required: true, + i18n: describe('错误代码', '与上一节点 error.code 精确匹配的字符串。')}}, + ], + output: output('json', '上一节点输出', '透传上一节点的 output,缺失时为 null;失败分支保留原失败状态及可选错误。'), + ports: [port('normal', '正常', '上一节点不存在或状态不是 failure 时执行,节点状态为 success。'), + port('error', '匹配异常', '上一节点失败且匹配错误代码规则时执行,保留 failure 状态。'), + port('unmatched', '未匹配异常', '上一节点失败但不匹配错误代码规则时执行,保留 failure 状态。'), failure_port], +}; + +export const execute: NodeHandler = async (node, state, signal) => { + if (node.type !== 'error') throw new Error('Expected an error node'); + signal.throwIfAborted(); + const previous = state.last_result; + if (!previous || previous.status !== 'failure') { + return {port: 'normal', result: {status: 'success', output: previous?.output ?? null}}; + } + const result: NodeResult = {status: previous.status, output: previous.output}; + if (previous.error) result.error = {...previous.error}; + const matches = node.config.codes === undefined + || (previous.error !== undefined && node.config.codes.includes(previous.error.code)); + return {port: matches ? 'error' : 'unmatched', result}; +}; diff --git a/bot/workflow/node/function_node.ts b/bot/workflow/node/function_node.ts new file mode 100644 index 0000000..cbcf056 --- /dev/null +++ b/bot/workflow/node/function_node.ts @@ -0,0 +1,35 @@ +import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, failure_port, output, success_port} from '../metadata.ts'; +import {resolve_value} from '../runtime/value.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'function', version: '1.0.0', + i18n: describe('调用函数', '解析参数值后调用机器人函数,返回 true 时成功,否则进入失败分支。'), + provider: 'function', + config: [ + {name: 'path', type: 'string', required: true, + i18n: describe('函数路径', '函数所属目录,以 / 分隔;每段为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。')}, + {name: 'name', type: 'string', required: true, max_length: 100, + i18n: describe('函数名称', '函数标识,由 1 至 100 个字母、数字、下划线或连字符组成,禁止保留名称。')}, + {name: 'args', type: 'array', required: true, default: [], max: 100, + i18n: describe('函数参数', '按调用顺序提供参数,最多 100 项,每项可使用字面量或状态引用。'), + item: {name: 'argument', type: 'value_source', required: true, + i18n: describe('参数值', '解析字面量或状态引用,将得到的 JSON 值作为对应位置的函数参数。')}}, + ], + output: {...output('boolean', '函数返回值', '函数返回 true 时输出 true;返回其他值时输出 false,错误代码为 FUNCTION_RETURNED_FALSE;异常或超时输出 null。'), nullable: true}, + ports: [success_port, failure_port], +}; + +export const execute: NodeHandler = async (node, state, signal, deps) => { + if (node.type !== 'function') throw new Error('Expected a function node'); + signal.throwIfAborted(); + const output = await deps.run_function(node.config.path, node.config.name, + node.config.args.map(source => resolve_value(source, state)), signal); + signal.throwIfAborted(); + if (output !== true) { + return {port: 'failure', result: {status: 'failure', output: false, + error: {code: 'FUNCTION_RETURNED_FALSE', message: 'Function returned false', node_id: node.id}}}; + } + return {port: 'success', result: {status: 'success', output}}; +}; diff --git a/bot/workflow/node/goto_node.ts b/bot/workflow/node/goto_node.ts new file mode 100644 index 0000000..65cb671 --- /dev/null +++ b/bot/workflow/node/goto_node.ts @@ -0,0 +1,37 @@ +import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, failure_port, output, port} from '../metadata.ts'; +import {assert_json} from '../runtime/value.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'goto', version: '1.0.0', + i18n: describe('重试跳转', '按当前节点的重试计数限制跳转次数,重试目标由 retry 出口连线指定。'), + config: [ + {name: 'max_retries', type: 'number', required: true, default: 2, integer: true, min: 0, max: 10_000, + i18n: describe('最大重试次数', '允许经过 retry 出口的次数;0 表示直接进入次数耗尽分支,默认允许重试 2 次。')}, + {name: 'delay_ms', type: 'number', required: true, default: 0, integer: true, min: 0, max: 2_147_483_647, + i18n: describe('重试间隔', '每次重试跳转前等待的毫秒数,0 表示不增加等待时长。')}, + ], + output: output('json', '上一节点输出', '重试或次数耗尽时均透传上一节点的 output,缺失时为 null。'), + ports: [port('retry', '重试', '未达到最大重试次数时增加计数、等待指定间隔,再以 success 状态跳转;必须连接重试目标。'), + port('exhausted', '次数耗尽', '已达到最大重试次数时以 failure 状态执行,错误代码为 RETRY_EXHAUSTED。'), failure_port], +}; + +export const execute: NodeHandler = async (node, state, signal, deps) => { + if (node.type !== 'goto') throw new Error('Expected a goto node'); + signal.throwIfAborted(); + const count = Object.hasOwn(state.retry_counts, node.id) ? state.retry_counts[node.id] : 0; + if (!Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(node.config.max_retries) + || node.config.max_retries < 0 || !Number.isFinite(node.config.delay_ms) || node.config.delay_ms < 0) { + throw Object.assign(new Error('Invalid retry configuration or count'), {code: 'INVALID_RETRY'}); + } + if (count >= node.config.max_retries) { + return {port: 'exhausted', result: {status: 'failure', output: state.last_result?.output ?? null, + error: {code: 'RETRY_EXHAUSTED', message: 'Retry limit exhausted', node_id: node.id}}}; + } + const retry_counts = {[node.id]: count + 1}; + assert_json(retry_counts); + await deps.sleep('milliseconds', node.config.delay_ms, signal); + signal.throwIfAborted(); + return {port: 'retry', result: {status: 'success', output: state.last_result?.output ?? null}, retry_counts}; +}; diff --git a/bot/workflow/node/log_node.ts b/bot/workflow/node/log_node.ts new file mode 100644 index 0000000..440d060 --- /dev/null +++ b/bot/workflow/node/log_node.ts @@ -0,0 +1,31 @@ +import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, failure_port, output, success_port} from '../metadata.ts'; +import {resolve_value} from '../runtime/value.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'log', version: '1.0.0', + i18n: describe('记录日志', '解析消息值并按指定级别记录日志,同时输出解析后的原始值。'), + config: [ + {name: 'level', type: 'select', required: true, default: 'info', + i18n: describe('日志级别', '选择日志的记录级别;错误级别本身不会使节点执行失败。'), + options: [ + {value: 'info', i18n: describe('信息', '记录一般运行信息。')}, + {value: 'warning', i18n: describe('警告', '记录需要关注的情况。')}, + {value: 'error', i18n: describe('错误', '记录错误信息,仍按正常日志节点继续执行。')}, + ]}, + {name: 'message', type: 'value_source', required: true, + i18n: describe('日志消息', '使用字面量或状态引用;字符串直接记录,其他 JSON 值序列化后记录。')}, + ], + output: output('json', '消息原值', '解析 message 得到的原始 JSON 值,非字符串值不会因日志序列化而改变输出类型。'), + ports: [success_port, failure_port], +}; + +export const execute: NodeHandler = async (node, state, signal, deps) => { + if (node.type !== 'log') throw new Error('Expected a log node'); + signal.throwIfAborted(); + const output = resolve_value(node.config.message, state); + deps.log(node.config.level, typeof output === 'string' ? output : JSON.stringify(output)); + signal.throwIfAborted(); + return {port: 'success', result: {status: 'success', output}}; +}; diff --git a/bot/workflow/node/merge_node.ts b/bot/workflow/node/merge_node.ts new file mode 100644 index 0000000..52e6689 --- /dev/null +++ b/bot/workflow/node/merge_node.ts @@ -0,0 +1,25 @@ +import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, edge_input_output, failure_port, output, success_port} from '../metadata.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'merge', version: '1.0.0', + i18n: describe('分支合流', '按全部入线汇合或每次到达独立继续的方式处理分支,输出本次入线数据。'), + config: [ + {name: 'mode', type: 'select', required: true, default: 'all', + i18n: describe('合流方式', '选择是否等待每一条入线;全部入线模式至少需要两条入线且不能作为入口节点。'), + options: [ + {value: 'all', i18n: describe('全部入线', '等待同一轮次的每一条入线均到达后合并状态并执行一次;互斥分支可能导致入线无法到齐。')}, + {value: 'any', i18n: describe('每次到达', '每条入线每次到达都独立执行,不等待其他入线,也不会只执行首次到达。')}, + ]}, + ], + output: {...output('object', '入线数据', '以连线 ID 为键的 inputs 对象;all 包含本轮全部入线,any 包含本次到达的入线。'), + nullable: true, additional_properties: edge_input_output}, + ports: [success_port, failure_port], +}; + +export const execute: NodeHandler = async (node, state, signal) => { + if (node.type !== 'merge') throw new Error('Expected a merge node'); + signal.throwIfAborted(); + return {port: 'success', result: {status: 'success', output: state.inputs}}; +}; diff --git a/bot/workflow/node/query_node.ts b/bot/workflow/node/query_node.ts new file mode 100644 index 0000000..210d638 --- /dev/null +++ b/bot/workflow/node/query_node.ts @@ -0,0 +1,40 @@ +import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, failure_port, output, success_port} from '../metadata.ts'; +import {assert_json, resolve_value} from '../runtime/value.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'query', version: '1.0.0', + i18n: describe('查询数据', '解析命名参数后调用查询提供方,返回 JSON 数据,并可将结果保存到工作流变量。'), + provider: 'query', + config: [ + {name: 'source', type: 'string', required: true, provider: 'query', max_length: 100, + i18n: describe('查询来源', '选择查询提供方,标识由 1 至 100 个字母、数字、下划线或连字符组成,禁止保留名称。')}, + {name: 'args', type: 'map', required: true, default: {}, + i18n: describe('查询参数', '按名称提供参数;键为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。'), + item: {name: 'argument', type: 'value_source', required: true, + i18n: describe('参数值', '解析字面量或状态引用,将得到的 JSON 值传给对应名称的查询参数。')}}, + {name: 'save_as', type: 'string', required: false, max_length: 100, + i18n: describe('保存变量', '可选,将查询输出保存到 vars 中的此名称;名称为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。')}, + ], + output: output('json', '查询结果', '查询提供方返回的原始 JSON 值,与可选保存到 vars 的值相同。'), + ports: [success_port, failure_port], +}; + +export const execute: NodeHandler = async (node, state, signal, deps) => { + if (node.type !== 'query') throw new Error('Expected a query node'); + signal.throwIfAborted(); + const args = Object.fromEntries(Object.entries(node.config.args) + .map(([key, source]) => [key, resolve_value(source, state)])); + assert_json(args); + const output = await deps.query(node.config.source, args, signal); + signal.throwIfAborted(); + assert_json(output); + const result = {status: 'success' as const, output}; + if (node.config.save_as !== undefined) { + const vars = {[node.config.save_as]: output}; + assert_json(vars); + return {port: 'success', result, vars}; + } + return {port: 'success', result}; +}; diff --git a/bot/workflow/node/sleep_node.ts b/bot/workflow/node/sleep_node.ts new file mode 100644 index 0000000..7f6bc71 --- /dev/null +++ b/bot/workflow/node/sleep_node.ts @@ -0,0 +1,28 @@ +import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, failure_port, output, success_port} from '../metadata.ts'; + +export const config_template: WorkflowNodeTemplate = { + name: 'sleep', version: '1.0.0', + i18n: describe('等待', '按毫秒或游戏刻等待指定时长,再继续执行。'), + config: [ + {name: 'mode', type: 'select', required: true, default: 'milliseconds', + i18n: describe('计时方式', '选择等待时长的单位。'), + options: [ + {value: 'milliseconds', i18n: describe('毫秒', '按毫秒等待,1000 毫秒为 1 秒。')}, + {value: 'ticks', i18n: describe('游戏刻', '按游戏刻等待,实际时长取决于游戏刻推进速度。')}, + ]}, + {name: 'value', type: 'number', required: true, default: 1000, integer: true, min: 0, max: 2_147_483_647, + i18n: describe('等待时长', '使用所选单位的非负整数时长,0 表示不增加等待时长。')}, + ], + output: output('null', '等待结果', '等待完成后输出 null。'), + ports: [success_port, failure_port], +}; + +export const execute: NodeHandler = async (node, _state, signal, deps) => { + if (node.type !== 'sleep') throw new Error('Expected a sleep node'); + signal.throwIfAborted(); + await deps.sleep(node.config.mode, node.config.value, signal); + signal.throwIfAborted(); + return {port: 'success', result: {status: 'success', output: null}}; +}; diff --git a/bot/workflow/node_action/event_node/chat.ts b/bot/workflow/node_action/event_node/chat.ts new file mode 100644 index 0000000..1668558 --- /dev/null +++ b/bot/workflow/node_action/event_node/chat.ts @@ -0,0 +1,27 @@ +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts'; +import {describe, output, event_args_output} from '../../metadata.ts'; +import {normalize_custom_event} from './custom.ts'; + +export const config_template: WorkflowEventTemplate = { + name: 'chat', version: '1.0.0', readiness: 'spawned', + i18n: describe('聊天事件', '接收玩家公开聊天,提供用户名、消息内容及安全的原始参数。'), + config: [], + output: { + ...output('object', '聊天数据', '从聊天事件前两个字符串参数提取的数据。'), + properties: { + args: event_args_output, + event: output('string', '事件名称', '固定为 chat。'), + username: output('string', '用户名', '发送公开聊天消息的玩家名称。'), + message: output('string', '消息内容', '玩家发送的聊天文本。'), + }, + }, +}; + +export function normalize(args: unknown[]): Record | undefined { + if (args.length > 32 || typeof args[0] !== 'string' || typeof args[1] !== 'string') return; + const original = normalize_custom_event('chat', args); + if (!original) return; + const trigger = {...original, username: args[0], message: args[1]}; + if (Buffer.byteLength(JSON.stringify(trigger)) <= 65_536) return trigger; +} diff --git a/bot/workflow/node_action/event_node/custom.ts b/bot/workflow/node_action/event_node/custom.ts new file mode 100644 index 0000000..de7711f --- /dev/null +++ b/bot/workflow/node_action/event_node/custom.ts @@ -0,0 +1,35 @@ +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts'; +import {assert_json} from '../../runtime/value.ts'; +import {describe, output} from '../../metadata.ts'; + +export const config_template: WorkflowEventTemplate = { + name: 'custom', version: '1.0.0', readiness: 'spawned', custom: true, + i18n: describe('自定义事件', '监听自定义名称的事件,以有界 JSON 参数数组输出。any 事件保留原始事件名称作为首个参数。'), + config: [{name: 'event', type: 'string', required: true, + i18n: describe('事件名称', '填写要监听的事件名称;workflow_update 及 any 转发的 workflow_update 不触发工作流。')}], + output: { + ...output('object', '自定义事件数据', '最多 32 个参数,输出 JSON 最大 64 KiB;无法安全转换的参数替换为 null。'), + properties: { + event: output('string', '事件名称', '订阅的事件名称;订阅 any 时为 any。'), + args: {...output('array', '事件参数', '按原始顺序复制的 JSON 参数;非 JSON 对象或不安全值为 null。'), + items: {...output('json', '参数值', '事件参数的 JSON 副本,无法安全转换时为 null。'), nullable: true}}, + }, + }, +}; + +export function normalize_custom_event(event: string, args: unknown[]): Record | undefined { + try { + if (args.length > 32) return; + const trigger = {event, args: args.map(value => { + try { assert_json(value); return structuredClone(value); } + catch { return null; } + })}; + assert_json(trigger); + if (Buffer.byteLength(JSON.stringify(trigger)) <= 65_536) return trigger; + } catch { /* Unsupported or oversized payloads skip this occurrence. */ } +} + +export function normalize(args: unknown[]): Record | undefined { + return normalize_custom_event('custom', args); +} diff --git a/bot/workflow/node_action/event_node/health.ts b/bot/workflow/node_action/event_node/health.ts new file mode 100644 index 0000000..e221559 --- /dev/null +++ b/bot/workflow/node_action/event_node/health.ts @@ -0,0 +1,18 @@ +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts'; +import {describe, output, event_args_output} from '../../metadata.ts'; +import {normalize_custom_event} from './custom.ts'; + +export const config_template: WorkflowEventTemplate = { + name: 'health', version: '1.0.0', readiness: 'spawned', + i18n: describe('生命状态事件', '生命状态更新时发出通知。生命值和饥饿值需通过 info 查询单独读取。'), + config: [], + output: { + ...output('object', '生命状态通知', '包含事件名称和原始参数,不包含生命值或饥饿值快照。'), + properties: {event: output('string', '事件名称', '固定为 health。'), args: event_args_output}, + }, +}; + +export function normalize(args: unknown[] = []): Record | undefined { + return normalize_custom_event('health', args); +} diff --git a/bot/workflow/node_action/event_node/message.ts b/bot/workflow/node_action/event_node/message.ts new file mode 100644 index 0000000..e81f4dc --- /dev/null +++ b/bot/workflow/node_action/event_node/message.ts @@ -0,0 +1,47 @@ +import {MinecraftJsonParser} from '../../../../utils/message_json_parser.ts'; +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts'; +import {assert_json} from '../../runtime/value.ts'; +import {describe, output} from '../../metadata.ts'; + +export const config_template: WorkflowEventTemplate = { + name: 'message', version: '1.0.0', readiness: 'spawned', + i18n: describe('消息事件', '接收聊天或系统消息,将原始文本组件解析为用户名、消息和完整纯文本。'), + config: [], + output: { + ...output('object', '消息数据', '解析后的消息内容和协议附加信息。'), + properties: { + event: output('string', '事件名称', '固定为 message。'), + username: output('string', '用户名', '从消息文本提取的用户名;无法识别时为空字符串。'), + message: output('string', '消息内容', '去除已识别用户名后的消息;无法识别时为裁剪首尾空白的纯文本。'), + plainText: output('string', '完整纯文本', '展开翻译和文本组件后得到的完整消息文本。'), + position: output('string', '显示位置', '协议提供的字符串位置;缺失或类型不符时为空字符串。'), + sender: {...output('string', '发送者', '协议提供的发送者标识;缺失或类型不符时为 null。'), nullable: true}, + verified: {...output('boolean', '签名验证', '协议提供的验证结果;缺失或类型不符时为 null。'), nullable: true}, + }, + }, +}; + +export function parse_message_trigger(args: unknown[]): Record | undefined { + try { + if (args.length > 32) return; + let component = args[0]; + if (component && typeof component === 'object' && !Array.isArray(component)) { + const json = Object.getOwnPropertyDescriptor(component, 'json'); + if (json && 'value' in json) component = json.value; + } + assert_json(component); + if (Buffer.byteLength(JSON.stringify(component)) > 65_536) return; + const parsed = MinecraftJsonParser.parse(component); + const trigger: Record = { + event: 'message', username: parsed.username, message: parsed.message, plainText: parsed.plainText, + position: typeof args[1] === 'string' ? args[1] : '', + sender: typeof args[2] === 'string' ? args[2] : null, + verified: typeof args[3] === 'boolean' ? args[3] : null, + }; + assert_json(trigger); + if (Buffer.byteLength(JSON.stringify(trigger)) <= 65_536) return trigger; + } catch { /* Malformed or non-JSON message payloads are not workflow inputs. */ } +} + +export const normalize = parse_message_trigger; diff --git a/bot/workflow/node_action/event_node/registry.ts b/bot/workflow/node_action/event_node/registry.ts new file mode 100644 index 0000000..42de33a --- /dev/null +++ b/bot/workflow/node_action/event_node/registry.ts @@ -0,0 +1,19 @@ +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowEventDefinition} from '../../../../types/bot/workflow/metadata.ts'; +import * as message from './message.ts'; +import * as chat from './chat.ts'; +import * as whisper from './whisper.ts'; +import * as spawn from './spawn.ts'; +import * as health from './health.ts'; +import * as custom from './custom.ts'; + +export const event_registry = {message, chat, whisper, spawn, health, custom} satisfies Record; + +export function normalize_event(event: string, args: unknown[]): Record | undefined { + if (args.length > 32) return; + if (Object.hasOwn(event_registry, event)) { + const definition: WorkflowEventDefinition = event_registry[event as keyof typeof event_registry]; + return definition.normalize(args); + } + return custom.normalize_custom_event(event, args); +} diff --git a/bot/workflow/node_action/event_node/spawn.ts b/bot/workflow/node_action/event_node/spawn.ts new file mode 100644 index 0000000..91a4d69 --- /dev/null +++ b/bot/workflow/node_action/event_node/spawn.ts @@ -0,0 +1,18 @@ +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts'; +import {describe, output, event_args_output} from '../../metadata.ts'; +import {normalize_custom_event} from './custom.ts'; + +export const config_template: WorkflowEventTemplate = { + name: 'spawn', version: '1.0.0', readiness: 'spawned', + i18n: describe('出生事件', '通知机器人已出生;输出不包含机器人实例。'), + config: [], + output: { + ...output('object', '出生通知', '机器人出生事件的通知数据。'), + properties: {event: output('string', '事件名称', '固定为 spawn。'), args: event_args_output}, + }, +}; + +export function normalize(args: unknown[] = []): Record | undefined { + return normalize_custom_event('spawn', args); +} diff --git a/bot/workflow/node_action/event_node/whisper.ts b/bot/workflow/node_action/event_node/whisper.ts new file mode 100644 index 0000000..4f1071c --- /dev/null +++ b/bot/workflow/node_action/event_node/whisper.ts @@ -0,0 +1,27 @@ +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts'; +import {describe, output, event_args_output} from '../../metadata.ts'; +import {normalize_custom_event} from './custom.ts'; + +export const config_template: WorkflowEventTemplate = { + name: 'whisper', version: '1.0.0', readiness: 'spawned', + i18n: describe('私聊事件', '接收玩家私聊,提供用户名、消息内容及安全的原始参数。'), + config: [], + output: { + ...output('object', '私聊数据', '从私聊事件前两个字符串参数提取的数据。'), + properties: { + args: event_args_output, + event: output('string', '事件名称', '固定为 whisper。'), + username: output('string', '用户名', '发送私聊消息的玩家名称。'), + message: output('string', '消息内容', '玩家发送的私聊文本。'), + }, + }, +}; + +export function normalize(args: unknown[]): Record | undefined { + if (args.length > 32 || typeof args[0] !== 'string' || typeof args[1] !== 'string') return; + const original = normalize_custom_event('whisper', args); + if (!original) return; + const trigger = {...original, username: args[0], message: args[1]}; + if (Buffer.byteLength(JSON.stringify(trigger)) <= 65_536) return trigger; +} diff --git a/bot/workflow/node_action/query_node/info.ts b/bot/workflow/node_action/query_node/info.ts new file mode 100644 index 0000000..9d35dca --- /dev/null +++ b/bot/workflow/node_action/query_node/info.ts @@ -0,0 +1,29 @@ +import type {Json} from '../../../../types/bot/workflow/workflow.ts'; +import type {WorkflowQueryTemplate} from '../../../../types/bot/workflow/metadata.ts'; +import {assert_json} from '../../runtime/value.ts'; +import {describe, output} from '../../metadata.ts'; + +export const config_template: WorkflowQueryTemplate = { + name: 'info', version: '1.0.0', provider: 'info', + i18n: describe('信息查询', '从 InfoManager 读取指定名称的信息快照,仅返回快照的 value。'), + config: [{name: 'name', type: 'string', required: true, provider: 'info', min_length: 1, + i18n: describe('信息名称', '选择或填写 InfoManager 中的信息名称;名称必须是非空字符串。')}], + output: output('json', '信息值', 'InfoManager.get_info(name).value 的实际 JSON 值,类型和结构取决于所选信息;不包含快照名称或翻译元数据。'), +}; + +export function query_info(context: { + info_manager: {get_info(name: string): {value: unknown} | undefined} | null; +}, args: Record): Json { + if (typeof args.name !== 'string' || args.name.length === 0) { + throw Object.assign(new Error('Info query requires a name'), {code: 'INVALID_QUERY_ARGUMENTS'}); + } + if (!context.info_manager) { + throw Object.assign(new Error('Info manager is unavailable'), {code: 'INFO_UNAVAILABLE'}); + } + const snapshot = context.info_manager.get_info(args.name); + if (!snapshot) { + throw Object.assign(new Error(`Info was not found: ${args.name}`), {code: 'INFO_NOT_FOUND'}); + } + assert_json(snapshot.value); + return snapshot.value; +} diff --git a/bot/workflow/registry.ts b/bot/workflow/registry.ts new file mode 100644 index 0000000..b7a2e59 --- /dev/null +++ b/bot/workflow/registry.ts @@ -0,0 +1,29 @@ +import * as function_node from './node/function_node.ts'; +import * as query_node from './node/query_node.ts'; +import * as condition_node from './node/condition_node.ts'; +import * as sleep_node from './node/sleep_node.ts'; +import * as error_node from './node/error_node.ts'; +import * as goto_node from './node/goto_node.ts'; +import * as merge_node from './node/merge_node.ts'; +import * as log_node from './node/log_node.ts'; +import * as code_node from './node/code_execute_node.ts'; +import * as common_start from './start_node/common_node.ts'; +import * as event_start from './start_node/event_node.ts'; +import * as timer_start from './start_node/timer_node.ts'; +import * as info_query from './node_action/query_node/info.ts'; +import type {WorkflowNode, WorkflowStart} from '../../types/bot/workflow/workflow.ts'; +import type {NodeHandler} from '../../types/bot/workflow/runtime.ts'; +import type {WorkflowNodeTemplate, WorkflowQueryTemplate, WorkflowStartTemplate} from '../../types/bot/workflow/metadata.ts'; + +export const node_registry = { + function: function_node, query: query_node, condition: condition_node, sleep: sleep_node, + error: error_node, goto: goto_node, merge: merge_node, log: log_node, code: code_node, +} satisfies Record; + +export const start_registry = { + common: common_start, event: event_start, timer: timer_start, +} satisfies Record; + +export const query_registry = { + info: info_query, +} satisfies Record; diff --git a/bot/workflow/runtime/compile.ts b/bot/workflow/runtime/compile.ts new file mode 100644 index 0000000..3dbe728 --- /dev/null +++ b/bot/workflow/runtime/compile.ts @@ -0,0 +1,73 @@ +import type {CompiledWorkflow} from '../../../types/bot/workflow/runtime.ts'; +import {validate_workflow} from './validate.ts'; +import {assert_json} from './value.ts'; +import {resolve_fields} from './template.ts'; +import {node_registry, start_registry} from '../registry.ts'; +import {node_fields, policy_fields} from '../common_template.ts'; +import type {Json} from '../../../types/bot/workflow/workflow.ts'; + +export function compile_workflow(value: unknown): CompiledWorkflow { + assert_json(value); + const normalized = structuredClone(value) as Record; + if (normalized && !Array.isArray(normalized) && typeof normalized === 'object') { + const start = normalized.start; + if (start && typeof start.type === 'string' && Object.hasOwn(start_registry, start.type)) { + const module = start_registry[start.type as keyof typeof start_registry]; + normalized.start = resolve_fields(module.config_template.config, start); + } + if (normalized.policy !== null && (normalized.policy === undefined || typeof normalized.policy === 'object' && !Array.isArray(normalized.policy))) { + normalized.policy = resolve_fields(policy_fields, normalized.policy ?? {}); + } + if (Array.isArray(normalized.nodes)) normalized.nodes = normalized.nodes.map((node: Record) => { + if (!node || typeof node !== 'object' || Array.isArray(node) || typeof node.type !== 'string' || !Object.hasOwn(node_registry, node.type)) return node; + const fields = node_registry[node.type as keyof typeof node_registry].config_template.config; + const result = resolve_fields(node_fields, node as Record); + if (node.config && typeof node.config === 'object' && !Array.isArray(node.config)) result.config = resolve_fields(fields, node.config); + return result; + }); + } + validate_workflow(normalized); + const definition = normalized; + const graph: CompiledWorkflow = { + definition, + nodes: new Map(definition.nodes.map(node => [node.id, node])), + outgoing: new Map(), incoming: new Map(), join_owners: new Map(), + }; + for (const edge of definition.edges) { + graph.outgoing.set(edge.source, [...graph.outgoing.get(edge.source) ?? [], edge]); + graph.incoming.set(edge.target, [...graph.incoming.get(edge.target) ?? [], edge]); + } + graph.join_owners = find_join_owners(graph); + return graph; +} + +/** The nearest dominating fork owns each join, including joins downstream of other joins. */ +export function find_join_owners(graph: CompiledWorkflow): Map { + const ids = [...graph.nodes.keys()]; + const dominators = new Map(ids.map(id => [id, new Set(id === graph.definition.entry ? [id] : ids)])); + let changed = true; + while (changed) { + changed = false; + for (const id of ids) { + if (id === graph.definition.entry) continue; + const predecessors = (graph.incoming.get(id) ?? []).map(edge => edge.source); + const common = new Set(dominators.get(predecessors[0]) ?? []); + for (const predecessor of predecessors.slice(1)) { + for (const candidate of common) if (!dominators.get(predecessor)!.has(candidate)) common.delete(candidate); + } + common.add(id); + const previous = dominators.get(id)!; + if (common.size !== previous.size || [...common].some(item => !previous.has(item))) { + dominators.set(id, common); changed = true; + } + } + } + const owners = new Map(); + for (const node of graph.nodes.values()) { + if (node.type !== 'merge' || node.config.mode !== 'all') continue; + const candidates = [...dominators.get(node.id)!].filter(id => id !== node.id && (graph.outgoing.get(id)?.length ?? 0) > 1); + candidates.sort((a, b) => dominators.get(b)!.size - dominators.get(a)!.size); + if (candidates[0]) owners.set(node.id, candidates[0]); + } + return owners; +} diff --git a/bot/workflow/runtime/execute.ts b/bot/workflow/runtime/execute.ts new file mode 100644 index 0000000..6247f8e --- /dev/null +++ b/bot/workflow/runtime/execute.ts @@ -0,0 +1,65 @@ +import {setImmediate} from 'node:timers/promises'; +import {performance} from 'node:perf_hooks'; +import type {BranchState, NodeReturn, WorkflowDependencies} from '../../../types/bot/workflow/runtime.ts'; +import type {WorkflowNode} from '../../../types/bot/workflow/workflow.ts'; +import {node_registry} from '../registry.ts'; +import {assert_json} from './value.ts'; + + +export async function execute_node(node: WorkflowNode, state: BranchState, signal: AbortSignal, + deps: WorkflowDependencies): Promise { + const controller = new AbortController(); + const combined = AbortSignal.any([signal, controller.signal]); + const timeout_ms = node.timeout_ms ?? 30_000; + const timeout_error = Object.assign(new Error('Node execution timed out'), {code: 'NODE_TIMEOUT'}); + const started_at = performance.now(); + const valid_timeout = Number.isFinite(timeout_ms) && timeout_ms > 0 && timeout_ms <= 2_147_483_647; + let timer: ReturnType | undefined; + try { + if (valid_timeout) timer = setTimeout(() => controller.abort(timeout_error), timeout_ms); + await setImmediate(); + signal.throwIfAborted(); + if (!valid_timeout) { + throw Object.assign(new Error('Invalid node timeout'), {code: 'INVALID_NODE_TIMEOUT'}); + } + combined.throwIfAborted(); + if (!Object.hasOwn(node_registry, node.type)) { + throw Object.assign(new Error('Unknown workflow node type'), {code: 'UNKNOWN_NODE_TYPE'}); + } + // Await the handler itself so cancellation never leaves an operation running in the background. + const returned = await node_registry[node.type].execute(node, state, combined, deps); + signal.throwIfAborted(); + if (performance.now() - started_at >= timeout_ms) controller.abort(timeout_error); + combined.throwIfAborted(); + assert_json(returned); + if (typeof returned.port !== 'string' || returned.port.length === 0 || !returned.result + || !['success', 'failure'].includes(returned.result.status) + || !Object.hasOwn(returned.result, 'output') + || (returned.result.error !== undefined && (typeof returned.result.error.code !== 'string' + || typeof returned.result.error.message !== 'string' || typeof returned.result.error.node_id !== 'string')) + || (returned.vars !== undefined && (returned.vars === null || Array.isArray(returned.vars) + || typeof returned.vars !== 'object')) + || (returned.retry_counts !== undefined && (returned.retry_counts === null + || typeof returned.retry_counts !== 'object' || Array.isArray(returned.retry_counts) + || Object.values(returned.retry_counts).some(value => !Number.isSafeInteger(value) || value < 0)))) { + throw Object.assign(new Error('Invalid node return value'), {code: 'INVALID_NODE_RETURN'}); + } + if (!node_registry[node.type].config_template.ports.some(port => port.name === returned.port)) { + throw Object.assign(new Error('Node returned an undeclared port'), {code: 'INVALID_NODE_PORT'}); + } + signal.throwIfAborted(); + if (performance.now() - started_at >= timeout_ms) throw timeout_error; + return returned; + } catch (error) { + signal.throwIfAborted(); + const cause = controller.signal.aborted || (valid_timeout && performance.now() - started_at >= timeout_ms) + ? timeout_error : error; + const coded = cause as {code?: unknown; message?: unknown} | null; + const code = typeof coded?.code === 'string' ? coded.code : 'NODE_EXECUTION_FAILED'; + const message = typeof coded?.message === 'string' ? coded.message : String(cause); + return {port: 'failure', result: {status: 'failure', output: null, + error: {code: code.slice(0, 256), message: message.slice(0, 4096), node_id: node.id}}}; + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/bot/workflow/runtime/machine.ts b/bot/workflow/runtime/machine.ts new file mode 100644 index 0000000..23a99e7 --- /dev/null +++ b/bot/workflow/runtime/machine.ts @@ -0,0 +1,251 @@ +import {assign, createActor, fromPromise, setup} from 'xstate'; +import {randomUUID} from 'node:crypto'; +import {isDeepStrictEqual} from 'node:util'; +import type {Json} from '../../../types/bot/workflow/workflow.ts'; +import type {BranchState, CompiledWorkflow, NodeResult, NodeReturn, RunSnapshot, StopReason, + WorkflowDependencies, WorkflowError, WorkflowRunEvent} from '../../../types/bot/workflow/runtime.ts'; +import {execute_node} from './execute.ts'; +import {assert_json} from './value.ts'; + +interface Token { + node_id: string; + edge_id: string | null; + state: BranchState; + joins: Record; + bases: Record>; + forced?: NodeReturn; +} +interface Completion {token: Token; returned: NodeReturn; execution: number} +interface ExecutionCompletion {execution: number; completed?: Completion; error?: unknown} +export interface WorkflowRun { + run_id: string; + done: Promise; + get_snapshot(): RunSnapshot; + stop(reason?: StopReason): Promise; +} + +function failure(code: string, message: string, node_id = ''): WorkflowError {return {code, message, node_id};} + +function merge_states(tokens: Token[], base: Record): BranchState { + const state = structuredClone(tokens[0].state); + state.vars = structuredClone(base); + state.inputs = {}; + state.outputs = {}; + const writes = new Set(); + for (const token of tokens) { + for (const [key, value] of Object.entries(token.state.vars)) { + if (Object.hasOwn(base, key) && isDeepStrictEqual(base[key], value)) continue; + if (writes.has(key) && !isDeepStrictEqual(state.vars[key], value)) { + throw Object.assign(new Error(`Conflicting branch variable: ${key}`), {code: 'MERGE_CONFLICT'}); + } + writes.add(key); + state.vars[key] = structuredClone(value); + } + for (const [id, result] of Object.entries(token.state.outputs)) { + if (!state.outputs[id] || result.execution > state.outputs[id].execution) state.outputs[id] = structuredClone(result); + } + for (const [key, count] of Object.entries(token.state.retry_counts)) { + state.retry_counts[key] = Math.max(state.retry_counts[key] ?? 0, count); + } + Object.assign(state.inputs, structuredClone(token.state.inputs)); + } + state.last_result = structuredClone(tokens.find(token => token.state.last_result?.status === 'failure')?.state.last_result + ?? tokens[tokens.length - 1].state.last_result); + return state; +} + +export function create_workflow_run(graph: CompiledWorkflow, deps: WorkflowDependencies, + trigger: Record = {}, notify: (event: WorkflowRunEvent) => void = () => {}): WorkflowRun { + assert_json(trigger); + if (!trigger || Array.isArray(trigger) || typeof trigger !== 'object') throw new Error('Invalid workflow trigger'); + const run_id = randomUUID(); + const controller = new AbortController(); + const initial: BranchState = { + trigger: structuredClone(trigger), vars: structuredClone(graph.definition.initial_vars ?? {}), + outputs: {}, last_result: null, inputs: {}, retry_counts: {}, + }; + const snapshot: RunSnapshot = { + run_id, workflow_id: graph.definition.id, status: 'running', steps: 0, active_nodes: [], + started_at: Date.now(), ended_at: null, stop_reason: null, error: null, + trigger: structuredClone(trigger), trigger_available: true, branches: [], + }; + const emit = (event: WorkflowRunEvent) => {try {notify(structuredClone(event));} catch { /* Observers cannot stop execution. */ }}; + const update = () => emit({type: 'run_updated', snapshot}); + const waiting = new Map>(); + const ready: Token[] = [{node_id: graph.definition.entry, edge_id: null, state: initial, joins: {}, bases: {}}]; + const active = new Map>(); + const completions: ExecutionCompletion[] = []; + let wake: (() => void) | null = null; + const active_nodes = new Map(); + let fatal: WorkflowError | null = null; + const token_count = () => ready.length + active.size + snapshot.branches.length + + [...waiting.values()].reduce((sum, group) => sum + [...group.values()].reduce((n, queue) => n + queue.length, 0), 0); + + const join_owners = graph.join_owners; + + const enqueue = (token: Token) => { + const node = graph.nodes.get(token.node_id)!; + if (node.type !== 'merge' || node.config.mode === 'any') {ready.push(token); return;} + const incoming = graph.incoming.get(node.id)!; + const key = `${node.id}:${token.joins[node.id] ?? 'root'}`; + let group = waiting.get(key); + if (!group) {group = new Map(); waiting.set(key, group);} + const edge_id = token.edge_id!; + group.set(edge_id, [...group.get(edge_id) ?? [], token]); + if (!incoming.every(edge => (group!.get(edge.id)?.length ?? 0) > 0)) return; + const tokens = incoming.map(edge => group!.get(edge.id)!.shift()!); + if ([...group.values()].every(queue => queue.length === 0)) waiting.delete(key); + let joined: Token; + try {joined = {...tokens[0], state: merge_states(tokens, tokens[0].bases[node.id] ?? initial.vars), + joins: {...tokens[0].joins}, bases: {...tokens[0].bases}};} + catch (error) { + joined = {...tokens[0], joins: {...tokens[0].joins}, bases: {...tokens[0].bases}, forced: { + port: 'failure', result: {status: 'failure', output: null, + error: failure((error as {code?: string}).code ?? 'MERGE_FAILED', String((error as Error).message), node.id)}, + }}; + } + // Keep lineage when a retry returns to the join without revisiting its owner. + // A fresh owner execution replaces the key; FIFO inputs isolate rounds within it. + joined.bases[node.id] = structuredClone(joined.state.vars); + ready.push(joined); + }; + + const commit = ({token, returned, execution}: Completion) => { + const state = structuredClone(token.state); + state.vars = {...state.vars, ...returned.vars}; + state.retry_counts = {...state.retry_counts, ...returned.retry_counts}; + state.outputs[token.node_id] = {...structuredClone(returned.result), execution}; + state.last_result = {...structuredClone(returned.result), node_id: token.node_id}; + assert_json(state); + emit({type: 'node_finished', run_id, node_id: token.node_id, execution, result: returned.result}); + const edges = (graph.outgoing.get(token.node_id) ?? []).filter(edge => edge.port === returned.port); + if (token_count() + Math.max(1, edges.length) > 256) { + fatal = failure('BRANCH_LIMIT_EXCEEDED', 'Workflow exceeded its live branch limit', token.node_id); + return; + } + if (!edges.length) { + snapshot.branches.push(state); + if (returned.result.status === 'failure') fatal ??= returned.result.error + ?? failure('UNHANDLED_FAILURE', 'A branch ended with an unhandled failure', token.node_id); + return; + } + const joins = {...token.joins}; + const bases = {...token.bases}; + if (edges.length) { + const fork_id = randomUUID(); + let baseline: Record | undefined; + for (const [join, owner] of join_owners) if (owner === token.node_id) { + joins[join] = fork_id; + // Baselines are immutable; all joins owned by this execution share one copy. + baseline ??= structuredClone(state.vars); + bases[join] = baseline; + } + } + for (const edge of edges) { + const next = structuredClone(state); + next.inputs = {[edge.id]: {source: edge.source, port: edge.port, result: structuredClone(returned.result) as unknown as Json}}; + enqueue({node_id: edge.target, edge_id: edge.id, state: next, joins: {...joins}, bases: {...bases}}); + } + }; + + const execute_graph = async () => { + try { + while ((ready.length || active.size) && !fatal && !controller.signal.aborted) { + while (ready.length && active.size < 16 && !fatal && !controller.signal.aborted) { + const token = ready.shift()!; + if (snapshot.steps >= graph.definition.policy.max_steps) { + fatal = failure('STEP_LIMIT_EXCEEDED', 'Workflow exceeded its step limit', token.node_id); break; + } + const execution = ++snapshot.steps; + active_nodes.set(execution, token.node_id); + snapshot.active_nodes = [...active_nodes.values()]; + emit({type: 'node_started', run_id, node_id: token.node_id, execution}); + update(); + const complete = (value: ExecutionCompletion) => { + completions.push(value); + wake?.(); wake = null; + }; + const promise = (token.forced ? Promise.resolve(token.forced) + : execute_node(graph.nodes.get(token.node_id)!, structuredClone(token.state), controller.signal, deps)) + .then(returned => complete({execution, completed: {token, returned, execution}}), error => complete({execution, error})); + active.set(execution, promise); + } + if (fatal || !active.size) break; + if (!completions.length) await new Promise(resolve => {wake = resolve;}); + const finished = completions.shift()!; + active.delete(finished.execution); + active_nodes.delete(finished.execution); + snapshot.active_nodes = [...active_nodes.values()]; + if (controller.signal.aborted) break; + if (finished.error) throw finished.error; + commit(finished.completed!); + update(); + } + if (!controller.signal.aborted && !fatal && waiting.size) { + fatal = failure('MERGE_INCOMPLETE', 'All-input merge has missing inputs; check exclusive branches and loops'); + } + } catch (error) { + if (!controller.signal.aborted) fatal = failure((error as {code?: string})?.code ?? 'RUNTIME_ERROR', + error instanceof Error ? error.message : String(error)); + } finally { + if (fatal) controller.abort(new Error(fatal.message)); + await Promise.all(active.values()); + completions.length = 0; + active.clear(); ready.length = 0; waiting.clear(); + snapshot.active_nodes = []; + snapshot.error = fatal; + } + return fatal; + }; + + const machine = setup({ + types: {context: {} as {error: WorkflowError | null}, events: {} as {type: 'STOP'; reason: StopReason}}, + actors: {graph: fromPromise(execute_graph)}, + actions: { + stop: ({event}) => { + if (snapshot.stop_reason || fatal) return; + snapshot.stop_reason = event.reason; + snapshot.status = 'stopping'; + controller.abort(new Error(`Workflow stopped: ${event.reason}`)); + update(); + }, + }, + }).createMachine({ + id: 'workflow', initial: 'running', context: {error: null}, + states: { + running: { + on: {STOP: {actions: 'stop'}}, + invoke: {src: 'graph', onDone: {target: 'ending', actions: assign({error: ({event}) => event.output})}, + onError: {target: 'failed', actions: assign({error: ({event}) => failure('RUNTIME_ERROR', String(event.error))})}}, + }, + ending: {always: [ + {guard: () => snapshot.stop_reason === 'timeout', target: 'timed_out'}, + {guard: () => snapshot.stop_reason !== null, target: 'cancelled'}, + {guard: ({context}) => context.error !== null, target: 'failed'}, + {target: 'completed'}, + ]}, + completed: {type: 'final'}, failed: {type: 'final'}, cancelled: {type: 'final'}, timed_out: {type: 'final'}, + }, + }); + const actor = createActor(machine); + let resolve!: (value: RunSnapshot) => void; + const done = new Promise(finish => {resolve = finish;}); + const timer = setTimeout(() => actor.send({type: 'STOP', reason: 'timeout'}), graph.definition.policy.timeout_ms); + actor.subscribe({next: value => { + if (value.status !== 'done') return; + clearTimeout(timer); + snapshot.status = value.value as RunSnapshot['status']; + snapshot.error = value.context.error; + snapshot.ended_at = Date.now(); + update(); + resolve(structuredClone(snapshot)); + }, error: error => { + clearTimeout(timer); snapshot.status = 'failed'; snapshot.ended_at = Date.now(); + snapshot.error = failure('ACTOR_ERROR', String(error)); update(); resolve(structuredClone(snapshot)); + }}); + actor.start(); + return { + run_id, done, get_snapshot: () => structuredClone(snapshot), + async stop(reason = 'manual') {if (snapshot.ended_at === null) actor.send({type: 'STOP', reason}); await done;}, + }; +} diff --git a/bot/workflow/runtime/states_manager.ts b/bot/workflow/runtime/states_manager.ts new file mode 100644 index 0000000..3ed4609 --- /dev/null +++ b/bot/workflow/runtime/states_manager.ts @@ -0,0 +1,693 @@ +import {randomUUID} from 'node:crypto'; +import {isDeepStrictEqual} from 'node:util'; +import type {Bot} from '@wp2508/mineflayer'; +import type {bot_event_handler, bot_event_name, bot_instance_context} from '../../instance/bot_instance_init.ts'; +import type {Json, workflow_config} from '../../../types/bot/workflow/workflow.ts'; +import type {CompiledWorkflow, RunSnapshot, StartResult, StopReason, WorkflowDependencies, + WorkflowManager as WorkflowManagerContract, WorkflowRunEvent, WorkflowControl, WorkflowCommand, + WorkflowRuntimeState, WorkflowRuntimeSnapshot} from '../../../types/bot/workflow/runtime.ts'; +import {compile_workflow} from './compile.ts'; +import {create_workflow_run, type WorkflowRun} from './machine.ts'; +import {assert_json} from './value.ts'; +import {query_registry} from '../registry.ts'; +import {resolve_fields} from './template.ts'; +import {register_event_start} from '../start_node/event_node.ts'; +import {schedule_timer_start} from '../start_node/timer_node.ts'; + +const max_queue = 256; +const max_trigger_bytes = 65_536; +const max_history_bytes = 5 * 1024 * 1024; +const max_history_runs = 20; +type Request = {graph: CompiledWorkflow; generation: number; trigger: Record; request_id: string}; +type Entry = {graph: CompiledWorkflow; state: WorkflowRuntimeState; cleanup?: () => void; + firing: boolean; common_fired: boolean; manually_stopped: boolean; stopping: boolean}; +export interface WorkflowManagerOptions { + allow_code?: boolean; + /** Total concurrent runs for this bot, including runs draining cancellation; defaults to 16. */ + max_concurrent_runs?: number; + /** Initial persisted configuration revision; zero is the unversioned baseline. */ + source_config_revision?: number; +} + +function fail(code: string, message: string): never { throw Object.assign(new Error(message), {code}); } + +export class WorkflowManager implements WorkflowManagerContract { + private readonly graphs = new Map(); + private readonly entries = new Map(); + private readonly generations = new Map(); + private readonly commands = new Map(); + private readonly reload_times = new Map(); + private operation: Promise = Promise.resolve(); + private pending_controls = 0; + private source_revision: number; + private source_definitions: workflow_config[]; + private runtime_pending = false; + private readonly queue: Request[] = []; + private readonly history = new Map(); + private readonly listeners = new Set<(event: WorkflowRunEvent) => void>(); + private readonly cleanups: (() => void)[] = []; + private history_bytes = 0; + private readonly active = new Map(); + private readonly max_concurrent_runs: number; + private spawned_bot: Bot | null = null; + private connection_bot: Bot | null = null; + private launching = false; + private launch_done?: Promise; + private initialized = false; + private disposed = false; + private dispose_task?: Promise; + + constructor(private readonly context: bot_instance_context, configs: workflow_config[], + private readonly options: WorkflowManagerOptions = {}) { + this.max_concurrent_runs = options.max_concurrent_runs ?? 16; + if (!Number.isSafeInteger(this.max_concurrent_runs) || this.max_concurrent_runs < 1 || this.max_concurrent_runs > 256) { + fail('INVALID_CONCURRENCY_LIMIT', 'Concurrent run limit must be an integer between 1 and 256'); + } + this.source_revision = options.source_config_revision ?? 0; + this.check_revision(this.source_revision); + const compiled = this.compile(configs); + this.source_definitions = structuredClone(configs); + for (const graph of compiled.values()) this.install(graph); + } + + private compile(configs: workflow_config[]): Map { + if (!Array.isArray(configs)) fail('INVALID_WORKFLOW_CONFIG', 'Workflow definitions must be an array'); + const compiled = new Map(); + for (const config of configs) { + const graph = compile_workflow(config); + if (compiled.has(graph.definition.id)) fail('DUPLICATE_WORKFLOW_ID', 'Duplicate workflow id'); + if (graph.definition.start.type === 'event' && graph.definition.start.event === 'workflow_update') { + fail('INVALID_WORKFLOW_EVENT', 'workflow_update cannot trigger a workflow'); + } + compiled.set(graph.definition.id, graph); + } + return compiled; + } + + private install(graph: CompiledWorkflow): Entry { + const id = graph.definition.id; + const generation = (this.generations.get(id) ?? 0) + 1; + this.generations.set(id, generation); + const start = graph.definition.start; + const entry: Entry = {graph, firing: false, common_fired: false, manually_stopped: false, stopping: false, state: { + workflow_id: id, generation, enabled: graph.definition.enabled, start_type: start.type, + trigger_state: 'stopped', accepted_count: 0, + max_runs: start.type === 'common' || start.mode === 'once' ? 1 + : start.type === 'timer' ? start.max_runs ?? null : null, + next_fire_at: null, active_run_ids: [], queued_count: 0, last_error: null, + }}; + this.graphs.set(id, graph); + this.entries.set(id, entry); + return entry; + } + + initialize(): void { + if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed'); + if (this.initialized) return; + this.initialized = true; + this.spawned_bot = this.context.bot_status === 'online' && this.context.bot_instance?.entity + ? this.context.bot_instance : null; + this.connection_bot = this.spawned_bot; + try { + this.listen('spawn', () => { + const bot = this.context.bot_instance; + if (!bot) return; + if (this.spawned_bot && this.spawned_bot !== bot) this.disconnect(); + this.spawned_bot = bot; + if (this.connection_bot !== bot) { + this.connection_bot = bot; + for (const entry of this.entries.values()) { + if (entry.stopping) continue; + this.stop_trigger(entry); + entry.manually_stopped = false; + entry.common_fired = false; + entry.state.accepted_count = 0; + entry.state.last_error = null; + this.auto_start(entry); + } + } + this.runtime_changed(); + }); + this.listen('status_update', status => { + if (status !== 'online') this.disconnect(); + }); + this.listen('end', () => this.disconnect()); + this.listen('kicked', () => this.disconnect()); + this.listen('error', () => this.disconnect()); + this.listen('connection_lost', () => this.disconnect()); + this.listen('instance_created', () => this.disconnect()); + for (const entry of this.entries.values()) this.auto_start(entry, true); + } catch (error) { + void this.dispose().catch(() => {}); + throw error; + } + } + + private listen(event: E, callback: bot_event_handler): void { + this.cleanups.push(() => this.context.off(event, callback)); + this.context.on(event, callback); + } + + private ready(): boolean { + return this.context.bot_status === 'online' && this.spawned_bot !== null + && this.context.bot_instance === this.spawned_bot && !!this.spawned_bot.entity; + } + + private disconnect(): void { + this.spawned_bot = null; + this.queue.length = 0; + for (const entry of this.entries.values()) this.stop_trigger(entry); + for (const {run} of this.active.values()) void run.stop('disconnect').catch(() => {}); + this.runtime_changed(); + } + + private auto_start(entry: Entry, defer_common = false): void { + if (!this.ready() || this.disposed || entry.stopping || !entry.graph.definition.enabled || entry.manually_stopped) return; + const config = entry.graph.definition.start; + if (config.type === 'common') { + if (config.auto_start !== 'spawn' || entry.common_fired) return; + const fire = () => { + if (!this.ready() || this.disposed || this.entries.get(entry.state.workflow_id) !== entry + || entry.stopping || entry.common_fired || entry.manually_stopped) return; + entry.common_fired = true; + this.fire(entry, {event: 'spawn'}); + }; + if (defer_common) queueMicrotask(fire); else fire(); + } else if (config.auto_start !== false) this.start_trigger(entry); + } + + private stop_trigger(entry: Entry, state: WorkflowRuntimeState['trigger_state'] = 'stopped'): void { + const cleanup = entry.cleanup; + entry.cleanup = undefined; + entry.state.trigger_state = state; + entry.state.next_fire_at = null; + cleanup?.(); + this.runtime_changed(); + } + + private start_trigger(entry: Entry): void { + if (entry.stopping) fail('WORKFLOW_STOPPING', 'Workflow is stopping'); + if (entry.cleanup || entry.firing) return; + if (!entry.graph.definition.enabled) fail('WORKFLOW_DISABLED', 'Workflow is disabled'); + if (!this.ready()) fail('BOT_NOT_READY', 'Bot must be online and spawned'); + entry.manually_stopped = false; + entry.state.last_error = null; + const config = entry.graph.definition.start; + if (config.type === 'event') { + entry.cleanup = register_event_start(this.context, entry.graph.definition, trigger => this.fire(entry, trigger)); + entry.state.trigger_state = 'listening'; + } else if (config.type === 'timer') { + entry.state.next_fire_at = Date.now() + config.interval_ms; + entry.cleanup = schedule_timer_start(config.interval_ms, trigger => { + entry.cleanup = undefined; + entry.state.next_fire_at = null; + this.fire(entry, trigger); + if (entry.state.trigger_state === 'scheduled' && !entry.cleanup && this.ready() && !this.disposed) { + this.start_trigger(entry); + } + }); + entry.state.trigger_state = 'scheduled'; + } + this.runtime_changed(); + } + + private fire(entry: Entry, trigger: Record): void { + if (entry.firing || this.entries.get(entry.state.workflow_id) !== entry || this.disposed || !this.ready() + || entry.stopping || entry.manually_stopped || entry.state.trigger_state === 'reloading') return; + if (entry.state.start_type !== 'common' + && !['listening', 'scheduled'].includes(entry.state.trigger_state)) return; + entry.firing = true; + const previous = entry.state.trigger_state; + const last = entry.state.max_runs !== null && entry.state.accepted_count + 1 >= entry.state.max_runs; + // Detach finite triggers before start() publishes any reentrant run notifications. + if (last) this.stop_trigger(entry, 'exhausted'); + try { + const result = this.start(entry.state.workflow_id, trigger); + if (result.status !== 'skipped') entry.state.accepted_count++; + else if (last) entry.state.trigger_state = previous; + } catch (error) { + this.stop_trigger(entry, 'error'); + entry.state.last_error = this.error(error); + } finally { + entry.firing = false; + if (last && entry.state.trigger_state === 'listening' && !entry.cleanup && this.ready()) this.start_trigger(entry); + this.runtime_changed(); + } + } + + private available(graph: CompiledWorkflow): boolean { + const entry = this.entries.get(graph.definition.id); + return entry?.graph === graph && !entry.stopping && this.active.size < this.max_concurrent_runs + && [...this.active.values()].filter(item => item.workflow_id === graph.definition.id).length + < (graph.definition.policy.max_concurrent_runs ?? 1); + } + + start(workflow_id: string, trigger: Record = {}): StartResult { + if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed'); + const graph = this.graphs.get(workflow_id); + if (!graph) fail('WORKFLOW_NOT_FOUND', `Unknown workflow: ${workflow_id}`); + const entry = this.entries.get(workflow_id)!; + if (entry.state.trigger_state === 'reloading') fail('WORKFLOW_RELOADING', 'Workflow is being replaced'); + if (entry.stopping) fail('WORKFLOW_STOPPING', 'Workflow is stopping'); + if (!graph.definition.enabled) fail('WORKFLOW_DISABLED', `Workflow is disabled: ${workflow_id}`); + if (!this.initialized || !this.ready()) fail('BOT_NOT_READY', 'Bot must be online and spawned'); + assert_json(trigger); + if (trigger === null || typeof trigger !== 'object' || Array.isArray(trigger) + || Buffer.byteLength(JSON.stringify(trigger)) > max_trigger_bytes) { + fail('INVALID_WORKFLOW_TRIGGER', 'Workflow trigger must be a bounded JSON object'); + } + const copy = structuredClone(trigger); + if (!this.available(graph) || this.launching) { + if (graph.definition.policy.on_busy === 'skip' || this.queue.length >= max_queue + || this.queue.filter(item => item.graph === graph).length >= graph.definition.policy.max_queue) { + return {status: 'skipped'}; + } + const request_id = randomUUID(); + this.queue.push({graph, generation: entry.state.generation, trigger: copy, request_id}); + this.runtime_changed(); + queueMicrotask(() => this.pump()); + return {status: 'queued', request_id}; + } + return {status: 'started', run_id: this.launch(graph, copy)}; + } + + private launch(graph: CompiledWorkflow, trigger: Record): string { + this.launching = true; + let finish_launch!: () => void; + this.launch_done = new Promise(resolve => { finish_launch = resolve; }); + const notifications: WorkflowRunEvent[] = []; + let assigned = false; + try { + const run = create_workflow_run(graph, this.dependencies(), trigger, event => { + if (!assigned) notifications.push(event); + else this.publish(event); + }); + this.active.set(run.run_id, {run, workflow_id: graph.definition.id}); + this.runtime_changed(); + assigned = true; + void run.done.then(snapshot => { + this.remember(snapshot); + this.active.delete(run.run_id); + this.runtime_changed(); + this.pump(); + }).catch(() => { + this.active.delete(run.run_id); + this.runtime_changed(); + this.pump(); + }); + if (this.disposed || !this.ready()) { + void run.stop(this.disposed ? 'disposed' : 'disconnect').catch(() => {}); + } + for (const event of notifications) this.publish(event); + return run.run_id; + } finally { + this.launching = false; + finish_launch(); + } + } + + private pump(): void { + if (this.disposed || this.launching || !this.ready()) return; + for (let index = this.queue.length - 1; index >= 0; index--) { + const request = this.queue[index]; + const entry = this.entries.get(request.graph.definition.id); + if (!entry || entry.graph !== request.graph || entry.state.generation !== request.generation + || entry.stopping || entry.state.trigger_state === 'reloading') this.queue.splice(index, 1); + } + while (this.active.size < this.max_concurrent_runs && this.ready() && !this.disposed) { + // A busy definition must not block other workflows with free capacity. + const index = this.queue.findIndex(request => this.available(request.graph)); + if (index < 0) break; + const request = this.queue.splice(index, 1)[0]; + this.runtime_changed(); + try { this.launch(request.graph, request.trigger); } + catch { /* A failed launch must not strand later requests. */ } + } + } + + private dependencies(): WorkflowDependencies { + const bot = this.context.bot_instance!; + const functions = this.context.function_manager; + const info = this.context.info_manager; + const check = (signal: AbortSignal) => { + signal.throwIfAborted(); + if (this.disposed || !this.ready() || this.context.bot_instance !== bot + || this.context.function_manager !== functions || this.context.info_manager !== info) { + fail('WORKFLOW_CONTEXT_CHANGED', 'Bot runtime changed during workflow execution'); + } + }; + return { + ...(!this.options.allow_code ? {execute_code: async () => + fail('CODE_EXECUTION_DISABLED', 'Workflow code execution is disabled')} : {}), + run_function: async (path, name, args, signal) => { + check(signal); + if (!functions) fail('FUNCTION_MANAGER_UNAVAILABLE', 'Bot function manager is unavailable'); + return functions.run(path, name, args, () => check(signal), {signal}); + }, + query: async (source, args, signal) => { + check(signal); + if (!Object.hasOwn(query_registry, source)) fail('UNKNOWN_QUERY_SOURCE', `Unknown workflow query source: ${source}`); + const provider = query_registry[source as keyof typeof query_registry]; + let config: Record; + try {config = resolve_fields(provider.config_template.config, args);} + catch {fail('INVALID_QUERY_ARGUMENTS', 'Invalid workflow query arguments');} + return provider.query_info({info_manager: info}, config); + }, + sleep: (mode, value, signal) => { + check(signal); + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + let ticks = 0; + let settled = false; + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + bot.off('physicsTick', tick); + bot.off('end', end); + }; + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + cleanup(); + if (error !== undefined) reject(error); else resolve(); + }; + const abort = () => finish(signal.reason ?? new Error('Workflow sleep cancelled')); + const end = () => finish(new Error('Bot disconnected during workflow sleep')); + const tick = () => { + try { check(signal); if (++ticks >= value) finish(); } + catch (error) { finish(error); } + }; + try { + signal.addEventListener('abort', abort, {once: true}); + if (signal.aborted) { abort(); return; } + if (value === 0) { finish(); return; } + bot.on('end', end); + if (mode === 'milliseconds') timer = setTimeout(() => { + try { check(signal); finish(); } catch (error) { finish(error); } + }, value); + else bot.on('physicsTick', tick); + } catch (error) { finish(error); } + }); + }, + log: (level, message) => { + const logger = this.context.logger; + if (logger) this.context.log([logger[level](message)]); + }, + }; + } + + private remember(snapshot: RunSnapshot): void { + const previous = this.history.get(snapshot.run_id); + if (previous) { this.history.delete(snapshot.run_id); this.history_bytes -= previous.bytes; } + let bytes = Buffer.byteLength(JSON.stringify(snapshot)); + if (bytes > max_history_bytes) { + snapshot = {...snapshot, branches: []}; + bytes = Buffer.byteLength(JSON.stringify(snapshot)); + } + this.history.set(snapshot.run_id, {snapshot: structuredClone(snapshot), bytes}); + this.history_bytes += bytes; + while (this.history.size > max_history_runs || this.history_bytes > max_history_bytes) { + const oldest = this.history.entries().next().value!; + this.history.delete(oldest[0]); + this.history_bytes -= oldest[1].bytes; + } + } + + private publish(event: WorkflowRunEvent): void { + if (event.type === 'run_updated' && event.snapshot.ended_at !== null) this.remember(event.snapshot); + try { this.context.emit('workflow_update', structuredClone(event)); } catch {} + for (const listener of Array.from(this.listeners)) { + if (!this.listeners.has(listener)) continue; + try { listener(structuredClone(event)); } catch {} + } + } + + async stop(run_id: string, reason: StopReason = 'manual'): Promise { + const active = this.active.get(run_id); + if (active) await active.run.stop(reason); + else if (!this.history.has(run_id)) fail('WORKFLOW_RUN_NOT_FOUND', `Unknown workflow run: ${run_id}`); + } + + get_snapshot(run_id: string): RunSnapshot | undefined { + const active = this.active.get(run_id); + if (active) return active.run.get_snapshot(); + const snapshot = this.history.get(run_id)?.snapshot; + return snapshot ? structuredClone(snapshot) : undefined; + } + + get_snapshots(): RunSnapshot[] { + const snapshots = Array.from(this.history.values(), entry => structuredClone(entry.snapshot)); + for (const {run} of this.active.values()) if (!this.history.has(run.run_id)) snapshots.push(run.get_snapshot()); + return snapshots; + } + + on_update(callback: (event: WorkflowRunEvent) => void): () => void { + if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed'); + const listener = (event: WorkflowRunEvent) => callback(event); + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + private error(error: unknown): {code: string; message: string} { + return {code: typeof (error as any)?.code === 'string' ? (error as any).code : 'WORKFLOW_OPERATION_FAILED', + message: error instanceof Error ? error.message : 'Workflow operation failed'}; + } + + private check_revision(revision: number): void { + if (!Number.isSafeInteger(revision) || revision < 0) fail('INVALID_SOURCE_REVISION', 'Invalid workflow source revision'); + } + + private serialize(operation: () => Promise | T): Promise { + const task = this.operation.then(() => { + if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed'); + return operation(); + }); + this.operation = task.then(() => {}, () => {}); + return task; + } + + apply(configs: workflow_config[], source_revision?: number): Promise { + // Compilation and cloning finish before any controller or running graph is touched. + let compiled: Map; + let definitions: workflow_config[]; + try { + compiled = this.compile(configs); + definitions = structuredClone(configs); + if (source_revision !== undefined) this.check_revision(source_revision); + } catch (error) { return Promise.reject(error); } + return this.serialize(async () => { + if (source_revision !== undefined) { + if (source_revision < this.source_revision) fail('STALE_SOURCE_REVISION', 'Workflow source revision is stale'); + if (source_revision === this.source_revision) { + if (!isDeepStrictEqual(definitions, this.source_definitions)) { + fail('SOURCE_REVISION_CONFLICT', 'Workflow source revision has different content'); + } + return; + } + } + const replaced = [...this.entries.values()].filter(entry => + !isDeepStrictEqual(entry.graph.definition, compiled.get(entry.state.workflow_id)?.definition)); + // Connection events may overwrite trigger_state while cancellation is draining. + for (const entry of replaced) entry.stopping = true; + const ids = new Set(replaced.map(entry => entry.state.workflow_id)); + try { + for (const entry of replaced) this.stop_trigger(entry, 'reloading'); + this.remove_queue(ids); + await Promise.all(replaced.map(entry => this.drain(entry.state.workflow_id, 'updated'))); + } catch (error) { + for (const entry of replaced) { + entry.manually_stopped = true; + entry.state.last_error = this.error(error); + this.stop_trigger(entry, 'error'); + } + throw error; + } + if (this.disposed) return; + for (const id of ids) { this.entries.delete(id); this.graphs.delete(id); } + for (const [id, graph] of compiled) { + if (!this.entries.has(id)) this.auto_start(this.install(graph)); + } + this.source_definitions = definitions; + if (source_revision !== undefined) this.source_revision = source_revision; + this.runtime_changed(); + this.pump(); + }); + } + + private remove_queue(ids: Set): void { + for (let index = this.queue.length - 1; index >= 0; index--) { + if (ids.has(this.queue[index].graph.definition.id)) this.queue.splice(index, 1); + } + this.runtime_changed(); + } + + private async drain(workflow_id: string, reason: StopReason): Promise { + await this.launch_done; + await Promise.all([...this.active.values()].filter(active => active.workflow_id === workflow_id) + .map(({run}) => run.stop(reason))); + } + + control(request: WorkflowControl): WorkflowCommand { + if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed'); + assert_json(request); + if (!request || typeof request !== 'object' || Array.isArray(request) + || typeof request.command_id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(request.command_id) + || !['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'].includes(request.action) + || Object.keys(request).some(key => !['command_id', 'action', 'workflow_id', 'run_id', 'expected_generation', 'input'].includes(key)) + || request.expected_generation !== undefined && (!Number.isSafeInteger(request.expected_generation) || request.expected_generation < 1) + || request.workflow_id !== undefined && (typeof request.workflow_id !== 'string' || request.workflow_id.length > 100) + || request.run_id !== undefined && (typeof request.run_id !== 'string' || request.run_id.length > 100)) { + fail('INVALID_WORKFLOW_CONTROL', 'Invalid workflow control request'); + } + if (request.input !== undefined && (request.input === null || typeof request.input !== 'object' + || Array.isArray(request.input) || Buffer.byteLength(JSON.stringify(request.input)) > max_trigger_bytes)) { + fail('INVALID_WORKFLOW_TRIGGER', 'Workflow input must be a bounded JSON object'); + } + const previous = this.commands.get(request.command_id); + if (previous) { + if (!isDeepStrictEqual(previous.request, request)) fail('COMMAND_ID_CONFLICT', 'Command id has different content'); + return structuredClone(previous.command); + } + if (this.pending_controls >= 32) fail('WORKFLOW_CONTROL_BUSY', 'Too many pending workflow controls'); + const copy = structuredClone(request); + const command: WorkflowCommand = {command_id: request.command_id, action: request.action, + workflow_id: request.workflow_id ?? null, state: 'accepted'}; + this.commands.set(command.command_id, {request: copy, command}); + this.pending_controls++; + this.runtime_changed(); + void this.serialize(async () => { + if (copy.action === 'stop_run') { + if (!copy.run_id) fail('INVALID_WORKFLOW_CONTROL', 'stop_run requires run_id'); + const snapshot = this.get_snapshot(copy.run_id); + if (!snapshot) fail('WORKFLOW_RUN_NOT_FOUND', 'Unknown workflow run'); + if (copy.workflow_id !== undefined && copy.workflow_id !== snapshot.workflow_id) { + fail('WORKFLOW_RUN_MISMATCH', 'Run belongs to another workflow'); + } + command.workflow_id = snapshot.workflow_id; + this.expect_generation(copy, this.entries.get(snapshot.workflow_id)); + await this.stop(copy.run_id); + command.result = {stopped: true}; + return; + } + const entry = copy.workflow_id ? this.entries.get(copy.workflow_id) : undefined; + if (!entry) fail('WORKFLOW_NOT_FOUND', 'Unknown workflow'); + this.expect_generation(copy, entry); + switch (copy.action) { + case 'run_once': command.result = this.start(entry.state.workflow_id, copy.input ?? {}); break; + case 'trigger_start': + if (entry.state.start_type === 'common') fail('INVALID_TRIGGER_TYPE', 'Common workflows have no recurring trigger'); + if (!entry.cleanup && !entry.firing) { + if (!this.ready()) fail('BOT_NOT_READY', 'Bot must be online and spawned'); + entry.state.accepted_count = 0; + this.start_trigger(entry); + } + command.result = {generation: entry.state.generation}; + break; + case 'trigger_stop': + if (entry.state.start_type === 'common') fail('INVALID_TRIGGER_TYPE', 'Common workflows have no recurring trigger'); + entry.manually_stopped = true; + this.stop_trigger(entry); + command.result = {stopped: true}; + break; + case 'stop': + entry.manually_stopped = true; + entry.stopping = true; + this.stop_trigger(entry); + this.remove_queue(new Set([entry.state.workflow_id])); + try { await this.drain(entry.state.workflow_id, 'manual'); } + finally { entry.stopping = false; } + command.result = {stopped: true}; + break; + case 'reload': { + const now = Date.now(); + const recent = (this.reload_times.get(entry.state.workflow_id) ?? []).filter(time => now - time < 10_000); + if (recent.length >= 10) fail('WORKFLOW_RELOAD_RATE_LIMIT', 'Workflow reload limit exceeded'); + recent.push(now); + this.reload_times.set(entry.state.workflow_id, recent); + const graph = this.compile([entry.graph.definition]).get(entry.state.workflow_id)!; + entry.stopping = true; + try { + this.stop_trigger(entry, 'reloading'); + this.remove_queue(new Set([entry.state.workflow_id])); + await this.drain(entry.state.workflow_id, 'reloaded'); + } catch (error) { + entry.manually_stopped = true; + entry.state.last_error = this.error(error); + this.stop_trigger(entry, 'error'); + throw error; + } + if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed'); + const replacement = this.install(graph); + this.auto_start(replacement); + command.result = {generation: replacement.state.generation}; + break; + } + } + }).then(() => { command.state = 'completed'; }, error => { + command.state = 'failed'; command.error = this.error(error); + }).finally(() => { + this.pending_controls--; + while (this.commands.size > 100) { + const oldest = [...this.commands].find(([, item]) => item.command.state !== 'accepted'); + if (!oldest) break; + this.commands.delete(oldest[0]); + } + this.runtime_changed(); + }); + return structuredClone(command); + } + + private expect_generation(request: WorkflowControl, entry?: Entry): void { + if (request.expected_generation !== undefined && entry?.state.generation !== request.expected_generation) { + fail('WORKFLOW_GENERATION_CONFLICT', 'Workflow generation has changed'); + } + } + + get_command(command_id: string): WorkflowCommand | undefined { + const command = this.commands.get(command_id)?.command; + return command ? structuredClone(command) : undefined; + } + + get_runtime_snapshot(): WorkflowRuntimeSnapshot { + return {source_config_revision: this.source_revision, + workflows: [...this.entries.values()].map(entry => ({...structuredClone(entry.state), + active_run_ids: [...this.active].filter(([, active]) => active.workflow_id === entry.state.workflow_id) + .map(([id]) => id), + queued_count: this.queue.filter(request => request.graph === entry.graph + && request.generation === entry.state.generation).length})), + commands: [...this.commands.values()].map(item => structuredClone(item.command))}; + } + + private runtime_changed(): void { + if (!this.initialized || this.runtime_pending || this.disposed) return; + this.runtime_pending = true; + queueMicrotask(() => { + this.runtime_pending = false; + if (!this.disposed) this.publish({type: 'runtime_updated', snapshot: this.get_runtime_snapshot()}); + }); + } + + dispose(): Promise { + if (this.dispose_task) return this.dispose_task; + this.disposed = true; + this.spawned_bot = null; + this.queue.length = 0; + for (const entry of this.entries.values()) this.stop_trigger(entry); + for (const cleanup of this.cleanups.splice(0).reverse()) { + try { cleanup(); } catch {} + } + const launching = this.launch_done; + this.dispose_task = Promise.resolve().then(async () => { + try { await launching; await Promise.all([...this.active.values()].map(({run}) => run.stop('disposed'))); } + finally { await this.operation; this.listeners.clear(); } + }); + return this.dispose_task; + } +} + +export function create_workflow_manager(context: bot_instance_context, configs: workflow_config[], + options: WorkflowManagerOptions = {}): WorkflowManager { + const manager = new WorkflowManager(context, configs, options); + manager.initialize(); + return manager; +} diff --git a/bot/workflow/runtime/summary.ts b/bot/workflow/runtime/summary.ts new file mode 100644 index 0000000..ae4b998 --- /dev/null +++ b/bot/workflow/runtime/summary.ts @@ -0,0 +1,8 @@ +import type {RunSnapshot} from '../../../types/bot/workflow/runtime.ts'; + +export type RunSummary = Omit; + +/** Full trigger data is fetched on demand; live summaries never broadcast event payloads. */ +export function summarize_run({branches, trigger, ...summary}: RunSnapshot): RunSummary { + return {...summary, trigger_available: trigger !== undefined}; +} diff --git a/bot/workflow/runtime/template.ts b/bot/workflow/runtime/template.ts new file mode 100644 index 0000000..3703a51 --- /dev/null +++ b/bot/workflow/runtime/template.ts @@ -0,0 +1,111 @@ +import type {WorkflowField, WorkflowOutputTemplate} from '../../../types/bot/workflow/metadata.ts'; +import type {Json} from '../../../types/bot/workflow/workflow.ts'; +import {assert_json} from './value.ts'; + +function error(message: string): never {throw new Error(`Invalid workflow template: ${message}`);} +const safe_name = (name: string) => /^[a-zA-Z0-9_-]{1,100}$/.test(name) && !['__proto__', 'prototype', 'constructor'].includes(name); +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); + +export function validate_i18n(value: unknown): void { + if (!Array.isArray(value) || !value.length) error('missing translations'); + const languages = new Set(); + for (const item of value) { + if (!object(item) || typeof item.lang !== 'string' || !item.lang || languages.has(item.lang) + || typeof item.name !== 'string' || !item.name.trim() || typeof item.description !== 'string' || !item.description.trim()) { + error('invalid translations'); + } + languages.add(item.lang); + } +} + +export function validate_output(value: WorkflowOutputTemplate, depth = 0): void { + if (depth > 32 || !object(value) || !['string', 'number', 'boolean', 'null', 'object', 'array', 'json'].includes(value.type)) error('invalid output'); + validate_i18n(value.i18n); + if (value.nullable !== undefined && typeof value.nullable !== 'boolean') error('invalid nullable output'); + if (value.optional !== undefined && typeof value.optional !== 'boolean') error('invalid optional output'); + if (value.properties !== undefined) { + if (value.type !== 'object' || !object(value.properties)) error('invalid output properties'); + for (const [name, child] of Object.entries(value.properties)) { + if (!safe_name(name)) error('invalid output property name'); + validate_output(child, depth + 1); + } + } + if (value.items !== undefined) {if (value.type !== 'array') error('items require array'); validate_output(value.items, depth + 1);} + if (value.additional_properties !== undefined) { + if (value.type !== 'object') error('additional properties require object'); + validate_output(value.additional_properties, depth + 1); + } +} + +export function validate_fields(fields: WorkflowField[], depth = 0): void { + if (depth > 20 || !Array.isArray(fields) || fields.length > 100) error('invalid field list'); + const names = new Set(); + for (const field of fields) { + if (!object(field) || !safe_name(field.name) || names.has(field.name) || typeof field.required !== 'boolean' + || !['string', 'number', 'boolean', 'select', 'object', 'array', 'map', 'value_source', 'condition', 'code'].includes(field.type)) error('invalid field'); + names.add(field.name); validate_i18n(field.i18n); + if (field.nullable !== undefined && typeof field.nullable !== 'boolean') error('invalid nullable field'); + if (field.type === 'select') { + if (!Array.isArray(field.options) || !field.options.length) error('select needs options'); + const values = new Set(); + for (const option of field.options) { + if (!object(option) || !['string', 'number'].includes(typeof option.value) || values.has(option.value)) error('invalid select option'); + assert_json(option.value); values.add(option.value); validate_i18n(option.i18n); + } + } + if (field.type === 'object') validate_fields(field.fields!, depth + 1); + if (field.type === 'array' || field.type === 'map') validate_fields([field.item!], depth + 1); + if (field.min !== undefined && !Number.isFinite(field.min) || field.max !== undefined && !Number.isFinite(field.max) + || field.min !== undefined && field.max !== undefined && field.min > field.max) error('invalid number range'); + if (field.integer !== undefined && typeof field.integer !== 'boolean') error('invalid integer constraint'); + for (const limit of [field.min_length, field.max_length]) { + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) error('invalid length constraint'); + } + if (field.min_length !== undefined && field.max_length !== undefined && field.min_length > field.max_length) error('invalid length range'); + if (Object.hasOwn(field, 'default')) {assert_json(field.default); check_field(field, field.default);} + } +} + +function check_field(field: WorkflowField, value: unknown): void { + if (value === null && field.nullable) return; + let valid = false; + switch (field.type) { + case 'string': case 'code': valid = typeof value === 'string' && (field.max_length === undefined || value.length <= field.max_length) + && (field.min_length === undefined || value.length >= field.min_length); break; + case 'number': valid = typeof value === 'number' && Number.isFinite(value) + && (!field.integer || Number.isSafeInteger(value)) && (field.min === undefined || value >= field.min) + && (field.max === undefined || value <= field.max); break; + case 'boolean': valid = typeof value === 'boolean'; break; + case 'select': valid = field.options!.some(option => option.value === value); break; + case 'array': + valid = Array.isArray(value) && (field.min === undefined || value.length >= field.min) + && (field.max === undefined || value.length <= field.max); + if (valid) for (const item of value as unknown[]) check_field(field.item!, item); break; + case 'object': valid = object(value); if (valid) resolve_fields(field.fields!, value as Record); break; + case 'map': valid = object(value); if (valid) for (const item of Object.values(value)) check_field(field.item!, item); break; + case 'value_source': valid = object(value) && (value.kind === 'literal' || value.kind === 'ref'); break; + case 'condition': valid = object(value) && typeof value.op === 'string'; break; + } + if (!valid) error(`invalid value for ${field.name}`); +} + +/** Applies defaults only to missing own properties. Explicit invalid values never fall back. */ +export function resolve_fields(fields: WorkflowField[], input: Record): Record { + assert_json(input); + if (!object(input)) error('configuration must be an object'); + const result = structuredClone(input); + for (const field of fields) { + if (!Object.hasOwn(result, field.name)) { + if (Object.hasOwn(field, 'default')) result[field.name] = structuredClone(field.default!); + else if (field.required) error(`missing ${field.name}`); + else continue; + } + check_field(field, result[field.name]); + if (field.type === 'object') result[field.name] = resolve_fields(field.fields!, result[field.name] as Record); + if (field.type === 'array' && field.item!.type === 'object') result[field.name] = (result[field.name] as Record[]) + .map(item => resolve_fields(field.item!.fields!, item)); + if (field.type === 'map' && field.item!.type === 'object') result[field.name] = Object.fromEntries( + Object.entries(result[field.name] as Record>).map(([key, item]) => [key, resolve_fields(field.item!.fields!, item)])); + } + return result; +} diff --git a/bot/workflow/runtime/validate.ts b/bot/workflow/runtime/validate.ts new file mode 100644 index 0000000..4ffcda9 --- /dev/null +++ b/bot/workflow/runtime/validate.ts @@ -0,0 +1,123 @@ +import type {Condition, ValueSource, WorkflowNode, workflow_config} from '../../../types/bot/workflow/workflow.ts'; +import {assert_json} from './value.ts'; +import {node_registry} from '../registry.ts'; + +const identifier = /^[a-zA-Z0-9_-]{1,100}$/; +const forbidden = new Set(['__proto__', 'prototype', 'constructor']); +function check(value: unknown, message: string): asserts value { if (!value) throw new Error(`Invalid workflow: ${message}`); } +function record(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} +function name(value: unknown): value is string { + return typeof value === 'string' && identifier.test(value) && !forbidden.has(value); +} +function integer(value: unknown, min: number, max = 2_147_483_647) { + return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max; +} +function source(value: any) { + check(record(value), 'value source must be an object'); + if (value.kind === 'literal') { assert_json(value.value); return; } + check(value.kind === 'ref' && ['trigger', 'vars', 'outputs', 'last_result', 'inputs'].includes(value.root), 'invalid reference root'); + check(Array.isArray(value.path) && value.path.length <= 32 && value.path.every((part: unknown) => + typeof part === 'string' && !forbidden.has(part) || integer(part, 0)), 'invalid reference path'); +} +function condition(value: any, depth = 0) { + check(depth < 20 && record(value), 'invalid or deeply nested condition'); + if (value.op === 'and' || value.op === 'or') { + check(Array.isArray(value.items) && value.items.length > 0 && value.items.length <= 100, 'invalid condition items'); + value.items.forEach((item: Condition) => condition(item, depth + 1)); + } else if (value.op === 'not') condition(value.item, depth + 1); + else { check(['eq', 'contains', 'gt', 'lt'].includes(value.op), 'unknown condition operator'); source(value.left); source(value.right); } +} + +export function validate_workflow(input: unknown): asserts input is workflow_config { + assert_json(input); + const value: Record = input as Record; + check(record(value) && value.schema_version === 1 && name(value.id) && typeof value.name === 'string' + && typeof value.enabled === 'boolean', 'invalid definition'); + const start = value.start; + check(record(start), 'missing start'); + if (start.type === 'common') check(['never', 'spawn'].includes(start.auto_start), 'invalid common start'); + else if (start.type === 'timer') { + check(integer(start.interval_ms, 10), 'invalid timer interval'); + check(start.auto_start === undefined || typeof start.auto_start === 'boolean', 'invalid timer auto_start'); + check(start.mode === undefined || ['once', 'repeat'].includes(start.mode), 'invalid timer mode'); + check(start.max_runs === undefined || start.max_runs === null || integer(start.max_runs, 1, 100000), 'invalid timer max_runs'); + } + else { + check(start.type === 'event' && typeof start.event === 'string' && /^[a-zA-Z][a-zA-Z0-9_]{0,99}$/.test(start.event), 'invalid event start'); + check(start.auto_start === undefined || typeof start.auto_start === 'boolean', 'invalid event auto_start'); + check(start.mode === undefined || ['continuous', 'once'].includes(start.mode), 'invalid event mode'); + if (start.condition !== undefined) condition(start.condition); + } + check(record(value.policy) && ['skip', 'queue'].includes(value.policy.on_busy) + && integer(value.policy.max_queue, 0, 1000) && integer(value.policy.max_steps, 1, 100000) + && integer(value.policy.timeout_ms, 1), 'invalid execution policy'); + check(value.policy.max_concurrent_runs === undefined || integer(value.policy.max_concurrent_runs, 1, 256), 'invalid concurrent run limit'); + if (value.initial_vars !== undefined) check(record(value.initial_vars) + && Object.keys(value.initial_vars).every(name), 'invalid initial variables'); + check(Array.isArray(value.nodes) && value.nodes.length > 0 && value.nodes.length <= 1000, 'invalid node list'); + const ids = new Set(); + for (const node of value.nodes) { + check(record(node) && name(node.id) && !ids.has(node.id) && Object.hasOwn(node_registry, node.type) + && record(node.config), 'invalid or duplicate node'); + ids.add(node.id); + check(node.timeout_ms === undefined || integer(node.timeout_ms, 1), `invalid timeout: ${node.id}`); + check(node.name === undefined || typeof node.name === 'string', 'invalid node name'); + const config = node.config; + switch (node.type) { + case 'function': + check(typeof config.path === 'string' && config.path.split('/').every(name) && name(config.name) + && Array.isArray(config.args) && config.args.length <= 100, 'invalid function'); + config.args.forEach(source); break; + case 'query': + check(name(config.source) && record(config.args) && Object.keys(config.args).every(name) + && (config.save_as === undefined || name(config.save_as)), 'invalid query'); + Object.values(config.args).forEach(source); break; + case 'condition': condition(config.condition); break; + case 'sleep': check(['milliseconds', 'ticks'].includes(config.mode) && integer(config.value, 0), 'invalid sleep'); break; + case 'error': check(config.codes === undefined || Array.isArray(config.codes) + && config.codes.every((code: unknown) => typeof code === 'string'), 'invalid error codes'); break; + case 'goto': check(integer(config.max_retries, 0, 10000) && integer(config.delay_ms, 0), 'invalid retry'); break; + case 'merge': check(['all', 'any'].includes(config.mode), 'invalid merge mode'); break; + case 'log': check(['info', 'warning', 'error'].includes(config.level), 'invalid log level'); source(config.message); break; + case 'code': check(typeof config.source === 'string' && config.source.length <= 100000, 'invalid code'); break; + } + } + check(ids.has(value.entry), 'entry does not exist'); + check(Array.isArray(value.edges) && value.edges.length <= 10000, 'invalid edges'); + const edges = new Set(); + const outgoing = new Map(); + for (const edge of value.edges) { + check(record(edge) && name(edge.id) && !edges.has(edge.id) && ids.has(edge.source) && ids.has(edge.target) + && typeof edge.port === 'string', 'invalid or duplicate edge'); + const node = value.nodes.find((item: WorkflowNode) => item.id === edge.source) as WorkflowNode; + check(node_registry[node.type].config_template.ports.some(port => port.name === edge.port), `invalid port ${edge.port} on ${edge.source}`); + edges.add(edge.id); + outgoing.set(edge.source, [...outgoing.get(edge.source) ?? [], edge.target]); + } + for (const node of value.nodes as WorkflowNode[]) { + if (node.type === 'goto') check(value.edges.some((edge: any) => edge.source === node.id && edge.port === 'retry'), 'retry target is missing'); + if (node.type === 'merge' && node.config.mode === 'all') { + check(node.id !== value.entry && value.edges.filter((edge: any) => edge.target === node.id).length >= 2, 'all merge needs at least two incoming edges'); + } + } + const reachable = new Set(); + const queue = [value.entry]; + for (const id of queue) { + if (reachable.has(id)) continue; + reachable.add(id); + queue.push(...outgoing.get(id) ?? []); + } + check(reachable.size === ids.size, 'unreachable nodes'); +} + +export function validate_definitions(values: unknown): asserts values is workflow_config[] { + check(Array.isArray(values), 'definitions must be an array'); + const ids = new Set(); + for (const value of values) { + validate_workflow(value); + check(!ids.has(value.id), 'duplicate workflow id'); + ids.add(value.id); + } +} diff --git a/bot/workflow/runtime/value.ts b/bot/workflow/runtime/value.ts new file mode 100644 index 0000000..8b5faca --- /dev/null +++ b/bot/workflow/runtime/value.ts @@ -0,0 +1,132 @@ +import {isDeepStrictEqual} from 'node:util'; +import type {BranchState} from '../../../types/bot/workflow/runtime.ts'; +import type {Condition, Json, ValueSource} from '../../../types/bot/workflow/workflow.ts'; + +const unsafe_keys = new Set(['__proto__', 'prototype', 'constructor']); +const max_depth = 64; +const max_entries = 100_000; +const max_bytes = 1_048_576; + +function invalid(code: string, message: string): never { + throw Object.assign(new Error(message), {code}); +} + +export function assert_json(value: unknown): asserts value is Json { + const ancestors = new Set(); + let entries = 0; + let bytes = 0; + const visit = (item: unknown, depth: number): void => { + if (depth > max_depth || ++entries > max_entries) { + invalid('INVALID_JSON', 'JSON depth or entry limit exceeded'); + } + if (item === null || typeof item === 'boolean') { + bytes += item === null ? 4 : item ? 4 : 5; + } else if (typeof item === 'number') { + if (!Number.isFinite(item)) invalid('INVALID_JSON', 'JSON numbers must be finite'); + bytes += String(item).length; + } else if (typeof item === 'string') { + if (item.length > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded'); + bytes += Buffer.byteLength(JSON.stringify(item)); + } else if (typeof item === 'object') { + const array = Array.isArray(item); + const prototype = Object.getPrototypeOf(item); + if (!array && prototype !== Object.prototype && prototype !== null) { + invalid('INVALID_JSON', 'JSON objects must have a plain prototype'); + } + if (ancestors.has(item)) invalid('INVALID_JSON', 'JSON must not contain cycles'); + ancestors.add(item); + const keys = Reflect.ownKeys(item); + if (keys.length > max_entries || (array && item.length > max_entries)) { + invalid('INVALID_JSON', 'JSON entry limit exceeded'); + } + bytes += 2; + let array_entries = 0; + for (const key of keys) { + if (array && key === 'length') continue; + if (typeof key !== 'string' || unsafe_keys.has(key)) { + invalid('INVALID_JSON', 'JSON contains an unsafe key'); + } + const descriptor = Object.getOwnPropertyDescriptor(item, key)!; + if (!descriptor.enumerable || !('value' in descriptor)) { + invalid('INVALID_JSON', 'JSON properties must be enumerable data properties'); + } + if (array) { + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= item.length) { + invalid('INVALID_JSON', 'JSON arrays must only contain indexed values'); + } + array_entries++; + } else { + if (key.length > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded'); + bytes += Buffer.byteLength(JSON.stringify(key)) + 1; + } + bytes++; + visit(descriptor.value, depth + 1); + } + if (array && array_entries !== item.length) invalid('INVALID_JSON', 'JSON arrays must not contain holes'); + ancestors.delete(item); + } else { + invalid('INVALID_JSON', 'Value is not JSON'); + } + if (bytes > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded'); + }; + visit(value, 0); +} + +export function resolve_value(source: ValueSource, state: BranchState): Json { + if (source.kind === 'literal') { + assert_json(source.value); + return source.value; + } + if (source.kind !== 'ref' || !['trigger', 'vars', 'outputs', 'last_result', 'inputs'].includes(source.root) + || !Array.isArray(source.path) || source.path.length > max_depth) { + invalid('INVALID_VALUE_SOURCE', 'Invalid value reference'); + } + let value: unknown = state[source.root]; + for (const segment of source.path) { + if ((typeof segment !== 'string' && typeof segment !== 'number') + || (typeof segment === 'number' && (!Number.isSafeInteger(segment) || segment < 0)) + || unsafe_keys.has(String(segment))) { + invalid('INVALID_VALUE_SOURCE', 'Invalid value reference path'); + } + if (value === null || typeof value !== 'object') invalid('VALUE_NOT_FOUND', 'Value reference was not found'); + const descriptor = Object.getOwnPropertyDescriptor(value, segment); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + invalid('VALUE_NOT_FOUND', 'Value reference was not found'); + } + value = descriptor.value; + } + assert_json(value); + return value; +} + +export function evaluate_condition(condition: Condition, state: BranchState): boolean { + let entries = 0; + const evaluate = (item: Condition, depth: number): boolean => { + if (depth > max_depth || ++entries > 10_000) invalid('INVALID_CONDITION', 'Condition limit exceeded'); + switch (item.op) { + case 'and': return item.items.every(child => evaluate(child, depth + 1)); + case 'or': return item.items.some(child => evaluate(child, depth + 1)); + case 'not': return !evaluate(item.item, depth + 1); + case 'eq': return isDeepStrictEqual(resolve_value(item.left, state), resolve_value(item.right, state)); + case 'contains': { + const left = resolve_value(item.left, state); + const right = resolve_value(item.right, state); + if (typeof left === 'string' && typeof right === 'string') return left.includes(right); + if (Array.isArray(left)) return left.some(value => isDeepStrictEqual(value, right)); + invalid('INVALID_CONDITION', 'contains requires a string pair or an array'); + } + case 'gt': + case 'lt': { + const left = resolve_value(item.left, state); + const right = resolve_value(item.right, state); + if (typeof left !== 'number' || typeof right !== 'number') { + invalid('INVALID_CONDITION', 'Numeric comparison requires numbers'); + } + return item.op === 'gt' ? left > right : left < right; + } + default: return invalid('INVALID_CONDITION', 'Unknown condition operator'); + } + }; + return evaluate(condition, 0); +} diff --git a/bot/workflow/start_node/common_node.ts b/bot/workflow/start_node/common_node.ts new file mode 100644 index 0000000..4909deb --- /dev/null +++ b/bot/workflow/start_node/common_node.ts @@ -0,0 +1,40 @@ +import type {bot_instance_context} from '../../instance/bot_instance_init.ts'; +import type {Json} from '../../../types/bot/workflow/workflow.ts'; +import type {WorkflowStartTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, output} from '../metadata.ts'; + +export const config_template: WorkflowStartTemplate = { + name: 'common', version: '1.0.0', + i18n: describe('普通启动', '手动启动工作流,也可在机器人出生后自动启动。'), + config: [{ + name: 'auto_start', type: 'select', required: true, default: 'never', + i18n: describe('自动启动', '选择是否在机器人出生后自动启动工作流。'), + options: [ + {value: 'never', i18n: describe('不自动启动', '仅通过手动请求启动。')}, + {value: 'spawn', i18n: describe('出生时启动', '机器人出生并就绪后自动启动。')}, + ], + }], + output: { + ...output('object', '启动数据', '手动启动时为调用方提供的 JSON 对象;自动启动时为 {event: "spawn"}。'), + additional_properties: output('json', '触发字段', '手动启动时传入的 JSON 字段值。'), + }, +}; + +export function register_common_start(context: Pick, 'on' | 'off'>, + start: (trigger: Record) => void, initially_ready = false): () => void { + let disposed = false; + let spawned = false; + const on_spawn = () => { + if (disposed || spawned) return; + spawned = true; + start({event: 'spawn'}); + }; + try { context.on('spawn', on_spawn); } + catch (error) { context.off('spawn', on_spawn); throw error; } + if (initially_ready) queueMicrotask(() => { if (!disposed && !spawned) on_spawn(); }); + return () => { + if (disposed) return; + disposed = true; + context.off('spawn', on_spawn); + }; +} diff --git a/bot/workflow/start_node/event_node.ts b/bot/workflow/start_node/event_node.ts new file mode 100644 index 0000000..bb41496 --- /dev/null +++ b/bot/workflow/start_node/event_node.ts @@ -0,0 +1,68 @@ +import type {bot_event_name, bot_instance_context} from '../../instance/bot_instance_init.ts'; +import type {BranchState} from '../../../types/bot/workflow/runtime.ts'; +import type {Json, workflow_config} from '../../../types/bot/workflow/workflow.ts'; +import type {WorkflowStartTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {assert_json, evaluate_condition} from '../runtime/value.ts'; +import {normalize_event} from '../node_action/event_node/registry.ts'; +import {describe, output} from '../metadata.ts'; + +export const config_template: WorkflowStartTemplate = { + name: 'event', version: '1.0.0', + i18n: describe('事件启动', '机器人出生并就绪后,监听指定事件并按可选条件启动工作流。'), + config: [ + {name: 'auto_start', type: 'boolean', required: false, default: true, + i18n: describe('自动监听', '机器人连接并出生后自动监听事件。')}, + {name: 'mode', type: 'select', required: false, default: 'continuous', + i18n: describe('监听模式', '持续监听,或在一次启动被接受后停止监听。'), options: [ + {value: 'continuous', i18n: describe('持续', '持续接受匹配的事件。')}, + {value: 'once', i18n: describe('一次', '接受一次启动或排队后停止;忙碌跳过时继续监听。')}, + ]}, + {name: 'event', type: 'string', required: true, provider: 'event', + i18n: describe('事件名称', '选择已描述的事件或填写自定义事件名称;不允许 workflow_update。')}, + {name: 'condition', type: 'condition', required: false, + i18n: describe('触发条件', '使用标准化后的 trigger 数据判断;支持多个条件全部满足(and/且)、任一满足(or/或)和嵌套分组。条件为真时启动,引用缺失时跳过。')}, + ], + output: { + ...output('object', '事件数据', '具体字段由所选事件模板定义;未知事件输出 {event, args}。'), + properties: {event: output('string', '事件名称', '当前订阅的事件名称。')}, + additional_properties: output('json', '事件字段', '事件适配器提供的有界 JSON 数据。'), + }, +}; + +export function create_event_handler(definition: workflow_config, + start: (trigger: Record) => void): (...args: unknown[]) => void { + if (definition.start.type !== 'event' || definition.start.event === 'workflow_update') { + throw new Error('Invalid workflow event trigger'); + } + const config = definition.start; + return (...args: unknown[]) => { + if (config.event === 'any' && args[0] === 'workflow_update') return; + try { + if (args.length > 32) return; + const trigger = normalize_event(config.event, args); + if (!trigger) return; + assert_json(trigger); + if (Buffer.byteLength(JSON.stringify(trigger)) > 65_536) return; + if (config.condition) { + const state: BranchState = {trigger, vars: structuredClone(definition.initial_vars ?? {}), + outputs: {}, last_result: null, inputs: {}, retry_counts: {}}; + if (!evaluate_condition(config.condition, state)) return; + } + start(trigger); + } catch { /* Unsupported payloads and missing condition references skip this occurrence. */ } + }; +} + +export function register_event_start(context: Pick, 'on' | 'off'>, + definition: workflow_config, start: (trigger: Record) => void): () => void { + const callback = create_event_handler(definition, start); + const event = (definition.start as {event: string}).event as bot_event_name; + try { context.on(event, callback); } + catch (error) { context.off(event, callback); throw error; } + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + context.off(event, callback); + }; +} diff --git a/bot/workflow/start_node/timer_node.ts b/bot/workflow/start_node/timer_node.ts new file mode 100644 index 0000000..44e98d9 --- /dev/null +++ b/bot/workflow/start_node/timer_node.ts @@ -0,0 +1,55 @@ +import type {Json} from '../../../types/bot/workflow/workflow.ts'; +import type {WorkflowStartTemplate} from '../../../types/bot/workflow/metadata.ts'; +import {describe, output} from '../metadata.ts'; + +export const config_template: WorkflowStartTemplate = { + name: 'timer', version: '1.0.0', + i18n: describe('定时启动', '机器人出生并就绪后,按指定毫秒间隔启动工作流。'), + config: [ + {name: 'auto_start', type: 'boolean', required: false, default: true, + i18n: describe('自动定时', '机器人连接并出生后自动启动定时器。')}, + {name: 'mode', type: 'select', required: false, default: 'repeat', + i18n: describe('定时模式', '执行一次或按间隔重复执行。'), options: [ + {value: 'once', i18n: describe('一次', '一次启动被接受后停止。')}, + {value: 'repeat', i18n: describe('重复', '按间隔持续尝试启动。')}, + ]}, + {name: 'max_runs', type: 'number', nullable: true, required: false, default: null, + integer: true, min: 1, max: 100000, + i18n: describe('最大启动次数', '仅计入已启动或排队的请求;null 表示无限次。')}, + { + name: 'interval_ms', type: 'number', required: true, integer: true, + min: 10, max: 2_147_483_647, default: 1000, + i18n: describe('间隔毫秒', '定时触发的间隔,必须是至少 10 毫秒的安全整数。'), + }], + output: { + ...output('object', '定时数据', '每次定时触发时生成的事件名称和时间戳。'), + properties: { + event: output('string', '事件名称', '固定为 timer。'), + timestamp: output('number', '触发时间', '触发时的 Unix 时间戳,单位为毫秒。'), + }, + }, +}; + +export function schedule_timer_start(interval_ms: number, + start: (trigger: Record) => void): () => void { + if (!Number.isSafeInteger(interval_ms) || interval_ms < 10 || interval_ms > 2_147_483_647) { + throw new Error('Invalid workflow timer interval'); + } + const timer = setTimeout(() => start({event: 'timer', timestamp: Date.now()}), interval_ms); + timer.unref?.(); + return () => clearTimeout(timer); +} + +export function register_timer_start(interval_ms: number, + start: (trigger: Record) => void): () => void { + let disposed = false; + let cancel: () => void; + const schedule = () => { + cancel = schedule_timer_start(interval_ms, trigger => { + try { start(trigger); } + finally { if (!disposed) schedule(); } + }); + }; + schedule(); + return () => { disposed = true; cancel(); }; +} diff --git a/data/runtime/language/zh_cn.json b/data/runtime/language/zh_cn.json new file mode 100644 index 0000000..840ef5b --- /dev/null +++ b/data/runtime/language/zh_cn.json @@ -0,0 +1,8651 @@ +{ + "accessibility.onboarding.accessibility.button": "辅助功能设置…", + "accessibility.onboarding.accessibility.button.narration": "辅助功能设置", + "accessibility.onboarding.screen.narrator": "按下Enter键启用复述功能", + "accessibility.onboarding.screen.title": "欢迎进入Minecraft!\n\n需要启用复述功能或访问辅助功能设置吗?", + "addServer.add": "完成", + "addServer.enterIp": "服务器地址", + "addServer.enterName": "服务器名称", + "addServer.resourcePack": "服务器资源包", + "addServer.resourcePack.disabled": "禁用", + "addServer.resourcePack.enabled": "启用", + "addServer.resourcePack.prompt": "询问", + "addServer.title": "编辑服务器信息", + "advMode.command": "控制台命令", + "advMode.mode": "模式", + "advMode.mode.auto": "循环", + "advMode.mode.autoexec.bat": "保持开启", + "advMode.mode.conditional": "条件制约", + "advMode.mode.redstone": "脉冲", + "advMode.mode.redstoneTriggered": "红石控制", + "advMode.mode.sequence": "连锁", + "advMode.mode.unconditional": "不受制约", + "advMode.notAllowed": "必须是处于创造模式的管理员", + "advMode.notEnabled": "命令方块未启用", + "advMode.notEnabled.spawner": "刷怪笼方块未启用", + "advMode.previousOutput": "上一个输出", + "advMode.setCommand": "设置此方块的控制台命令", + "advMode.setCommand.disabled": "成功设置命令:%s,但命令方块仍被禁用", + "advMode.setCommand.success": "成功设置命令:%s", + "advMode.trackOutput": "记录输出", + "advMode.triggering": "触发方式", + "advMode.type": "类型", + "advancement.advancementNotFound": "未知的进度“%s”", + "advancements.adventure.adventuring_time.description": "发现所有的生物群系", + "advancements.adventure.adventuring_time.title": "探索的时光", + "advancements.adventure.arbalistic.description": "用弩一发击杀五种生物", + "advancements.adventure.arbalistic.title": "劲弩手", + "advancements.adventure.avoid_vibration.description": "在幽匿感测体或监守者周围潜行以防被它们探测到", + "advancements.adventure.avoid_vibration.title": "潜行100级", + "advancements.adventure.blowback.description": "反弹旋风人的风弹来击杀旋风人", + "advancements.adventure.blowback.title": "逆风翻盘", + "advancements.adventure.brush_armadillo.description": "用刷子从犰狳身上获得犰狳鳞甲", + "advancements.adventure.brush_armadillo.title": "这不是鳞甲么?", + "advancements.adventure.bullseye.description": "从至少30米外射中标靶的靶心", + "advancements.adventure.bullseye.title": "正中靶心", + "advancements.adventure.craft_decorated_pot_using_only_sherds.description": "用4个陶片制作饰纹陶罐", + "advancements.adventure.craft_decorated_pot_using_only_sherds.title": "精修细补", + "advancements.adventure.crafters_crafting_crafters.description": "靠近一个正在合成合成器的合成器", + "advancements.adventure.crafters_crafting_crafters.title": "合成器合成合成器", + "advancements.adventure.fall_from_world_height.description": "从世界顶部(建筑高度限制处)自由落体,坠至世界底部并存活下来", + "advancements.adventure.fall_from_world_height.title": "上天入地", + "advancements.adventure.heart_transplanter.description": "将嘎枝之心以正确朝向放在两个苍白橡木原木方块中间", + "advancements.adventure.heart_transplanter.title": "移心接木", + "advancements.adventure.hero_of_the_village.description": "成功在袭击中保卫村庄", + "advancements.adventure.hero_of_the_village.title": "村庄英雄", + "advancements.adventure.honey_block_slide.description": "跳入蜂蜜块以缓冲摔落", + "advancements.adventure.honey_block_slide.title": "胶着状态", + "advancements.adventure.kill_a_mob.description": "杀死任意敌对性怪物", + "advancements.adventure.kill_a_mob.title": "怪物猎人", + "advancements.adventure.kill_all_mobs.description": "杀死每一种敌对性怪物", + "advancements.adventure.kill_all_mobs.title": "资深怪物猎人", + "advancements.adventure.kill_mob_near_sculk_catalyst.description": "在幽匿催发体附近杀死生物", + "advancements.adventure.kill_mob_near_sculk_catalyst.title": "它蔓延了", + "advancements.adventure.lighten_up.description": "用斧刮削铜灯来让它变得更亮", + "advancements.adventure.lighten_up.title": "铜光焕发", + "advancements.adventure.lightning_rod_with_villager_no_fire.description": "在不引发火灾的前提下保护村民免受雷击", + "advancements.adventure.lightning_rod_with_villager_no_fire.title": "电涌保护器", + "advancements.adventure.minecraft_trials_edition.description": "踏入试炼密室", + "advancements.adventure.minecraft_trials_edition.title": "Minecraft:试炼版", + "advancements.adventure.ol_betsy.description": "用弩进行一次射击", + "advancements.adventure.ol_betsy.title": "扣下悬刀", + "advancements.adventure.overoverkill.description": "使用重锤一击造成50颗心的伤害", + "advancements.adventure.overoverkill.title": "天赐良击", + "advancements.adventure.play_jukebox_in_meadows.description": "用唱片机的音乐声为草甸增添生机", + "advancements.adventure.play_jukebox_in_meadows.title": "音乐之声", + "advancements.adventure.read_power_from_chiseled_bookshelf.description": "使用红石比较器获取雕纹书架的信号强度", + "advancements.adventure.read_power_from_chiseled_bookshelf.title": "知识就是力量", + "advancements.adventure.revaulting.description": "用不祥试炼钥匙解锁不祥宝库", + "advancements.adventure.revaulting.title": "宝经磨炼", + "advancements.adventure.root.description": "冒险、探索与战斗", + "advancements.adventure.root.title": "冒险", + "advancements.adventure.salvage_sherd.description": "刷扫可疑的方块来获得陶片", + "advancements.adventure.salvage_sherd.title": "探古寻源", + "advancements.adventure.shoot_arrow.description": "用弓箭射点什么", + "advancements.adventure.shoot_arrow.title": "瞄准目标", + "advancements.adventure.sleep_in_bed.description": "在床上睡觉以改变你的重生点", + "advancements.adventure.sleep_in_bed.title": "甜蜜的梦", + "advancements.adventure.sniper_duel.description": "从至少50米外射杀一只骷髅", + "advancements.adventure.sniper_duel.title": "狙击手的对决", + "advancements.adventure.spear_many_mobs.description": "用矛在一次冲锋攻击内命中五只生物", + "advancements.adventure.spear_many_mobs.title": "生物串串香", + "advancements.adventure.spyglass_at_dragon.description": "透过望远镜观察末影龙", + "advancements.adventure.spyglass_at_dragon.title": "那是飞机吗?", + "advancements.adventure.spyglass_at_ghast.description": "透过望远镜观察恶魂", + "advancements.adventure.spyglass_at_ghast.title": "那是气球吗?", + "advancements.adventure.spyglass_at_parrot.description": "透过望远镜观察鹦鹉", + "advancements.adventure.spyglass_at_parrot.title": "那是鸟吗?", + "advancements.adventure.summon_iron_golem.description": "召唤一只铁傀儡来帮忙守卫村庄", + "advancements.adventure.summon_iron_golem.title": "招募援兵", + "advancements.adventure.throw_trident.description": "往什么东西扔出三叉戟。\n注:别把你唯一的武器也抖掉了。", + "advancements.adventure.throw_trident.title": "抖包袱", + "advancements.adventure.totem_of_undying.description": "利用不死图腾逃离死神", + "advancements.adventure.totem_of_undying.title": "超越生死", + "advancements.adventure.trade.description": "成功与一名村民进行交易", + "advancements.adventure.trade.title": "成交!", + "advancements.adventure.trade_at_world_height.description": "在建筑高度限制处与村民交易", + "advancements.adventure.trade_at_world_height.title": "星际商人", + "advancements.adventure.trim_with_all_exclusive_armor_patterns.description": "将下列锻造模板都至少使用一次:尖塔、猪鼻、肋骨、监守、幽静、恼鬼、潮汐、向导", + "advancements.adventure.trim_with_all_exclusive_armor_patterns.title": "匠心独具", + "advancements.adventure.trim_with_any_armor_pattern.description": "在锻造台中合成带有纹饰的盔甲", + "advancements.adventure.trim_with_any_armor_pattern.title": "旧貌锻新颜", + "advancements.adventure.two_birds_one_arrow.description": "用一支穿透箭射杀两只幻翼", + "advancements.adventure.two_birds_one_arrow.title": "一箭双雕", + "advancements.adventure.under_lock_and_key.description": "用试炼钥匙解锁宝库", + "advancements.adventure.under_lock_and_key.title": "珍藏密敛", + "advancements.adventure.use_lodestone.description": "对着磁石使用指南针", + "advancements.adventure.use_lodestone.title": "天涯共此石", + "advancements.adventure.very_very_frightening.description": "雷击一名村民", + "advancements.adventure.very_very_frightening.title": "魔女审判", + "advancements.adventure.voluntary_exile.description": "杀死一名袭击队长。\n或许该考虑暂时远离村庄……", + "advancements.adventure.voluntary_exile.title": "自我放逐", + "advancements.adventure.walk_on_powder_snow_with_leather_boots.description": "在细雪上行走……并且不陷进去", + "advancements.adventure.walk_on_powder_snow_with_leather_boots.title": "轻功雪上飘", + "advancements.adventure.who_needs_rockets.description": "使用风弹将自己向上弹射8格", + "advancements.adventure.who_needs_rockets.title": "还要啥火箭啊?", + "advancements.adventure.whos_the_pillager_now.description": "让掠夺者也尝尝弩的滋味", + "advancements.adventure.whos_the_pillager_now.title": "现在谁才是掠夺者?", + "advancements.empty": "这里好像什么都没有……", + "advancements.end.dragon_breath.description": "用玻璃瓶收集一些龙息", + "advancements.end.dragon_breath.title": "你需要来点薄荷糖", + "advancements.end.dragon_egg.description": "获得龙蛋", + "advancements.end.dragon_egg.title": "下一世代", + "advancements.end.elytra.description": "找到鞘翅", + "advancements.end.elytra.title": "天空即为极限", + "advancements.end.enter_end_gateway.description": "逃离这座岛屿", + "advancements.end.enter_end_gateway.title": "远程折跃", + "advancements.end.find_end_city.description": "进去吧,又能发生什么呢?", + "advancements.end.find_end_city.title": "在游戏尽头的城市", + "advancements.end.kill_dragon.description": "祝君好运", + "advancements.end.kill_dragon.title": "解放末地", + "advancements.end.levitate.description": "利用潜影贝的攻击向上飘浮50个方块", + "advancements.end.levitate.title": "这上面的风景不错", + "advancements.end.respawn_dragon.description": "复活末影龙", + "advancements.end.respawn_dragon.title": "结束了…再一次…", + "advancements.end.root.description": "抑或是起点?", + "advancements.end.root.title": "末地", + "advancements.husbandry.allay_deliver_cake_to_note_block.description": "让悦灵向音符盒投掷一块蛋糕", + "advancements.husbandry.allay_deliver_cake_to_note_block.title": "生日快乐歌", + "advancements.husbandry.allay_deliver_item_to_player.description": "让悦灵向你投掷物品", + "advancements.husbandry.allay_deliver_item_to_player.title": "找到一个好朋友", + "advancements.husbandry.axolotl_in_a_bucket.description": "用铁桶捕获一只美西螈", + "advancements.husbandry.axolotl_in_a_bucket.title": "最萌捕食者", + "advancements.husbandry.balanced_diet.description": "尝遍天下食材,即便是对身体不好的", + "advancements.husbandry.balanced_diet.title": "均衡饮食", + "advancements.husbandry.breed_all_animals.description": "繁殖每种动物!", + "advancements.husbandry.breed_all_animals.title": "成双成对", + "advancements.husbandry.breed_an_animal.description": "繁殖一对动物", + "advancements.husbandry.breed_an_animal.title": "我从哪儿来?", + "advancements.husbandry.complete_catalogue.description": "驯服所有种类的猫!", + "advancements.husbandry.complete_catalogue.title": "百猫全书", + "advancements.husbandry.feed_snifflet.description": "喂食一只小嗅探兽", + "advancements.husbandry.feed_snifflet.title": "小小嗅探兽", + "advancements.husbandry.fishy_business.description": "钓到一条鱼", + "advancements.husbandry.fishy_business.title": "腥味十足的生意", + "advancements.husbandry.froglights.description": "在你的物品栏中集齐所有种类的蛙明灯", + "advancements.husbandry.froglights.title": "相映生辉!", + "advancements.husbandry.kill_axolotl_target.description": "与美西螈并肩作战并赢得胜利", + "advancements.husbandry.kill_axolotl_target.title": "友谊的治愈力!", + "advancements.husbandry.leash_all_frog_variants.description": "用拴绳拴住所有种类的青蛙", + "advancements.husbandry.leash_all_frog_variants.title": "呱呱队出动", + "advancements.husbandry.make_a_sign_glow.description": "让任意种类告示牌上的文本发光", + "advancements.husbandry.make_a_sign_glow.title": "眼前一亮!", + "advancements.husbandry.netherite_hoe.description": "用下界合金锭升级一把锄,然后重新考虑你的人生抉择", + "advancements.husbandry.netherite_hoe.title": "终极奉献", + "advancements.husbandry.obtain_sniffer_egg.description": "获得嗅探兽蛋", + "advancements.husbandry.obtain_sniffer_egg.title": "怪味蛋", + "advancements.husbandry.place_dried_ghast_in_water.description": "将失水恶魂方块放在水中", + "advancements.husbandry.place_dried_ghast_in_water.title": "补水保湿!", + "advancements.husbandry.plant_any_sniffer_seed.description": "种植任意嗅探兽种子", + "advancements.husbandry.plant_any_sniffer_seed.title": "播种往事", + "advancements.husbandry.plant_seed.description": "种下种子,见证它的成长", + "advancements.husbandry.plant_seed.title": "开荒垦地", + "advancements.husbandry.remove_wolf_armor.description": "用剪刀移除狼身上的狼铠", + "advancements.husbandry.remove_wolf_armor.title": "华丽一剪", + "advancements.husbandry.repair_wolf_armor.description": "用犰狳鳞甲修复损坏的狼铠", + "advancements.husbandry.repair_wolf_armor.title": "完好如初", + "advancements.husbandry.ride_a_boat_with_a_goat.description": "与山羊同船共渡", + "advancements.husbandry.ride_a_boat_with_a_goat.title": "羊帆起航!", + "advancements.husbandry.root.description": "这个世界充满朋友与美食", + "advancements.husbandry.root.title": "农牧业", + "advancements.husbandry.safely_harvest_honey.description": "利用营火,在不惊动蜜蜂的情况下,用玻璃瓶从蜂巢收集蜂蜜", + "advancements.husbandry.safely_harvest_honey.title": "与蜂共舞", + "advancements.husbandry.silk_touch_nest.description": "用精准采集移动住着3只蜜蜂的蜂巢或蜂箱", + "advancements.husbandry.silk_touch_nest.title": "举巢搬迁", + "advancements.husbandry.tactical_fishing.description": "不用钓鱼竿抓住一条鱼!", + "advancements.husbandry.tactical_fishing.title": "战术性钓鱼", + "advancements.husbandry.tadpole_in_a_bucket.description": "用铁桶捕获一只蝌蚪", + "advancements.husbandry.tadpole_in_a_bucket.title": "蚪到桶里来", + "advancements.husbandry.tame_an_animal.description": "驯服一只动物", + "advancements.husbandry.tame_an_animal.title": "永恒的伙伴", + "advancements.husbandry.uh_oh.description": "让硫方怪吸收TNT方块", + "advancements.husbandry.uh_oh.title": "坏了", + "advancements.husbandry.wax_off.description": "给铜质方块脱蜡!", + "advancements.husbandry.wax_off.title": "脱蜡", + "advancements.husbandry.wax_on.description": "将蜜脾涂到铜质方块上!", + "advancements.husbandry.wax_on.title": "涂蜡", + "advancements.husbandry.whole_pack.description": "驯服所有种类的狼", + "advancements.husbandry.whole_pack.title": "群狼聚首", + "advancements.nether.all_effects.description": "同时拥有所有状态效果", + "advancements.nether.all_effects.title": "为什么会变成这样呢?", + "advancements.nether.all_potions.description": "同时拥有所有药水效果", + "advancements.nether.all_potions.title": "狂乱的鸡尾酒", + "advancements.nether.brew_potion.description": "酿造一瓶药水", + "advancements.nether.brew_potion.title": "本地酿造厂", + "advancements.nether.charge_respawn_anchor.description": "为重生锚充满能量", + "advancements.nether.charge_respawn_anchor.title": "锚没有九条命", + "advancements.nether.create_beacon.description": "建造并放置一个信标", + "advancements.nether.create_beacon.title": "带信标回家", + "advancements.nether.create_full_beacon.description": "让一座信标发挥最大功效", + "advancements.nether.create_full_beacon.title": "信标工程师", + "advancements.nether.distract_piglin.description": "用金质物品让猪灵分神", + "advancements.nether.distract_piglin.title": "金光闪闪!", + "advancements.nether.explore_nether.description": "探索所有下界生物群系", + "advancements.nether.explore_nether.title": "热门景点", + "advancements.nether.fast_travel.description": "利用下界移动对应主世界7千米的距离", + "advancements.nether.fast_travel.title": "曲速泡", + "advancements.nether.find_bastion.description": "进入堡垒遗迹", + "advancements.nether.find_bastion.title": "光辉岁月", + "advancements.nether.find_fortress.description": "闯入下界要塞", + "advancements.nether.find_fortress.title": "阴森的要塞", + "advancements.nether.get_wither_skull.description": "获得凋灵骷髅的头颅", + "advancements.nether.get_wither_skull.title": "惊悚恐怖骷髅头", + "advancements.nether.loot_bastion.description": "掠夺堡垒遗迹里的箱子", + "advancements.nether.loot_bastion.title": "战猪", + "advancements.nether.netherite_armor.description": "获得一整套下界合金盔甲", + "advancements.nether.netherite_armor.title": "残骸裹身", + "advancements.nether.obtain_ancient_debris.description": "获得远古残骸", + "advancements.nether.obtain_ancient_debris.title": "深藏不露", + "advancements.nether.obtain_blaze_rod.description": "让烈焰人从烈焰棒中解放吧", + "advancements.nether.obtain_blaze_rod.title": "与火共舞", + "advancements.nether.obtain_crying_obsidian.description": "获得哭泣的黑曜石", + "advancements.nether.obtain_crying_obsidian.title": "谁在切洋葱?", + "advancements.nether.return_to_sender.description": "用一团火球干掉一只恶魂", + "advancements.nether.return_to_sender.title": "见鬼去吧", + "advancements.nether.ride_strider.description": "手持诡异菌钓竿骑乘炽足兽", + "advancements.nether.ride_strider.title": "画船添足", + "advancements.nether.ride_strider_in_overworld_lava.description": "带炽足兽在主世界的熔岩湖上来一场长——途旅行", + "advancements.nether.ride_strider_in_overworld_lava.title": "温暖如家", + "advancements.nether.root.description": "记得带夏装", + "advancements.nether.root.title": "下界", + "advancements.nether.summon_wither.description": "召唤凋灵", + "advancements.nether.summon_wither.title": "凋零山庄", + "advancements.nether.uneasy_alliance.description": "从下界救出一只恶魂,将其安全地带到主世界……然后干掉它", + "advancements.nether.uneasy_alliance.title": "脆弱的同盟", + "advancements.nether.use_lodestone.description": "对着磁石使用指南针", + "advancements.nether.use_lodestone.title": "天涯共此石", + "advancements.progress": "%s/%s", + "advancements.sad_label": ":(", + "advancements.story.cure_zombie_villager.description": "弱化并治疗一名僵尸村民", + "advancements.story.cure_zombie_villager.title": "僵尸科医生", + "advancements.story.deflect_arrow.description": "用盾牌反弹一个弹射物", + "advancements.story.deflect_arrow.title": "不吃这套,谢谢", + "advancements.story.enchant_item.description": "用附魔台附魔一样物品", + "advancements.story.enchant_item.title": "附魔师", + "advancements.story.enter_the_end.description": "进入末地传送门", + "advancements.story.enter_the_end.title": "结束了?", + "advancements.story.enter_the_nether.description": "建造、激活并进入一座下界传送门", + "advancements.story.enter_the_nether.title": "勇往直下", + "advancements.story.follow_ender_eye.description": "跟随末影之眼", + "advancements.story.follow_ender_eye.title": "隔墙有眼", + "advancements.story.form_obsidian.description": "获得一块黑曜石", + "advancements.story.form_obsidian.title": "冰桶挑战", + "advancements.story.iron_tools.description": "升级你的镐", + "advancements.story.iron_tools.title": "这不是铁镐么?", + "advancements.story.lava_bucket.description": "用铁桶装点熔岩", + "advancements.story.lava_bucket.title": "热腾腾的", + "advancements.story.mine_diamond.description": "获得钻石", + "advancements.story.mine_diamond.title": "钻石!", + "advancements.story.mine_stone.description": "用你的新镐挖掘石头", + "advancements.story.mine_stone.title": "石器时代", + "advancements.story.obtain_armor.description": "用铁盔甲来保护你自己", + "advancements.story.obtain_armor.title": "整装上阵", + "advancements.story.root.description": "游戏的核心与故事", + "advancements.story.root.title": "Minecraft", + "advancements.story.shiny_gear.description": "钻石盔甲能救人", + "advancements.story.shiny_gear.title": "钻石护体", + "advancements.story.smelt_iron.description": "冶炼出一块铁锭", + "advancements.story.smelt_iron.title": "来硬的", + "advancements.story.upgrade_tools.description": "制作一把更好的镐", + "advancements.story.upgrade_tools.title": "获得升级", + "advancements.toast.challenge": "挑战已完成!", + "advancements.toast.goal": "目标已达成!", + "advancements.toast.task": "进度已达成!", + "argument.anchor.invalid": "无效的实体锚点“%s”", + "argument.angle.incomplete": "不完整(应有1个角度)", + "argument.angle.invalid": "无效的角度", + "argument.block.id.invalid": "未知的方块类型“%s”", + "argument.block.property.duplicate": "“%s”属性只能给%s设置一次", + "argument.block.property.invalid": "%1$s的%3$s属性不能被设为“%2$s”", + "argument.block.property.novalue": "方块%2$s上的属性“%1$s”应有值", + "argument.block.property.unclosed": "方块属性应以]结束", + "argument.block.property.unknown": "方块%s没有属性“%s”", + "argument.block.tag.disallowed": "无法在此使用标签,只允许使用实际的方块", + "argument.color.invalid": "未知的颜色“%s”", + "argument.component.invalid": "无效的聊天组件“%s”", + "argument.criteria.invalid": "未知的准则“%s”", + "argument.dimension.invalid": "未知的维度“%s”", + "argument.double.big": "双精度浮点型数据不能大于%s:实际为%s", + "argument.double.low": "双精度浮点型数据不能小于%s:实际为%s", + "argument.entity.invalid": "无效的名称或UUID", + "argument.entity.notfound.entity": "未找到实体", + "argument.entity.notfound.player": "未找到玩家", + "argument.entity.options.advancements.description": "玩家拥有的进度", + "argument.entity.options.distance.description": "与实体间的距离", + "argument.entity.options.distance.negative": "距离不能为负", + "argument.entity.options.dx.description": "位于x与x+dx之间的实体", + "argument.entity.options.dy.description": "位于y与y+dy之间的实体", + "argument.entity.options.dz.description": "位于z与z+dz之间的实体", + "argument.entity.options.gamemode.description": "玩家的游戏模式", + "argument.entity.options.inapplicable": "“%s”选项不适用于这里", + "argument.entity.options.level.description": "经验等级", + "argument.entity.options.level.negative": "等级不应该为负数", + "argument.entity.options.limit.description": "最大返回实体数", + "argument.entity.options.limit.toosmall": "限制必须至少为1", + "argument.entity.options.mode.invalid": "无效或未知的游戏模式“%s”", + "argument.entity.options.name.description": "实体名称", + "argument.entity.options.nbt.description": "实体所带的NBT", + "argument.entity.options.predicate.description": "自定义谓词", + "argument.entity.options.scores.description": "实体的分数", + "argument.entity.options.sort.description": "对实体排序", + "argument.entity.options.sort.irreversible": "无效或未知的排序类型“%s”", + "argument.entity.options.tag.description": "实体所带的标签", + "argument.entity.options.team.description": "实体所在的队伍", + "argument.entity.options.type.description": "实体类型", + "argument.entity.options.type.invalid": "无效或未知的实体类型“%s”", + "argument.entity.options.unknown": "未知的选项“%s”", + "argument.entity.options.unterminated": "选项的方括号不成对", + "argument.entity.options.valueless": "选项“%s”应有值", + "argument.entity.options.x.description": "X轴位置", + "argument.entity.options.x_rotation.description": "实体的X轴旋转角度", + "argument.entity.options.y.description": "Y轴位置", + "argument.entity.options.y_rotation.description": "实体的Y轴旋转角度", + "argument.entity.options.z.description": "Z轴位置", + "argument.entity.selector.allEntities": "所有实体", + "argument.entity.selector.allPlayers": "所有玩家", + "argument.entity.selector.missing": "缺少选择器类型", + "argument.entity.selector.nearestEntity": "距离最近的实体", + "argument.entity.selector.nearestPlayer": "距离最近的玩家", + "argument.entity.selector.not_allowed": "不能使用选择器", + "argument.entity.selector.randomPlayer": "随机玩家", + "argument.entity.selector.self": "当前实体", + "argument.entity.selector.unknown": "未知的选择器类型“%s”", + "argument.entity.toomany": "只允许一个实体,但提供的选择器允许多个实体", + "argument.enum.invalid": "无效的值“%s”", + "argument.float.big": "单精度浮点型数据不能大于%s:实际为%s", + "argument.float.low": "单精度浮点型数据不能小于%s:实际为%s", + "argument.gamemode.invalid": "未知的游戏模式“%s”", + "argument.hexcolor.invalid": "无效的十六进制颜色代码:“%s”", + "argument.id.invalid": "无效的ID", + "argument.id.unknown": "未知的ID“%s”", + "argument.integer.big": "整型数据不能大于%s:实际为%s", + "argument.integer.low": "整型数据不能小于%s:实际为%s", + "argument.item.id.invalid": "未知的物品“%s”", + "argument.item.tag.disallowed": "无法在此使用标签,只允许使用实际的物品", + "argument.literal.incorrect": "应为字面量%s", + "argument.long.big": "长整型数据不能大于%s:实际为%s", + "argument.long.low": "长整型数据不能小于%s:实际为%s", + "argument.message.too_long": "聊天消息过长(%s大于上限%s个字符)", + "argument.nbt.array.invalid": "无效的数组类型“%s”", + "argument.nbt.array.mixed": "无法将%s插入%s", + "argument.nbt.expected.compound": "应为复合标签", + "argument.nbt.expected.key": "需要键", + "argument.nbt.expected.value": "需要值", + "argument.nbt.list.mixed": "无法将%s插入%s的列表", + "argument.nbt.trailing": "多余的尾随数据", + "argument.player.entities": "只有玩家会受此命令的影响,但提供的选择器包括其他实体", + "argument.player.toomany": "只允许一名玩家,但提供的选择器允许多名玩家", + "argument.player.unknown": "该玩家不存在", + "argument.pos.missing.double": "需要一个坐标", + "argument.pos.missing.int": "应为一个方块的位置", + "argument.pos.mixed": "不能混用绝对坐标与局部坐标(必须全部使用^或都不使用)", + "argument.pos.outofbounds": "该位置超出了允许的范围", + "argument.pos.outofworld": "该位置已超出此世界!", + "argument.pos.unloaded": "该位置尚未被加载", + "argument.pos2d.incomplete": "不完整(应有2个坐标)", + "argument.pos3d.incomplete": "不完整(应有3个坐标)", + "argument.range.empty": "应为值或取值范围", + "argument.range.ints": "只允许整数,不允许小数", + "argument.range.swapped": "最小值不能大于最大值", + "argument.resource.invalid_type": "元素“%s”的类型“%s”错误(应为“%s”)", + "argument.resource.not_found": "无法找到类型为“%2$s”的元素“%1$s”", + "argument.resource_or_id.failed_to_parse": "解析结构失败:%s", + "argument.resource_or_id.invalid": "无效的ID或标签", + "argument.resource_or_id.no_such_element": "无法在注册表“%2$s”中找到元素“%1$s”", + "argument.resource_selector.not_found": "选择器“%s”和类型“%s”没有匹配项", + "argument.resource_tag.invalid_type": "标签“%s”的类型“%s”错误(应为“%s”)", + "argument.resource_tag.not_found": "无法找到类型为“%2$s”的标签“%1$s”", + "argument.rotation.incomplete": "不完整(应有2个坐标)", + "argument.scoreHolder.empty": "找不到与分数关联的持有者", + "argument.scoreboardDisplaySlot.invalid": "未知的显示位置“%s”", + "argument.style.invalid": "无效的样式“%s”", + "argument.swing_animation.invalid": "未知的挥臂动画类型“%s”", + "argument.time.invalid_tick_count": "刻数必须为非负数", + "argument.time.invalid_unit": "无效的单位", + "argument.time.tick_count_too_low": "刻数不能小于%s:实际为%s", + "argument.uuid.invalid": "无效的UUID", + "argument.waypoint.invalid": "选择的实体不是路径点", + "arguments.block.tag.unknown": "未知的方块标签“%s”", + "arguments.function.tag.unknown": "未知的函数标签“%s”", + "arguments.function.unknown": "未知的函数“%s”", + "arguments.item.component.expected": "应为物品组件", + "arguments.item.component.malformed": "错误的“%s”组件:“%s”", + "arguments.item.component.repeated": "物品组件“%s”重复,只能指定一个值", + "arguments.item.component.unknown": "未知的物品组件“%s”", + "arguments.item.malformed": "错误的物品:“%s”", + "arguments.item.overstacked": "%s只可以堆叠到%s", + "arguments.item.predicate.malformed": "错误的“%s”谓词:“%s”", + "arguments.item.predicate.unknown": "未知的物品谓词“%s”", + "arguments.item.tag.unknown": "未知的物品标签“%s”", + "arguments.nbtpath.node.invalid": "无效的NBT路径元素", + "arguments.nbtpath.nothing_found": "没有与%s相匹配的元素", + "arguments.nbtpath.too_deep": "生成的NBT嵌套过深", + "arguments.nbtpath.too_large": "生成的NBT过大", + "arguments.objective.notFound": "未知的记分项“%s”", + "arguments.objective.readonly": "记分项“%s”为只读类型", + "arguments.operation.div0": "不能除以零", + "arguments.operation.invalid": "无效的操作", + "arguments.swizzle.invalid": "无效的坐标组合:应为“x”、“y”和“z”的组合", + "attribute.modifier.equals.0": "%s %s", + "attribute.modifier.equals.1": "%s%% %s", + "attribute.modifier.equals.2": "%s%% %s", + "attribute.modifier.plus.0": "+%s %s", + "attribute.modifier.plus.1": "+%s%% %s", + "attribute.modifier.plus.2": "+%s%% %s", + "attribute.modifier.take.0": "-%s %s", + "attribute.modifier.take.1": "-%s%% %s", + "attribute.modifier.take.2": "-%s%% %s", + "attribute.name.air_drag_modifier": "空气阻力修正系数", + "attribute.name.armor": "护甲值", + "attribute.name.armor_toughness": "盔甲韧性", + "attribute.name.attack_damage": "攻击伤害", + "attribute.name.attack_knockback": "击退", + "attribute.name.attack_speed": "攻击速度", + "attribute.name.below_name_distance": "名牌分数距离", + "attribute.name.block_break_speed": "方块破坏速度", + "attribute.name.block_interaction_range": "方块交互距离", + "attribute.name.bounciness": "弹性", + "attribute.name.burning_time": "着火时间", + "attribute.name.camera_distance": "镜头距离", + "attribute.name.entity_interaction_range": "实体交互距离", + "attribute.name.explosion_knockback_resistance": "爆炸击退抗性", + "attribute.name.fall_damage_multiplier": "摔落伤害倍数", + "attribute.name.flying_speed": "飞行速度", + "attribute.name.follow_range": "生物跟随距离", + "attribute.name.friction_modifier": "摩擦力修正系数", + "attribute.name.generic.armor": "护甲值", + "attribute.name.generic.armor_toughness": "盔甲韧性", + "attribute.name.generic.attack_damage": "攻击伤害", + "attribute.name.generic.attack_knockback": "击退", + "attribute.name.generic.attack_speed": "攻击速度", + "attribute.name.generic.block_interaction_range": "方块交互距离", + "attribute.name.generic.burning_time": "着火时间", + "attribute.name.generic.entity_interaction_range": "实体交互距离", + "attribute.name.generic.explosion_knockback_resistance": "爆炸击退抗性", + "attribute.name.generic.fall_damage_multiplier": "摔落伤害倍数", + "attribute.name.generic.flying_speed": "飞行速度", + "attribute.name.generic.follow_range": "生物跟随距离", + "attribute.name.generic.gravity": "重力", + "attribute.name.generic.jump_strength": "跳跃力度", + "attribute.name.generic.knockback_resistance": "击退抗性", + "attribute.name.generic.luck": "幸运值", + "attribute.name.generic.max_absorption": "最大伤害吸收值", + "attribute.name.generic.max_health": "最大生命值", + "attribute.name.generic.movement_efficiency": "移动效率", + "attribute.name.generic.movement_speed": "速度", + "attribute.name.generic.oxygen_bonus": "额外氧气", + "attribute.name.generic.safe_fall_distance": "安全摔落高度", + "attribute.name.generic.scale": "尺寸", + "attribute.name.generic.step_height": "最大行走高度", + "attribute.name.generic.water_movement_efficiency": "水中移动效率", + "attribute.name.gravity": "重力", + "attribute.name.horse.jump_strength": "马匹跳跃能力", + "attribute.name.jump_strength": "跳跃力度", + "attribute.name.knockback_resistance": "击退抗性", + "attribute.name.luck": "幸运值", + "attribute.name.max_absorption": "最大伤害吸收值", + "attribute.name.max_health": "最大生命值", + "attribute.name.mining_efficiency": "挖掘效率", + "attribute.name.movement_efficiency": "移动效率", + "attribute.name.movement_speed": "速度", + "attribute.name.nameplate_distance": "名牌距离", + "attribute.name.oxygen_bonus": "额外氧气", + "attribute.name.player.block_break_speed": "方块破坏速度", + "attribute.name.player.block_interaction_range": "方块交互距离", + "attribute.name.player.entity_interaction_range": "实体交互距离", + "attribute.name.player.mining_efficiency": "挖掘效率", + "attribute.name.player.sneaking_speed": "潜行速度", + "attribute.name.player.submerged_mining_speed": "水下挖掘速度", + "attribute.name.player.sweeping_damage_ratio": "横扫伤害比率", + "attribute.name.safe_fall_distance": "安全摔落高度", + "attribute.name.scale": "尺寸", + "attribute.name.sneaking_speed": "潜行速度", + "attribute.name.spawn_reinforcements": "僵尸增援", + "attribute.name.step_height": "最大行走高度", + "attribute.name.submerged_mining_speed": "水下挖掘速度", + "attribute.name.sweeping_damage_ratio": "横扫伤害比率", + "attribute.name.tempt_range": "生物引诱范围", + "attribute.name.water_movement_efficiency": "水中移动效率", + "attribute.name.waypoint_receive_range": "路径点接收距离", + "attribute.name.waypoint_transmit_range": "路径点传输距离", + "attribute.name.zombie.spawn_reinforcements": "僵尸增援", + "biome.minecraft.badlands": "恶地", + "biome.minecraft.bamboo_jungle": "竹林", + "biome.minecraft.basalt_deltas": "玄武岩三角洲", + "biome.minecraft.beach": "沙滩", + "biome.minecraft.birch_forest": "桦木森林", + "biome.minecraft.cherry_grove": "樱花树林", + "biome.minecraft.cold_ocean": "冷水海洋", + "biome.minecraft.crimson_forest": "绯红森林", + "biome.minecraft.dappled_forest": "斑驳森林", + "biome.minecraft.dark_forest": "黑森林", + "biome.minecraft.deep_cold_ocean": "冷水深海", + "biome.minecraft.deep_dark": "深暗之域", + "biome.minecraft.deep_frozen_ocean": "冰冻深海", + "biome.minecraft.deep_lukewarm_ocean": "温水深海", + "biome.minecraft.deep_ocean": "深海", + "biome.minecraft.desert": "沙漠", + "biome.minecraft.dripstone_caves": "溶洞", + "biome.minecraft.end_barrens": "末地荒地", + "biome.minecraft.end_highlands": "末地高地", + "biome.minecraft.end_midlands": "末地内陆", + "biome.minecraft.eroded_badlands": "风蚀恶地", + "biome.minecraft.flower_forest": "繁花森林", + "biome.minecraft.forest": "森林", + "biome.minecraft.frozen_ocean": "冻洋", + "biome.minecraft.frozen_peaks": "冰封山峰", + "biome.minecraft.frozen_river": "冻河", + "biome.minecraft.grove": "雪林", + "biome.minecraft.ice_caves": "冰封洞穴", + "biome.minecraft.ice_spikes": "冰刺之地", + "biome.minecraft.jagged_peaks": "尖峭山峰", + "biome.minecraft.jungle": "丛林", + "biome.minecraft.lukewarm_ocean": "温水海洋", + "biome.minecraft.lush_caves": "繁茂洞穴", + "biome.minecraft.mangrove_swamp": "红树林沼泽", + "biome.minecraft.meadow": "草甸", + "biome.minecraft.mushroom_fields": "蘑菇岛", + "biome.minecraft.nether_wastes": "下界荒地", + "biome.minecraft.ocean": "海洋", + "biome.minecraft.old_growth_birch_forest": "原始桦木森林", + "biome.minecraft.old_growth_pine_taiga": "原始松木针叶林", + "biome.minecraft.old_growth_spruce_taiga": "原始云杉针叶林", + "biome.minecraft.pale_garden": "苍白之园", + "biome.minecraft.plains": "平原", + "biome.minecraft.river": "河流", + "biome.minecraft.savanna": "热带草原", + "biome.minecraft.savanna_plateau": "热带高原", + "biome.minecraft.small_end_islands": "末地小型岛屿", + "biome.minecraft.snowy_beach": "积雪沙滩", + "biome.minecraft.snowy_plains": "雪原", + "biome.minecraft.snowy_slopes": "积雪山坡", + "biome.minecraft.snowy_taiga": "积雪针叶林", + "biome.minecraft.soul_sand_valley": "灵魂沙峡谷", + "biome.minecraft.sparse_jungle": "稀疏丛林", + "biome.minecraft.stony_peaks": "裸岩山峰", + "biome.minecraft.stony_shore": "石岸", + "biome.minecraft.sulfur_caves": "硫黄洞穴", + "biome.minecraft.sunflower_plains": "向日葵平原", + "biome.minecraft.swamp": "沼泽", + "biome.minecraft.taiga": "针叶林", + "biome.minecraft.the_end": "末地", + "biome.minecraft.the_void": "虚空", + "biome.minecraft.warm_ocean": "暖水海洋", + "biome.minecraft.warped_forest": "诡异森林", + "biome.minecraft.windswept_forest": "风袭森林", + "biome.minecraft.windswept_gravelly_hills": "风袭沙砾丘陵", + "biome.minecraft.windswept_hills": "风袭丘陵", + "biome.minecraft.windswept_savanna": "风袭热带草原", + "biome.minecraft.wooded_badlands": "疏林恶地", + "block.minecraft.acacia_button": "金合欢木按钮", + "block.minecraft.acacia_door": "金合欢木门", + "block.minecraft.acacia_fence": "金合欢木栅栏", + "block.minecraft.acacia_fence_gate": "金合欢木栅栏门", + "block.minecraft.acacia_hanging_sign": "悬挂式金合欢木告示牌", + "block.minecraft.acacia_leaves": "金合欢树叶", + "block.minecraft.acacia_log": "金合欢原木", + "block.minecraft.acacia_planks": "金合欢木板", + "block.minecraft.acacia_pressure_plate": "金合欢木压力板", + "block.minecraft.acacia_sapling": "金合欢树苗", + "block.minecraft.acacia_shelf": "金合欢木展示架", + "block.minecraft.acacia_sign": "金合欢木告示牌", + "block.minecraft.acacia_slab": "金合欢木台阶", + "block.minecraft.acacia_stairs": "金合欢木楼梯", + "block.minecraft.acacia_trapdoor": "金合欢木活板门", + "block.minecraft.acacia_wall_hanging_sign": "墙上的悬挂式金合欢木告示牌", + "block.minecraft.acacia_wall_sign": "墙上的金合欢木告示牌", + "block.minecraft.acacia_wood": "金合欢木", + "block.minecraft.activator_rail": "激活铁轨", + "block.minecraft.air": "空气", + "block.minecraft.allium": "绒球葱", + "block.minecraft.amethyst_block": "紫水晶块", + "block.minecraft.amethyst_cluster": "紫水晶簇", + "block.minecraft.ancient_debris": "远古残骸", + "block.minecraft.andesite": "安山岩", + "block.minecraft.andesite_slab": "安山岩台阶", + "block.minecraft.andesite_stairs": "安山岩楼梯", + "block.minecraft.andesite_wall": "安山岩墙", + "block.minecraft.anvil": "铁砧", + "block.minecraft.attached_melon_stem": "结果的西瓜茎", + "block.minecraft.attached_pumpkin_stem": "结果的南瓜茎", + "block.minecraft.azalea": "杜鹃花丛", + "block.minecraft.azalea_leaves": "杜鹃树叶", + "block.minecraft.azure_bluet": "蓝花美耳草", + "block.minecraft.bamboo": "竹子", + "block.minecraft.bamboo_block": "竹块", + "block.minecraft.bamboo_button": "竹按钮", + "block.minecraft.bamboo_door": "竹门", + "block.minecraft.bamboo_fence": "竹栅栏", + "block.minecraft.bamboo_fence_gate": "竹栅栏门", + "block.minecraft.bamboo_hanging_sign": "悬挂式竹告示牌", + "block.minecraft.bamboo_mosaic": "竹马赛克", + "block.minecraft.bamboo_mosaic_slab": "竹马赛克台阶", + "block.minecraft.bamboo_mosaic_stairs": "竹马赛克楼梯", + "block.minecraft.bamboo_planks": "竹板", + "block.minecraft.bamboo_pressure_plate": "竹压力板", + "block.minecraft.bamboo_sapling": "竹笋", + "block.minecraft.bamboo_shelf": "竹展示架", + "block.minecraft.bamboo_sign": "竹告示牌", + "block.minecraft.bamboo_slab": "竹台阶", + "block.minecraft.bamboo_stairs": "竹楼梯", + "block.minecraft.bamboo_trapdoor": "竹活板门", + "block.minecraft.bamboo_wall_hanging_sign": "墙上的悬挂式竹告示牌", + "block.minecraft.bamboo_wall_sign": "墙上的竹告示牌", + "block.minecraft.banner.base.black": "黑底", + "block.minecraft.banner.base.blue": "蓝底", + "block.minecraft.banner.base.brown": "棕底", + "block.minecraft.banner.base.cyan": "青底", + "block.minecraft.banner.base.gray": "灰底", + "block.minecraft.banner.base.green": "绿底", + "block.minecraft.banner.base.light_blue": "淡蓝底", + "block.minecraft.banner.base.light_gray": "淡灰底", + "block.minecraft.banner.base.lime": "黄绿底", + "block.minecraft.banner.base.magenta": "品红底", + "block.minecraft.banner.base.orange": "橙底", + "block.minecraft.banner.base.pink": "粉红底", + "block.minecraft.banner.base.purple": "紫底", + "block.minecraft.banner.base.red": "红底", + "block.minecraft.banner.base.white": "白底", + "block.minecraft.banner.base.yellow": "黄底", + "block.minecraft.banner.border.black": "黑色方框边", + "block.minecraft.banner.border.blue": "蓝色方框边", + "block.minecraft.banner.border.brown": "棕色方框边", + "block.minecraft.banner.border.cyan": "青色方框边", + "block.minecraft.banner.border.gray": "灰色方框边", + "block.minecraft.banner.border.green": "绿色方框边", + "block.minecraft.banner.border.light_blue": "淡蓝色方框边", + "block.minecraft.banner.border.light_gray": "淡灰色方框边", + "block.minecraft.banner.border.lime": "黄绿色方框边", + "block.minecraft.banner.border.magenta": "品红色方框边", + "block.minecraft.banner.border.orange": "橙色方框边", + "block.minecraft.banner.border.pink": "粉红色方框边", + "block.minecraft.banner.border.purple": "紫色方框边", + "block.minecraft.banner.border.red": "红色方框边", + "block.minecraft.banner.border.white": "白色方框边", + "block.minecraft.banner.border.yellow": "黄色方框边", + "block.minecraft.banner.bricks.black": "黑色砖纹", + "block.minecraft.banner.bricks.blue": "蓝色砖纹", + "block.minecraft.banner.bricks.brown": "棕色砖纹", + "block.minecraft.banner.bricks.cyan": "青色砖纹", + "block.minecraft.banner.bricks.gray": "灰色砖纹", + "block.minecraft.banner.bricks.green": "绿色砖纹", + "block.minecraft.banner.bricks.light_blue": "淡蓝色砖纹", + "block.minecraft.banner.bricks.light_gray": "淡灰色砖纹", + "block.minecraft.banner.bricks.lime": "黄绿色砖纹", + "block.minecraft.banner.bricks.magenta": "品红色砖纹", + "block.minecraft.banner.bricks.orange": "橙色砖纹", + "block.minecraft.banner.bricks.pink": "粉红色砖纹", + "block.minecraft.banner.bricks.purple": "紫色砖纹", + "block.minecraft.banner.bricks.red": "红色砖纹", + "block.minecraft.banner.bricks.white": "白色砖纹", + "block.minecraft.banner.bricks.yellow": "黄色砖纹", + "block.minecraft.banner.circle.black": "黑色圆形", + "block.minecraft.banner.circle.blue": "蓝色圆形", + "block.minecraft.banner.circle.brown": "棕色圆形", + "block.minecraft.banner.circle.cyan": "青色圆形", + "block.minecraft.banner.circle.gray": "灰色圆形", + "block.minecraft.banner.circle.green": "绿色圆形", + "block.minecraft.banner.circle.light_blue": "淡蓝色圆形", + "block.minecraft.banner.circle.light_gray": "淡灰色圆形", + "block.minecraft.banner.circle.lime": "黄绿色圆形", + "block.minecraft.banner.circle.magenta": "品红色圆形", + "block.minecraft.banner.circle.orange": "橙色圆形", + "block.minecraft.banner.circle.pink": "粉红色圆形", + "block.minecraft.banner.circle.purple": "紫色圆形", + "block.minecraft.banner.circle.red": "红色圆形", + "block.minecraft.banner.circle.white": "白色圆形", + "block.minecraft.banner.circle.yellow": "黄色圆形", + "block.minecraft.banner.creeper.black": "黑色苦力怕盾徽", + "block.minecraft.banner.creeper.blue": "蓝色苦力怕盾徽", + "block.minecraft.banner.creeper.brown": "棕色苦力怕盾徽", + "block.minecraft.banner.creeper.cyan": "青色苦力怕盾徽", + "block.minecraft.banner.creeper.gray": "灰色苦力怕盾徽", + "block.minecraft.banner.creeper.green": "绿色苦力怕盾徽", + "block.minecraft.banner.creeper.light_blue": "淡蓝色苦力怕盾徽", + "block.minecraft.banner.creeper.light_gray": "淡灰色苦力怕盾徽", + "block.minecraft.banner.creeper.lime": "黄绿色苦力怕盾徽", + "block.minecraft.banner.creeper.magenta": "品红色苦力怕盾徽", + "block.minecraft.banner.creeper.orange": "橙色苦力怕盾徽", + "block.minecraft.banner.creeper.pink": "粉红色苦力怕盾徽", + "block.minecraft.banner.creeper.purple": "紫色苦力怕盾徽", + "block.minecraft.banner.creeper.red": "红色苦力怕盾徽", + "block.minecraft.banner.creeper.white": "白色苦力怕盾徽", + "block.minecraft.banner.creeper.yellow": "黄色苦力怕盾徽", + "block.minecraft.banner.cross.black": "黑斜十字", + "block.minecraft.banner.cross.blue": "蓝斜十字", + "block.minecraft.banner.cross.brown": "棕斜十字", + "block.minecraft.banner.cross.cyan": "青斜十字", + "block.minecraft.banner.cross.gray": "灰斜十字", + "block.minecraft.banner.cross.green": "绿斜十字", + "block.minecraft.banner.cross.light_blue": "淡蓝斜十字", + "block.minecraft.banner.cross.light_gray": "淡灰斜十字", + "block.minecraft.banner.cross.lime": "黄绿斜十字", + "block.minecraft.banner.cross.magenta": "品红斜十字", + "block.minecraft.banner.cross.orange": "橙斜十字", + "block.minecraft.banner.cross.pink": "粉红斜十字", + "block.minecraft.banner.cross.purple": "紫斜十字", + "block.minecraft.banner.cross.red": "红斜十字", + "block.minecraft.banner.cross.white": "白斜十字", + "block.minecraft.banner.cross.yellow": "黄斜十字", + "block.minecraft.banner.curly_border.black": "黑色波纹边", + "block.minecraft.banner.curly_border.blue": "蓝色波纹边", + "block.minecraft.banner.curly_border.brown": "棕色波纹边", + "block.minecraft.banner.curly_border.cyan": "青色波纹边", + "block.minecraft.banner.curly_border.gray": "灰色波纹边", + "block.minecraft.banner.curly_border.green": "绿色波纹边", + "block.minecraft.banner.curly_border.light_blue": "淡蓝色波纹边", + "block.minecraft.banner.curly_border.light_gray": "淡灰色波纹边", + "block.minecraft.banner.curly_border.lime": "黄绿色波纹边", + "block.minecraft.banner.curly_border.magenta": "品红色波纹边", + "block.minecraft.banner.curly_border.orange": "橙色波纹边", + "block.minecraft.banner.curly_border.pink": "粉红色波纹边", + "block.minecraft.banner.curly_border.purple": "紫色波纹边", + "block.minecraft.banner.curly_border.red": "红色波纹边", + "block.minecraft.banner.curly_border.white": "白色波纹边", + "block.minecraft.banner.curly_border.yellow": "黄色波纹边", + "block.minecraft.banner.diagonal_left.black": "黑色右上三角", + "block.minecraft.banner.diagonal_left.blue": "蓝色右上三角", + "block.minecraft.banner.diagonal_left.brown": "棕色右上三角", + "block.minecraft.banner.diagonal_left.cyan": "青色右上三角", + "block.minecraft.banner.diagonal_left.gray": "灰色右上三角", + "block.minecraft.banner.diagonal_left.green": "绿色右上三角", + "block.minecraft.banner.diagonal_left.light_blue": "淡蓝色右上三角", + "block.minecraft.banner.diagonal_left.light_gray": "淡灰色右上三角", + "block.minecraft.banner.diagonal_left.lime": "黄绿色右上三角", + "block.minecraft.banner.diagonal_left.magenta": "品红色右上三角", + "block.minecraft.banner.diagonal_left.orange": "橙色右上三角", + "block.minecraft.banner.diagonal_left.pink": "粉红色右上三角", + "block.minecraft.banner.diagonal_left.purple": "紫色右上三角", + "block.minecraft.banner.diagonal_left.red": "红色右上三角", + "block.minecraft.banner.diagonal_left.white": "白色右上三角", + "block.minecraft.banner.diagonal_left.yellow": "黄色右上三角", + "block.minecraft.banner.diagonal_right.black": "黑色左上三角", + "block.minecraft.banner.diagonal_right.blue": "蓝色左上三角", + "block.minecraft.banner.diagonal_right.brown": "棕色左上三角", + "block.minecraft.banner.diagonal_right.cyan": "青色左上三角", + "block.minecraft.banner.diagonal_right.gray": "灰色左上三角", + "block.minecraft.banner.diagonal_right.green": "绿色左上三角", + "block.minecraft.banner.diagonal_right.light_blue": "淡蓝色左上三角", + "block.minecraft.banner.diagonal_right.light_gray": "淡灰色左上三角", + "block.minecraft.banner.diagonal_right.lime": "黄绿色左上三角", + "block.minecraft.banner.diagonal_right.magenta": "品红色左上三角", + "block.minecraft.banner.diagonal_right.orange": "橙色左上三角", + "block.minecraft.banner.diagonal_right.pink": "粉红色左上三角", + "block.minecraft.banner.diagonal_right.purple": "紫色左上三角", + "block.minecraft.banner.diagonal_right.red": "红色左上三角", + "block.minecraft.banner.diagonal_right.white": "白色左上三角", + "block.minecraft.banner.diagonal_right.yellow": "黄色左上三角", + "block.minecraft.banner.diagonal_up_left.black": "黑色右下三角", + "block.minecraft.banner.diagonal_up_left.blue": "蓝色右下三角", + "block.minecraft.banner.diagonal_up_left.brown": "棕色右下三角", + "block.minecraft.banner.diagonal_up_left.cyan": "青色右下三角", + "block.minecraft.banner.diagonal_up_left.gray": "灰色右下三角", + "block.minecraft.banner.diagonal_up_left.green": "绿色右下三角", + "block.minecraft.banner.diagonal_up_left.light_blue": "淡蓝色右下三角", + "block.minecraft.banner.diagonal_up_left.light_gray": "淡灰色右下三角", + "block.minecraft.banner.diagonal_up_left.lime": "黄绿色右下三角", + "block.minecraft.banner.diagonal_up_left.magenta": "品红色右下三角", + "block.minecraft.banner.diagonal_up_left.orange": "橙色右下三角", + "block.minecraft.banner.diagonal_up_left.pink": "粉红色右下三角", + "block.minecraft.banner.diagonal_up_left.purple": "紫色右下三角", + "block.minecraft.banner.diagonal_up_left.red": "红色右下三角", + "block.minecraft.banner.diagonal_up_left.white": "白色右下三角", + "block.minecraft.banner.diagonal_up_left.yellow": "黄色右下三角", + "block.minecraft.banner.diagonal_up_right.black": "黑色左下三角", + "block.minecraft.banner.diagonal_up_right.blue": "蓝色左下三角", + "block.minecraft.banner.diagonal_up_right.brown": "棕色左下三角", + "block.minecraft.banner.diagonal_up_right.cyan": "青色左下三角", + "block.minecraft.banner.diagonal_up_right.gray": "灰色左下三角", + "block.minecraft.banner.diagonal_up_right.green": "绿色左下三角", + "block.minecraft.banner.diagonal_up_right.light_blue": "淡蓝色左下三角", + "block.minecraft.banner.diagonal_up_right.light_gray": "淡灰色左下三角", + "block.minecraft.banner.diagonal_up_right.lime": "黄绿色左下三角", + "block.minecraft.banner.diagonal_up_right.magenta": "品红色左下三角", + "block.minecraft.banner.diagonal_up_right.orange": "橙色左下三角", + "block.minecraft.banner.diagonal_up_right.pink": "粉红色左下三角", + "block.minecraft.banner.diagonal_up_right.purple": "紫色左下三角", + "block.minecraft.banner.diagonal_up_right.red": "红色左下三角", + "block.minecraft.banner.diagonal_up_right.white": "白色左下三角", + "block.minecraft.banner.diagonal_up_right.yellow": "黄色左下三角", + "block.minecraft.banner.flow.black": "黑色涡流", + "block.minecraft.banner.flow.blue": "蓝色涡流", + "block.minecraft.banner.flow.brown": "棕色涡流", + "block.minecraft.banner.flow.cyan": "青色涡流", + "block.minecraft.banner.flow.gray": "灰色涡流", + "block.minecraft.banner.flow.green": "绿色涡流", + "block.minecraft.banner.flow.light_blue": "淡蓝色涡流", + "block.minecraft.banner.flow.light_gray": "淡灰色涡流", + "block.minecraft.banner.flow.lime": "黄绿色涡流", + "block.minecraft.banner.flow.magenta": "品红色涡流", + "block.minecraft.banner.flow.orange": "橙色涡流", + "block.minecraft.banner.flow.pink": "粉红色涡流", + "block.minecraft.banner.flow.purple": "紫色涡流", + "block.minecraft.banner.flow.red": "红色涡流", + "block.minecraft.banner.flow.white": "白色涡流", + "block.minecraft.banner.flow.yellow": "黄色涡流", + "block.minecraft.banner.flower.black": "黑色花朵盾徽", + "block.minecraft.banner.flower.blue": "蓝色花朵盾徽", + "block.minecraft.banner.flower.brown": "棕色花朵盾徽", + "block.minecraft.banner.flower.cyan": "青色花朵盾徽", + "block.minecraft.banner.flower.gray": "灰色花朵盾徽", + "block.minecraft.banner.flower.green": "绿色花朵盾徽", + "block.minecraft.banner.flower.light_blue": "淡蓝色花朵盾徽", + "block.minecraft.banner.flower.light_gray": "淡灰色花朵盾徽", + "block.minecraft.banner.flower.lime": "黄绿色花朵盾徽", + "block.minecraft.banner.flower.magenta": "品红色花朵盾徽", + "block.minecraft.banner.flower.orange": "橙色花朵盾徽", + "block.minecraft.banner.flower.pink": "粉红色花朵盾徽", + "block.minecraft.banner.flower.purple": "紫色花朵盾徽", + "block.minecraft.banner.flower.red": "红色花朵盾徽", + "block.minecraft.banner.flower.white": "白色花朵盾徽", + "block.minecraft.banner.flower.yellow": "黄色花朵盾徽", + "block.minecraft.banner.globe.black": "黑色地球", + "block.minecraft.banner.globe.blue": "蓝色地球", + "block.minecraft.banner.globe.brown": "棕色地球", + "block.minecraft.banner.globe.cyan": "青色地球", + "block.minecraft.banner.globe.gray": "灰色地球", + "block.minecraft.banner.globe.green": "绿色地球", + "block.minecraft.banner.globe.light_blue": "淡蓝色地球", + "block.minecraft.banner.globe.light_gray": "淡灰色地球", + "block.minecraft.banner.globe.lime": "黄绿色地球", + "block.minecraft.banner.globe.magenta": "品红色地球", + "block.minecraft.banner.globe.orange": "橙色地球", + "block.minecraft.banner.globe.pink": "粉红色地球", + "block.minecraft.banner.globe.purple": "紫色地球", + "block.minecraft.banner.globe.red": "红色地球", + "block.minecraft.banner.globe.white": "白色地球", + "block.minecraft.banner.globe.yellow": "黄色地球", + "block.minecraft.banner.gradient.black": "黑色自上渐淡", + "block.minecraft.banner.gradient.blue": "蓝色自上渐淡", + "block.minecraft.banner.gradient.brown": "棕色自上渐淡", + "block.minecraft.banner.gradient.cyan": "青色自上渐淡", + "block.minecraft.banner.gradient.gray": "灰色自上渐淡", + "block.minecraft.banner.gradient.green": "绿色自上渐淡", + "block.minecraft.banner.gradient.light_blue": "淡蓝色自上渐淡", + "block.minecraft.banner.gradient.light_gray": "淡灰色自上渐淡", + "block.minecraft.banner.gradient.lime": "黄绿色自上渐淡", + "block.minecraft.banner.gradient.magenta": "品红色自上渐淡", + "block.minecraft.banner.gradient.orange": "橙色自上渐淡", + "block.minecraft.banner.gradient.pink": "粉红色自上渐淡", + "block.minecraft.banner.gradient.purple": "紫色自上渐淡", + "block.minecraft.banner.gradient.red": "红色自上渐淡", + "block.minecraft.banner.gradient.white": "白色自上渐淡", + "block.minecraft.banner.gradient.yellow": "黄色自上渐淡", + "block.minecraft.banner.gradient_up.black": "黑色自下渐淡", + "block.minecraft.banner.gradient_up.blue": "蓝色自下渐淡", + "block.minecraft.banner.gradient_up.brown": "棕色自下渐淡", + "block.minecraft.banner.gradient_up.cyan": "青色自下渐淡", + "block.minecraft.banner.gradient_up.gray": "灰色自下渐淡", + "block.minecraft.banner.gradient_up.green": "绿色自下渐淡", + "block.minecraft.banner.gradient_up.light_blue": "淡蓝色自下渐淡", + "block.minecraft.banner.gradient_up.light_gray": "淡灰色自下渐淡", + "block.minecraft.banner.gradient_up.lime": "黄绿色自下渐淡", + "block.minecraft.banner.gradient_up.magenta": "品红色自下渐淡", + "block.minecraft.banner.gradient_up.orange": "橙色自下渐淡", + "block.minecraft.banner.gradient_up.pink": "粉红色自下渐淡", + "block.minecraft.banner.gradient_up.purple": "紫色自下渐淡", + "block.minecraft.banner.gradient_up.red": "红色自下渐淡", + "block.minecraft.banner.gradient_up.white": "白色自下渐淡", + "block.minecraft.banner.gradient_up.yellow": "黄色自下渐淡", + "block.minecraft.banner.guster.black": "黑色旋风", + "block.minecraft.banner.guster.blue": "蓝色旋风", + "block.minecraft.banner.guster.brown": "棕色旋风", + "block.minecraft.banner.guster.cyan": "青色旋风", + "block.minecraft.banner.guster.gray": "灰色旋风", + "block.minecraft.banner.guster.green": "绿色旋风", + "block.minecraft.banner.guster.light_blue": "淡蓝色旋风", + "block.minecraft.banner.guster.light_gray": "淡灰色旋风", + "block.minecraft.banner.guster.lime": "黄绿色旋风", + "block.minecraft.banner.guster.magenta": "品红色旋风", + "block.minecraft.banner.guster.orange": "橙色旋风", + "block.minecraft.banner.guster.pink": "粉红色旋风", + "block.minecraft.banner.guster.purple": "紫色旋风", + "block.minecraft.banner.guster.red": "红色旋风", + "block.minecraft.banner.guster.white": "白色旋风", + "block.minecraft.banner.guster.yellow": "黄色旋风", + "block.minecraft.banner.half_horizontal.black": "黑色上半方形", + "block.minecraft.banner.half_horizontal.blue": "蓝色上半方形", + "block.minecraft.banner.half_horizontal.brown": "棕色上半方形", + "block.minecraft.banner.half_horizontal.cyan": "青色上半方形", + "block.minecraft.banner.half_horizontal.gray": "灰色上半方形", + "block.minecraft.banner.half_horizontal.green": "绿色上半方形", + "block.minecraft.banner.half_horizontal.light_blue": "淡蓝色上半方形", + "block.minecraft.banner.half_horizontal.light_gray": "淡灰色上半方形", + "block.minecraft.banner.half_horizontal.lime": "黄绿色上半方形", + "block.minecraft.banner.half_horizontal.magenta": "品红色上半方形", + "block.minecraft.banner.half_horizontal.orange": "橙色上半方形", + "block.minecraft.banner.half_horizontal.pink": "粉红色上半方形", + "block.minecraft.banner.half_horizontal.purple": "紫色上半方形", + "block.minecraft.banner.half_horizontal.red": "红色上半方形", + "block.minecraft.banner.half_horizontal.white": "白色上半方形", + "block.minecraft.banner.half_horizontal.yellow": "黄色上半方形", + "block.minecraft.banner.half_horizontal_bottom.black": "黑色下半方形", + "block.minecraft.banner.half_horizontal_bottom.blue": "蓝色下半方形", + "block.minecraft.banner.half_horizontal_bottom.brown": "棕色下半方形", + "block.minecraft.banner.half_horizontal_bottom.cyan": "青色下半方形", + "block.minecraft.banner.half_horizontal_bottom.gray": "灰色下半方形", + "block.minecraft.banner.half_horizontal_bottom.green": "绿色下半方形", + "block.minecraft.banner.half_horizontal_bottom.light_blue": "淡蓝色下半方形", + "block.minecraft.banner.half_horizontal_bottom.light_gray": "淡灰色下半方形", + "block.minecraft.banner.half_horizontal_bottom.lime": "黄绿色下半方形", + "block.minecraft.banner.half_horizontal_bottom.magenta": "品红色下半方形", + "block.minecraft.banner.half_horizontal_bottom.orange": "橙色下半方形", + "block.minecraft.banner.half_horizontal_bottom.pink": "粉红色下半方形", + "block.minecraft.banner.half_horizontal_bottom.purple": "紫色下半方形", + "block.minecraft.banner.half_horizontal_bottom.red": "红色下半方形", + "block.minecraft.banner.half_horizontal_bottom.white": "白色下半方形", + "block.minecraft.banner.half_horizontal_bottom.yellow": "黄色下半方形", + "block.minecraft.banner.half_vertical.black": "黑色右半方形", + "block.minecraft.banner.half_vertical.blue": "蓝色右半方形", + "block.minecraft.banner.half_vertical.brown": "棕色右半方形", + "block.minecraft.banner.half_vertical.cyan": "青色右半方形", + "block.minecraft.banner.half_vertical.gray": "灰色右半方形", + "block.minecraft.banner.half_vertical.green": "绿色右半方形", + "block.minecraft.banner.half_vertical.light_blue": "淡蓝色右半方形", + "block.minecraft.banner.half_vertical.light_gray": "淡灰色右半方形", + "block.minecraft.banner.half_vertical.lime": "黄绿色右半方形", + "block.minecraft.banner.half_vertical.magenta": "品红色右半方形", + "block.minecraft.banner.half_vertical.orange": "橙色右半方形", + "block.minecraft.banner.half_vertical.pink": "粉红色右半方形", + "block.minecraft.banner.half_vertical.purple": "紫色右半方形", + "block.minecraft.banner.half_vertical.red": "红色右半方形", + "block.minecraft.banner.half_vertical.white": "白色右半方形", + "block.minecraft.banner.half_vertical.yellow": "黄色右半方形", + "block.minecraft.banner.half_vertical_right.black": "黑色左半方形", + "block.minecraft.banner.half_vertical_right.blue": "蓝色左半方形", + "block.minecraft.banner.half_vertical_right.brown": "棕色左半方形", + "block.minecraft.banner.half_vertical_right.cyan": "青色左半方形", + "block.minecraft.banner.half_vertical_right.gray": "灰色左半方形", + "block.minecraft.banner.half_vertical_right.green": "绿色左半方形", + "block.minecraft.banner.half_vertical_right.light_blue": "淡蓝色左半方形", + "block.minecraft.banner.half_vertical_right.light_gray": "淡灰色左半方形", + "block.minecraft.banner.half_vertical_right.lime": "黄绿色左半方形", + "block.minecraft.banner.half_vertical_right.magenta": "品红色左半方形", + "block.minecraft.banner.half_vertical_right.orange": "橙色左半方形", + "block.minecraft.banner.half_vertical_right.pink": "粉红色左半方形", + "block.minecraft.banner.half_vertical_right.purple": "紫色左半方形", + "block.minecraft.banner.half_vertical_right.red": "红色左半方形", + "block.minecraft.banner.half_vertical_right.white": "白色左半方形", + "block.minecraft.banner.half_vertical_right.yellow": "黄色左半方形", + "block.minecraft.banner.mojang.black": "黑色Mojang徽标", + "block.minecraft.banner.mojang.blue": "蓝色Mojang徽标", + "block.minecraft.banner.mojang.brown": "棕色Mojang徽标", + "block.minecraft.banner.mojang.cyan": "青色Mojang徽标", + "block.minecraft.banner.mojang.gray": "灰色Mojang徽标", + "block.minecraft.banner.mojang.green": "绿色Mojang徽标", + "block.minecraft.banner.mojang.light_blue": "淡蓝色Mojang徽标", + "block.minecraft.banner.mojang.light_gray": "淡灰色Mojang徽标", + "block.minecraft.banner.mojang.lime": "黄绿色Mojang徽标", + "block.minecraft.banner.mojang.magenta": "品红色Mojang徽标", + "block.minecraft.banner.mojang.orange": "橙色Mojang徽标", + "block.minecraft.banner.mojang.pink": "粉红色Mojang徽标", + "block.minecraft.banner.mojang.purple": "紫色Mojang徽标", + "block.minecraft.banner.mojang.red": "红色Mojang徽标", + "block.minecraft.banner.mojang.white": "白色Mojang徽标", + "block.minecraft.banner.mojang.yellow": "黄色Mojang徽标", + "block.minecraft.banner.piglin.black": "黑色猪鼻", + "block.minecraft.banner.piglin.blue": "蓝色猪鼻", + "block.minecraft.banner.piglin.brown": "棕色猪鼻", + "block.minecraft.banner.piglin.cyan": "青色猪鼻", + "block.minecraft.banner.piglin.gray": "灰色猪鼻", + "block.minecraft.banner.piglin.green": "绿色猪鼻", + "block.minecraft.banner.piglin.light_blue": "淡蓝色猪鼻", + "block.minecraft.banner.piglin.light_gray": "淡灰色猪鼻", + "block.minecraft.banner.piglin.lime": "黄绿色猪鼻", + "block.minecraft.banner.piglin.magenta": "品红色猪鼻", + "block.minecraft.banner.piglin.orange": "橙色猪鼻", + "block.minecraft.banner.piglin.pink": "粉红色猪鼻", + "block.minecraft.banner.piglin.purple": "紫色猪鼻", + "block.minecraft.banner.piglin.red": "红色猪鼻", + "block.minecraft.banner.piglin.white": "白色猪鼻", + "block.minecraft.banner.piglin.yellow": "黄色猪鼻", + "block.minecraft.banner.rhombus.black": "黑色菱形", + "block.minecraft.banner.rhombus.blue": "蓝色菱形", + "block.minecraft.banner.rhombus.brown": "棕色菱形", + "block.minecraft.banner.rhombus.cyan": "青色菱形", + "block.minecraft.banner.rhombus.gray": "灰色菱形", + "block.minecraft.banner.rhombus.green": "绿色菱形", + "block.minecraft.banner.rhombus.light_blue": "淡蓝色菱形", + "block.minecraft.banner.rhombus.light_gray": "淡灰色菱形", + "block.minecraft.banner.rhombus.lime": "黄绿色菱形", + "block.minecraft.banner.rhombus.magenta": "品红色菱形", + "block.minecraft.banner.rhombus.orange": "橙色菱形", + "block.minecraft.banner.rhombus.pink": "粉红色菱形", + "block.minecraft.banner.rhombus.purple": "紫色菱形", + "block.minecraft.banner.rhombus.red": "红色菱形", + "block.minecraft.banner.rhombus.white": "白色菱形", + "block.minecraft.banner.rhombus.yellow": "黄色菱形", + "block.minecraft.banner.skull.black": "黑色头颅盾徽", + "block.minecraft.banner.skull.blue": "蓝色头颅盾徽", + "block.minecraft.banner.skull.brown": "棕色头颅盾徽", + "block.minecraft.banner.skull.cyan": "青色头颅盾徽", + "block.minecraft.banner.skull.gray": "灰色头颅盾徽", + "block.minecraft.banner.skull.green": "绿色头颅盾徽", + "block.minecraft.banner.skull.light_blue": "淡蓝色头颅盾徽", + "block.minecraft.banner.skull.light_gray": "淡灰色头颅盾徽", + "block.minecraft.banner.skull.lime": "黄绿色头颅盾徽", + "block.minecraft.banner.skull.magenta": "品红色头颅盾徽", + "block.minecraft.banner.skull.orange": "橙色头颅盾徽", + "block.minecraft.banner.skull.pink": "粉红色头颅盾徽", + "block.minecraft.banner.skull.purple": "紫色头颅盾徽", + "block.minecraft.banner.skull.red": "红色头颅盾徽", + "block.minecraft.banner.skull.white": "白色头颅盾徽", + "block.minecraft.banner.skull.yellow": "黄色头颅盾徽", + "block.minecraft.banner.small_stripes.black": "黑竖条纹", + "block.minecraft.banner.small_stripes.blue": "蓝竖条纹", + "block.minecraft.banner.small_stripes.brown": "棕竖条纹", + "block.minecraft.banner.small_stripes.cyan": "青竖条纹", + "block.minecraft.banner.small_stripes.gray": "灰竖条纹", + "block.minecraft.banner.small_stripes.green": "绿竖条纹", + "block.minecraft.banner.small_stripes.light_blue": "淡蓝竖条纹", + "block.minecraft.banner.small_stripes.light_gray": "淡灰竖条纹", + "block.minecraft.banner.small_stripes.lime": "黄绿竖条纹", + "block.minecraft.banner.small_stripes.magenta": "品红竖条纹", + "block.minecraft.banner.small_stripes.orange": "橙竖条纹", + "block.minecraft.banner.small_stripes.pink": "粉红竖条纹", + "block.minecraft.banner.small_stripes.purple": "紫竖条纹", + "block.minecraft.banner.small_stripes.red": "红竖条纹", + "block.minecraft.banner.small_stripes.white": "白竖条纹", + "block.minecraft.banner.small_stripes.yellow": "黄竖条纹", + "block.minecraft.banner.square_bottom_left.black": "右底黑方", + "block.minecraft.banner.square_bottom_left.blue": "右底蓝方", + "block.minecraft.banner.square_bottom_left.brown": "右底棕方", + "block.minecraft.banner.square_bottom_left.cyan": "右底青方", + "block.minecraft.banner.square_bottom_left.gray": "右底灰方", + "block.minecraft.banner.square_bottom_left.green": "右底绿方", + "block.minecraft.banner.square_bottom_left.light_blue": "右底淡蓝方", + "block.minecraft.banner.square_bottom_left.light_gray": "右底淡灰方", + "block.minecraft.banner.square_bottom_left.lime": "右底黄绿方", + "block.minecraft.banner.square_bottom_left.magenta": "右底品红方", + "block.minecraft.banner.square_bottom_left.orange": "右底橙方", + "block.minecraft.banner.square_bottom_left.pink": "右底粉红方", + "block.minecraft.banner.square_bottom_left.purple": "右底紫方", + "block.minecraft.banner.square_bottom_left.red": "右底红方", + "block.minecraft.banner.square_bottom_left.white": "右底白方", + "block.minecraft.banner.square_bottom_left.yellow": "右底黄方", + "block.minecraft.banner.square_bottom_right.black": "左底黑方", + "block.minecraft.banner.square_bottom_right.blue": "左底蓝方", + "block.minecraft.banner.square_bottom_right.brown": "左底棕方", + "block.minecraft.banner.square_bottom_right.cyan": "左底青方", + "block.minecraft.banner.square_bottom_right.gray": "左底灰方", + "block.minecraft.banner.square_bottom_right.green": "左底绿方", + "block.minecraft.banner.square_bottom_right.light_blue": "左底淡蓝方", + "block.minecraft.banner.square_bottom_right.light_gray": "左底淡灰方", + "block.minecraft.banner.square_bottom_right.lime": "左底黄绿方", + "block.minecraft.banner.square_bottom_right.magenta": "左底品红方", + "block.minecraft.banner.square_bottom_right.orange": "左底橙方", + "block.minecraft.banner.square_bottom_right.pink": "左底粉红方", + "block.minecraft.banner.square_bottom_right.purple": "左底紫方", + "block.minecraft.banner.square_bottom_right.red": "左底红方", + "block.minecraft.banner.square_bottom_right.white": "左底白方", + "block.minecraft.banner.square_bottom_right.yellow": "左底黄方", + "block.minecraft.banner.square_top_left.black": "右顶黑方", + "block.minecraft.banner.square_top_left.blue": "右顶蓝方", + "block.minecraft.banner.square_top_left.brown": "右顶棕方", + "block.minecraft.banner.square_top_left.cyan": "右顶青方", + "block.minecraft.banner.square_top_left.gray": "右顶灰方", + "block.minecraft.banner.square_top_left.green": "右顶绿方", + "block.minecraft.banner.square_top_left.light_blue": "右顶淡蓝方", + "block.minecraft.banner.square_top_left.light_gray": "右顶淡灰方", + "block.minecraft.banner.square_top_left.lime": "右顶黄绿方", + "block.minecraft.banner.square_top_left.magenta": "右顶品红方", + "block.minecraft.banner.square_top_left.orange": "右顶橙方", + "block.minecraft.banner.square_top_left.pink": "右顶粉红方", + "block.minecraft.banner.square_top_left.purple": "右顶紫方", + "block.minecraft.banner.square_top_left.red": "右顶红方", + "block.minecraft.banner.square_top_left.white": "右顶白方", + "block.minecraft.banner.square_top_left.yellow": "右顶黄方", + "block.minecraft.banner.square_top_right.black": "左顶黑方", + "block.minecraft.banner.square_top_right.blue": "左顶蓝方", + "block.minecraft.banner.square_top_right.brown": "左顶棕方", + "block.minecraft.banner.square_top_right.cyan": "左顶青方", + "block.minecraft.banner.square_top_right.gray": "左顶灰方", + "block.minecraft.banner.square_top_right.green": "左顶绿方", + "block.minecraft.banner.square_top_right.light_blue": "左顶淡蓝方", + "block.minecraft.banner.square_top_right.light_gray": "左顶淡灰方", + "block.minecraft.banner.square_top_right.lime": "左顶黄绿方", + "block.minecraft.banner.square_top_right.magenta": "左顶品红方", + "block.minecraft.banner.square_top_right.orange": "左顶橙方", + "block.minecraft.banner.square_top_right.pink": "左顶粉红方", + "block.minecraft.banner.square_top_right.purple": "左顶紫方", + "block.minecraft.banner.square_top_right.red": "左顶红方", + "block.minecraft.banner.square_top_right.white": "左顶白方", + "block.minecraft.banner.square_top_right.yellow": "左顶黄方", + "block.minecraft.banner.straight_cross.black": "黑正十字", + "block.minecraft.banner.straight_cross.blue": "蓝正十字", + "block.minecraft.banner.straight_cross.brown": "棕正十字", + "block.minecraft.banner.straight_cross.cyan": "青正十字", + "block.minecraft.banner.straight_cross.gray": "灰正十字", + "block.minecraft.banner.straight_cross.green": "绿正十字", + "block.minecraft.banner.straight_cross.light_blue": "淡蓝正十字", + "block.minecraft.banner.straight_cross.light_gray": "淡灰正十字", + "block.minecraft.banner.straight_cross.lime": "黄绿正十字", + "block.minecraft.banner.straight_cross.magenta": "品红正十字", + "block.minecraft.banner.straight_cross.orange": "橙正十字", + "block.minecraft.banner.straight_cross.pink": "粉红正十字", + "block.minecraft.banner.straight_cross.purple": "紫正十字", + "block.minecraft.banner.straight_cross.red": "红正十字", + "block.minecraft.banner.straight_cross.white": "白正十字", + "block.minecraft.banner.straight_cross.yellow": "黄正十字", + "block.minecraft.banner.stripe_bottom.black": "底黑横条", + "block.minecraft.banner.stripe_bottom.blue": "底蓝横条", + "block.minecraft.banner.stripe_bottom.brown": "底棕横条", + "block.minecraft.banner.stripe_bottom.cyan": "底青横条", + "block.minecraft.banner.stripe_bottom.gray": "底灰横条", + "block.minecraft.banner.stripe_bottom.green": "底绿横条", + "block.minecraft.banner.stripe_bottom.light_blue": "底淡蓝横条", + "block.minecraft.banner.stripe_bottom.light_gray": "底淡灰横条", + "block.minecraft.banner.stripe_bottom.lime": "底黄绿横条", + "block.minecraft.banner.stripe_bottom.magenta": "底品红横条", + "block.minecraft.banner.stripe_bottom.orange": "底橙横条", + "block.minecraft.banner.stripe_bottom.pink": "底粉红横条", + "block.minecraft.banner.stripe_bottom.purple": "底紫横条", + "block.minecraft.banner.stripe_bottom.red": "底红横条", + "block.minecraft.banner.stripe_bottom.white": "底白横条", + "block.minecraft.banner.stripe_bottom.yellow": "底黄横条", + "block.minecraft.banner.stripe_center.black": "中黑竖条", + "block.minecraft.banner.stripe_center.blue": "中蓝竖条", + "block.minecraft.banner.stripe_center.brown": "中棕竖条", + "block.minecraft.banner.stripe_center.cyan": "中青竖条", + "block.minecraft.banner.stripe_center.gray": "中灰竖条", + "block.minecraft.banner.stripe_center.green": "中绿竖条", + "block.minecraft.banner.stripe_center.light_blue": "中淡蓝竖条", + "block.minecraft.banner.stripe_center.light_gray": "中淡灰竖条", + "block.minecraft.banner.stripe_center.lime": "中黄绿竖条", + "block.minecraft.banner.stripe_center.magenta": "中品红竖条", + "block.minecraft.banner.stripe_center.orange": "中橙竖条", + "block.minecraft.banner.stripe_center.pink": "中粉红竖条", + "block.minecraft.banner.stripe_center.purple": "中紫竖条", + "block.minecraft.banner.stripe_center.red": "中红竖条", + "block.minecraft.banner.stripe_center.white": "中白竖条", + "block.minecraft.banner.stripe_center.yellow": "中黄竖条", + "block.minecraft.banner.stripe_downleft.black": "左黑斜条", + "block.minecraft.banner.stripe_downleft.blue": "左蓝斜条", + "block.minecraft.banner.stripe_downleft.brown": "左棕斜条", + "block.minecraft.banner.stripe_downleft.cyan": "左青斜条", + "block.minecraft.banner.stripe_downleft.gray": "左灰斜条", + "block.minecraft.banner.stripe_downleft.green": "左绿斜条", + "block.minecraft.banner.stripe_downleft.light_blue": "左淡蓝斜条", + "block.minecraft.banner.stripe_downleft.light_gray": "左淡灰斜条", + "block.minecraft.banner.stripe_downleft.lime": "左黄绿斜条", + "block.minecraft.banner.stripe_downleft.magenta": "左品红斜条", + "block.minecraft.banner.stripe_downleft.orange": "左橙斜条", + "block.minecraft.banner.stripe_downleft.pink": "左粉红斜条", + "block.minecraft.banner.stripe_downleft.purple": "左紫斜条", + "block.minecraft.banner.stripe_downleft.red": "左红斜条", + "block.minecraft.banner.stripe_downleft.white": "左白斜条", + "block.minecraft.banner.stripe_downleft.yellow": "左黄斜条", + "block.minecraft.banner.stripe_downright.black": "右黑斜条", + "block.minecraft.banner.stripe_downright.blue": "右蓝斜条", + "block.minecraft.banner.stripe_downright.brown": "右棕斜条", + "block.minecraft.banner.stripe_downright.cyan": "右青斜条", + "block.minecraft.banner.stripe_downright.gray": "右灰斜条", + "block.minecraft.banner.stripe_downright.green": "右绿斜条", + "block.minecraft.banner.stripe_downright.light_blue": "右淡蓝斜条", + "block.minecraft.banner.stripe_downright.light_gray": "右淡灰斜条", + "block.minecraft.banner.stripe_downright.lime": "右黄绿斜条", + "block.minecraft.banner.stripe_downright.magenta": "右品红斜条", + "block.minecraft.banner.stripe_downright.orange": "右橙斜条", + "block.minecraft.banner.stripe_downright.pink": "右粉红斜条", + "block.minecraft.banner.stripe_downright.purple": "右紫斜条", + "block.minecraft.banner.stripe_downright.red": "右红斜条", + "block.minecraft.banner.stripe_downright.white": "右白斜条", + "block.minecraft.banner.stripe_downright.yellow": "右黄斜条", + "block.minecraft.banner.stripe_left.black": "右黑竖条", + "block.minecraft.banner.stripe_left.blue": "右蓝竖条", + "block.minecraft.banner.stripe_left.brown": "右棕竖条", + "block.minecraft.banner.stripe_left.cyan": "右青竖条", + "block.minecraft.banner.stripe_left.gray": "右灰竖条", + "block.minecraft.banner.stripe_left.green": "右绿竖条", + "block.minecraft.banner.stripe_left.light_blue": "右淡蓝竖条", + "block.minecraft.banner.stripe_left.light_gray": "右淡灰竖条", + "block.minecraft.banner.stripe_left.lime": "右黄绿竖条", + "block.minecraft.banner.stripe_left.magenta": "右品红竖条", + "block.minecraft.banner.stripe_left.orange": "右橙竖条", + "block.minecraft.banner.stripe_left.pink": "右粉红竖条", + "block.minecraft.banner.stripe_left.purple": "右紫竖条", + "block.minecraft.banner.stripe_left.red": "右红竖条", + "block.minecraft.banner.stripe_left.white": "右白竖条", + "block.minecraft.banner.stripe_left.yellow": "右黄竖条", + "block.minecraft.banner.stripe_middle.black": "中黑横条", + "block.minecraft.banner.stripe_middle.blue": "中蓝横条", + "block.minecraft.banner.stripe_middle.brown": "中棕横条", + "block.minecraft.banner.stripe_middle.cyan": "中青横条", + "block.minecraft.banner.stripe_middle.gray": "中灰横条", + "block.minecraft.banner.stripe_middle.green": "中绿横条", + "block.minecraft.banner.stripe_middle.light_blue": "中淡蓝横条", + "block.minecraft.banner.stripe_middle.light_gray": "中淡灰横条", + "block.minecraft.banner.stripe_middle.lime": "中黄绿横条", + "block.minecraft.banner.stripe_middle.magenta": "中品红横条", + "block.minecraft.banner.stripe_middle.orange": "中橙横条", + "block.minecraft.banner.stripe_middle.pink": "中粉红横条", + "block.minecraft.banner.stripe_middle.purple": "中紫横条", + "block.minecraft.banner.stripe_middle.red": "中红横条", + "block.minecraft.banner.stripe_middle.white": "中白横条", + "block.minecraft.banner.stripe_middle.yellow": "中黄横条", + "block.minecraft.banner.stripe_right.black": "左黑竖条", + "block.minecraft.banner.stripe_right.blue": "左蓝竖条", + "block.minecraft.banner.stripe_right.brown": "左棕竖条", + "block.minecraft.banner.stripe_right.cyan": "左青竖条", + "block.minecraft.banner.stripe_right.gray": "左灰竖条", + "block.minecraft.banner.stripe_right.green": "左绿竖条", + "block.minecraft.banner.stripe_right.light_blue": "左淡蓝竖条", + "block.minecraft.banner.stripe_right.light_gray": "左淡灰竖条", + "block.minecraft.banner.stripe_right.lime": "左黄绿竖条", + "block.minecraft.banner.stripe_right.magenta": "左品红竖条", + "block.minecraft.banner.stripe_right.orange": "左橙竖条", + "block.minecraft.banner.stripe_right.pink": "左粉红竖条", + "block.minecraft.banner.stripe_right.purple": "左紫竖条", + "block.minecraft.banner.stripe_right.red": "左红竖条", + "block.minecraft.banner.stripe_right.white": "左白竖条", + "block.minecraft.banner.stripe_right.yellow": "左黄竖条", + "block.minecraft.banner.stripe_top.black": "顶黑横条", + "block.minecraft.banner.stripe_top.blue": "顶蓝横条", + "block.minecraft.banner.stripe_top.brown": "顶棕横条", + "block.minecraft.banner.stripe_top.cyan": "顶青横条", + "block.minecraft.banner.stripe_top.gray": "顶灰横条", + "block.minecraft.banner.stripe_top.green": "顶绿横条", + "block.minecraft.banner.stripe_top.light_blue": "顶淡蓝横条", + "block.minecraft.banner.stripe_top.light_gray": "顶淡灰横条", + "block.minecraft.banner.stripe_top.lime": "顶黄绿横条", + "block.minecraft.banner.stripe_top.magenta": "顶品红横条", + "block.minecraft.banner.stripe_top.orange": "顶橙横条", + "block.minecraft.banner.stripe_top.pink": "顶粉红横条", + "block.minecraft.banner.stripe_top.purple": "顶紫横条", + "block.minecraft.banner.stripe_top.red": "顶红横条", + "block.minecraft.banner.stripe_top.white": "顶白横条", + "block.minecraft.banner.stripe_top.yellow": "顶黄横条", + "block.minecraft.banner.triangle_bottom.black": "底黑三角", + "block.minecraft.banner.triangle_bottom.blue": "底蓝三角", + "block.minecraft.banner.triangle_bottom.brown": "底棕三角", + "block.minecraft.banner.triangle_bottom.cyan": "底青三角", + "block.minecraft.banner.triangle_bottom.gray": "底灰三角", + "block.minecraft.banner.triangle_bottom.green": "底绿三角", + "block.minecraft.banner.triangle_bottom.light_blue": "底淡蓝三角", + "block.minecraft.banner.triangle_bottom.light_gray": "底淡灰三角", + "block.minecraft.banner.triangle_bottom.lime": "底黄绿三角", + "block.minecraft.banner.triangle_bottom.magenta": "底品红三角", + "block.minecraft.banner.triangle_bottom.orange": "底橙三角", + "block.minecraft.banner.triangle_bottom.pink": "底粉红三角", + "block.minecraft.banner.triangle_bottom.purple": "底紫三角", + "block.minecraft.banner.triangle_bottom.red": "底红三角", + "block.minecraft.banner.triangle_bottom.white": "底白三角", + "block.minecraft.banner.triangle_bottom.yellow": "底黄三角", + "block.minecraft.banner.triangle_top.black": "顶黑三角", + "block.minecraft.banner.triangle_top.blue": "顶蓝三角", + "block.minecraft.banner.triangle_top.brown": "顶棕三角", + "block.minecraft.banner.triangle_top.cyan": "顶青三角", + "block.minecraft.banner.triangle_top.gray": "顶灰三角", + "block.minecraft.banner.triangle_top.green": "顶绿三角", + "block.minecraft.banner.triangle_top.light_blue": "顶淡蓝三角", + "block.minecraft.banner.triangle_top.light_gray": "顶淡灰三角", + "block.minecraft.banner.triangle_top.lime": "顶黄绿三角", + "block.minecraft.banner.triangle_top.magenta": "顶品红三角", + "block.minecraft.banner.triangle_top.orange": "顶橙三角", + "block.minecraft.banner.triangle_top.pink": "顶粉红三角", + "block.minecraft.banner.triangle_top.purple": "顶紫三角", + "block.minecraft.banner.triangle_top.red": "顶红三角", + "block.minecraft.banner.triangle_top.white": "顶白三角", + "block.minecraft.banner.triangle_top.yellow": "顶黄三角", + "block.minecraft.banner.triangles_bottom.black": "黑色底波纹", + "block.minecraft.banner.triangles_bottom.blue": "蓝色底波纹", + "block.minecraft.banner.triangles_bottom.brown": "棕色底波纹", + "block.minecraft.banner.triangles_bottom.cyan": "青色底波纹", + "block.minecraft.banner.triangles_bottom.gray": "灰色底波纹", + "block.minecraft.banner.triangles_bottom.green": "绿色底波纹", + "block.minecraft.banner.triangles_bottom.light_blue": "淡蓝色底波纹", + "block.minecraft.banner.triangles_bottom.light_gray": "淡灰色底波纹", + "block.minecraft.banner.triangles_bottom.lime": "黄绿色底波纹", + "block.minecraft.banner.triangles_bottom.magenta": "品红色底波纹", + "block.minecraft.banner.triangles_bottom.orange": "橙色底波纹", + "block.minecraft.banner.triangles_bottom.pink": "粉红色底波纹", + "block.minecraft.banner.triangles_bottom.purple": "紫色底波纹", + "block.minecraft.banner.triangles_bottom.red": "红色底波纹", + "block.minecraft.banner.triangles_bottom.white": "白色底波纹", + "block.minecraft.banner.triangles_bottom.yellow": "黄色底波纹", + "block.minecraft.banner.triangles_top.black": "黑色顶波纹", + "block.minecraft.banner.triangles_top.blue": "蓝色顶波纹", + "block.minecraft.banner.triangles_top.brown": "棕色顶波纹", + "block.minecraft.banner.triangles_top.cyan": "青色顶波纹", + "block.minecraft.banner.triangles_top.gray": "灰色顶波纹", + "block.minecraft.banner.triangles_top.green": "绿色顶波纹", + "block.minecraft.banner.triangles_top.light_blue": "淡蓝色顶波纹", + "block.minecraft.banner.triangles_top.light_gray": "淡灰色顶波纹", + "block.minecraft.banner.triangles_top.lime": "黄绿色顶波纹", + "block.minecraft.banner.triangles_top.magenta": "品红色顶波纹", + "block.minecraft.banner.triangles_top.orange": "橙色顶波纹", + "block.minecraft.banner.triangles_top.pink": "粉红色顶波纹", + "block.minecraft.banner.triangles_top.purple": "紫色顶波纹", + "block.minecraft.banner.triangles_top.red": "红色顶波纹", + "block.minecraft.banner.triangles_top.white": "白色顶波纹", + "block.minecraft.banner.triangles_top.yellow": "黄色顶波纹", + "block.minecraft.barrel": "木桶", + "block.minecraft.barrier": "屏障", + "block.minecraft.basalt": "玄武岩", + "block.minecraft.beacon": "信标", + "block.minecraft.beacon.primary": "主效果", + "block.minecraft.beacon.secondary": "辅助效果", + "block.minecraft.bed.no_sleep": "你只能在夜间或雷暴中入眠", + "block.minecraft.bed.not_safe": "你现在不能休息,周围有怪物在游荡", + "block.minecraft.bed.obstructed": "这张床已被阻挡", + "block.minecraft.bed.occupied": "这张床已被占用", + "block.minecraft.bed.too_far_away": "你现在不能休息,床太远了", + "block.minecraft.bedrock": "基岩", + "block.minecraft.bee_nest": "蜂巢", + "block.minecraft.beehive": "蜂箱", + "block.minecraft.beetroots": "甜菜根", + "block.minecraft.bell": "钟", + "block.minecraft.big_dripleaf": "大型垂滴叶", + "block.minecraft.big_dripleaf_stem": "大型垂滴叶茎", + "block.minecraft.birch_button": "白桦木按钮", + "block.minecraft.birch_door": "白桦木门", + "block.minecraft.birch_fence": "白桦木栅栏", + "block.minecraft.birch_fence_gate": "白桦木栅栏门", + "block.minecraft.birch_hanging_sign": "悬挂式白桦木告示牌", + "block.minecraft.birch_leaves": "白桦树叶", + "block.minecraft.birch_log": "白桦原木", + "block.minecraft.birch_planks": "白桦木板", + "block.minecraft.birch_pressure_plate": "白桦木压力板", + "block.minecraft.birch_sapling": "白桦树苗", + "block.minecraft.birch_shelf": "白桦木展示架", + "block.minecraft.birch_sign": "白桦木告示牌", + "block.minecraft.birch_slab": "白桦木台阶", + "block.minecraft.birch_stairs": "白桦木楼梯", + "block.minecraft.birch_trapdoor": "白桦木活板门", + "block.minecraft.birch_wall_hanging_sign": "墙上的悬挂式白桦木告示牌", + "block.minecraft.birch_wall_sign": "墙上的白桦木告示牌", + "block.minecraft.birch_wood": "白桦木", + "block.minecraft.black_banner": "黑色旗帜", + "block.minecraft.black_bed": "黑色床", + "block.minecraft.black_candle": "黑色蜡烛", + "block.minecraft.black_candle_cake": "插上黑色蜡烛的蛋糕", + "block.minecraft.black_carpet": "黑色地毯", + "block.minecraft.black_concrete": "黑色混凝土", + "block.minecraft.black_concrete_powder": "黑色混凝土粉末", + "block.minecraft.black_concrete_slab": "黑色混凝土台阶", + "block.minecraft.black_concrete_stairs": "黑色混凝土楼梯", + "block.minecraft.black_glazed_terracotta": "黑色带釉陶瓦", + "block.minecraft.black_shulker_box": "黑色潜影盒", + "block.minecraft.black_stained_glass": "黑色染色玻璃", + "block.minecraft.black_stained_glass_pane": "黑色染色玻璃板", + "block.minecraft.black_terracotta": "黑色陶瓦", + "block.minecraft.black_wool": "黑色羊毛", + "block.minecraft.black_wool_slab": "黑色羊毛台阶", + "block.minecraft.black_wool_stairs": "黑色羊毛楼梯", + "block.minecraft.blackstone": "黑石", + "block.minecraft.blackstone_slab": "黑石台阶", + "block.minecraft.blackstone_stairs": "黑石楼梯", + "block.minecraft.blackstone_wall": "黑石墙", + "block.minecraft.blast_furnace": "高炉", + "block.minecraft.blue_banner": "蓝色旗帜", + "block.minecraft.blue_bed": "蓝色床", + "block.minecraft.blue_candle": "蓝色蜡烛", + "block.minecraft.blue_candle_cake": "插上蓝色蜡烛的蛋糕", + "block.minecraft.blue_carpet": "蓝色地毯", + "block.minecraft.blue_concrete": "蓝色混凝土", + "block.minecraft.blue_concrete_powder": "蓝色混凝土粉末", + "block.minecraft.blue_concrete_slab": "蓝色混凝土台阶", + "block.minecraft.blue_concrete_stairs": "蓝色混凝土楼梯", + "block.minecraft.blue_glazed_terracotta": "蓝色带釉陶瓦", + "block.minecraft.blue_ice": "蓝冰", + "block.minecraft.blue_orchid": "兰花", + "block.minecraft.blue_shulker_box": "蓝色潜影盒", + "block.minecraft.blue_stained_glass": "蓝色染色玻璃", + "block.minecraft.blue_stained_glass_pane": "蓝色染色玻璃板", + "block.minecraft.blue_terracotta": "蓝色陶瓦", + "block.minecraft.blue_wool": "蓝色羊毛", + "block.minecraft.blue_wool_slab": "蓝色羊毛台阶", + "block.minecraft.blue_wool_stairs": "蓝色羊毛楼梯", + "block.minecraft.bone_block": "骨块", + "block.minecraft.bookshelf": "书架", + "block.minecraft.brain_coral": "脑纹珊瑚", + "block.minecraft.brain_coral_block": "脑纹珊瑚块", + "block.minecraft.brain_coral_fan": "脑纹珊瑚扇", + "block.minecraft.brain_coral_wall_fan": "墙上的脑纹珊瑚扇", + "block.minecraft.brewing_stand": "酿造台", + "block.minecraft.brick_slab": "红砖台阶", + "block.minecraft.brick_stairs": "红砖楼梯", + "block.minecraft.brick_wall": "红砖墙", + "block.minecraft.bricks": "红砖块", + "block.minecraft.brown_banner": "棕色旗帜", + "block.minecraft.brown_bed": "棕色床", + "block.minecraft.brown_candle": "棕色蜡烛", + "block.minecraft.brown_candle_cake": "插上棕色蜡烛的蛋糕", + "block.minecraft.brown_carpet": "棕色地毯", + "block.minecraft.brown_concrete": "棕色混凝土", + "block.minecraft.brown_concrete_powder": "棕色混凝土粉末", + "block.minecraft.brown_concrete_slab": "棕色混凝土台阶", + "block.minecraft.brown_concrete_stairs": "棕色混凝土楼梯", + "block.minecraft.brown_glazed_terracotta": "棕色带釉陶瓦", + "block.minecraft.brown_mushroom": "棕色蘑菇", + "block.minecraft.brown_mushroom_block": "棕色蘑菇方块", + "block.minecraft.brown_shulker_box": "棕色潜影盒", + "block.minecraft.brown_stained_glass": "棕色染色玻璃", + "block.minecraft.brown_stained_glass_pane": "棕色染色玻璃板", + "block.minecraft.brown_terracotta": "棕色陶瓦", + "block.minecraft.brown_wool": "棕色羊毛", + "block.minecraft.brown_wool_slab": "棕色羊毛台阶", + "block.minecraft.brown_wool_stairs": "棕色羊毛楼梯", + "block.minecraft.bubble_column": "气泡柱", + "block.minecraft.bubble_coral": "气泡珊瑚", + "block.minecraft.bubble_coral_block": "气泡珊瑚块", + "block.minecraft.bubble_coral_fan": "气泡珊瑚扇", + "block.minecraft.bubble_coral_wall_fan": "墙上的气泡珊瑚扇", + "block.minecraft.budding_amethyst": "紫水晶母岩", + "block.minecraft.bush": "灌木丛", + "block.minecraft.cactus": "仙人掌", + "block.minecraft.cactus_flower": "仙人掌花", + "block.minecraft.cake": "蛋糕", + "block.minecraft.calcite": "方解石", + "block.minecraft.calibrated_sculk_sensor": "校频幽匿感测体", + "block.minecraft.campfire": "营火", + "block.minecraft.candle": "蜡烛", + "block.minecraft.candle_cake": "插上蜡烛的蛋糕", + "block.minecraft.carrots": "胡萝卜", + "block.minecraft.cartography_table": "制图台", + "block.minecraft.carved_pumpkin": "雕刻南瓜", + "block.minecraft.cauldron": "炼药锅", + "block.minecraft.cave_air": "洞穴空气", + "block.minecraft.cave_vines": "洞穴藤蔓", + "block.minecraft.cave_vines_plant": "洞穴藤蔓植株", + "block.minecraft.chain": "锁链", + "block.minecraft.chain_command_block": "连锁型命令方块", + "block.minecraft.cherry_button": "樱花木按钮", + "block.minecraft.cherry_door": "樱花木门", + "block.minecraft.cherry_fence": "樱花木栅栏", + "block.minecraft.cherry_fence_gate": "樱花木栅栏门", + "block.minecraft.cherry_hanging_sign": "悬挂式樱花木告示牌", + "block.minecraft.cherry_leaves": "樱花树叶", + "block.minecraft.cherry_log": "樱花原木", + "block.minecraft.cherry_planks": "樱花木板", + "block.minecraft.cherry_pressure_plate": "樱花木压力板", + "block.minecraft.cherry_sapling": "樱花树苗", + "block.minecraft.cherry_shelf": "樱花木展示架", + "block.minecraft.cherry_sign": "樱花木告示牌", + "block.minecraft.cherry_slab": "樱花木台阶", + "block.minecraft.cherry_stairs": "樱花木楼梯", + "block.minecraft.cherry_trapdoor": "樱花木活板门", + "block.minecraft.cherry_wall_hanging_sign": "墙上的悬挂式樱花木告示牌", + "block.minecraft.cherry_wall_sign": "墙上的樱花木告示牌", + "block.minecraft.cherry_wood": "樱花木", + "block.minecraft.chest": "箱子", + "block.minecraft.chipped_anvil": "开裂的铁砧", + "block.minecraft.chiseled_bookshelf": "雕纹书架", + "block.minecraft.chiseled_cinnabar": "雕纹朱砂", + "block.minecraft.chiseled_copper": "雕纹铜块", + "block.minecraft.chiseled_deepslate": "雕纹深板岩", + "block.minecraft.chiseled_nether_bricks": "雕纹下界砖块", + "block.minecraft.chiseled_polished_blackstone": "雕纹磨制黑石", + "block.minecraft.chiseled_quartz_block": "雕纹石英块", + "block.minecraft.chiseled_red_sandstone": "雕纹红砂岩", + "block.minecraft.chiseled_resin_bricks": "雕纹树脂砖块", + "block.minecraft.chiseled_sandstone": "雕纹砂岩", + "block.minecraft.chiseled_stone_bricks": "雕纹石砖", + "block.minecraft.chiseled_sulfur": "雕纹硫黄", + "block.minecraft.chiseled_tuff": "雕纹凝灰岩", + "block.minecraft.chiseled_tuff_bricks": "雕纹凝灰岩砖", + "block.minecraft.chorus_flower": "紫颂花", + "block.minecraft.chorus_plant": "紫颂植株", + "block.minecraft.cinnabar": "朱砂", + "block.minecraft.cinnabar_brick_slab": "朱砂砖台阶", + "block.minecraft.cinnabar_brick_stairs": "朱砂砖楼梯", + "block.minecraft.cinnabar_brick_wall": "朱砂砖墙", + "block.minecraft.cinnabar_bricks": "朱砂砖", + "block.minecraft.cinnabar_slab": "朱砂台阶", + "block.minecraft.cinnabar_stairs": "朱砂楼梯", + "block.minecraft.cinnabar_wall": "朱砂墙", + "block.minecraft.clay": "黏土", + "block.minecraft.closed_eyeblossom": "闭合的眼眸花", + "block.minecraft.coal_block": "煤炭块", + "block.minecraft.coal_ore": "煤矿石", + "block.minecraft.coarse_dirt": "砂土", + "block.minecraft.cobbled_deepslate": "深板岩圆石", + "block.minecraft.cobbled_deepslate_slab": "深板岩圆石台阶", + "block.minecraft.cobbled_deepslate_stairs": "深板岩圆石楼梯", + "block.minecraft.cobbled_deepslate_wall": "深板岩圆石墙", + "block.minecraft.cobblestone": "圆石", + "block.minecraft.cobblestone_slab": "圆石台阶", + "block.minecraft.cobblestone_stairs": "圆石楼梯", + "block.minecraft.cobblestone_wall": "圆石墙", + "block.minecraft.cobweb": "蜘蛛网", + "block.minecraft.cocoa": "可可果", + "block.minecraft.command_block": "命令方块", + "block.minecraft.comparator": "红石比较器", + "block.minecraft.composter": "堆肥桶", + "block.minecraft.conduit": "潮涌核心", + "block.minecraft.copper_bars": "铜栏杆", + "block.minecraft.copper_block": "铜块", + "block.minecraft.copper_bulb": "铜灯", + "block.minecraft.copper_chain": "铜链", + "block.minecraft.copper_chest": "铜箱子", + "block.minecraft.copper_door": "铜门", + "block.minecraft.copper_golem_statue": "铜傀儡像", + "block.minecraft.copper_grate": "铜格栅", + "block.minecraft.copper_lantern": "铜灯笼", + "block.minecraft.copper_ore": "铜矿石", + "block.minecraft.copper_torch": "铜火把", + "block.minecraft.copper_trapdoor": "铜活板门", + "block.minecraft.copper_wall_torch": "墙上的铜火把", + "block.minecraft.cornflower": "矢车菊", + "block.minecraft.cracked_deepslate_bricks": "裂纹深板岩砖", + "block.minecraft.cracked_deepslate_tiles": "裂纹深板岩瓦", + "block.minecraft.cracked_nether_bricks": "裂纹下界砖块", + "block.minecraft.cracked_polished_blackstone_bricks": "裂纹磨制黑石砖", + "block.minecraft.cracked_stone_bricks": "裂纹石砖", + "block.minecraft.crafter": "合成器", + "block.minecraft.crafting_table": "工作台", + "block.minecraft.creaking_heart": "嘎枝之心", + "block.minecraft.creeper_head": "苦力怕的头", + "block.minecraft.creeper_wall_head": "墙上的苦力怕的头", + "block.minecraft.crimson_button": "绯红木按钮", + "block.minecraft.crimson_door": "绯红木门", + "block.minecraft.crimson_fence": "绯红木栅栏", + "block.minecraft.crimson_fence_gate": "绯红木栅栏门", + "block.minecraft.crimson_fungus": "绯红菌", + "block.minecraft.crimson_hanging_sign": "悬挂式绯红木告示牌", + "block.minecraft.crimson_hyphae": "绯红菌核", + "block.minecraft.crimson_nylium": "绯红菌岩", + "block.minecraft.crimson_planks": "绯红木板", + "block.minecraft.crimson_pressure_plate": "绯红木压力板", + "block.minecraft.crimson_roots": "绯红菌索", + "block.minecraft.crimson_shelf": "绯红木展示架", + "block.minecraft.crimson_sign": "绯红木告示牌", + "block.minecraft.crimson_slab": "绯红木台阶", + "block.minecraft.crimson_stairs": "绯红木楼梯", + "block.minecraft.crimson_stem": "绯红菌柄", + "block.minecraft.crimson_trapdoor": "绯红木活板门", + "block.minecraft.crimson_wall_hanging_sign": "墙上的悬挂式绯红木告示牌", + "block.minecraft.crimson_wall_sign": "墙上的绯红木告示牌", + "block.minecraft.crying_obsidian": "哭泣的黑曜石", + "block.minecraft.cut_copper": "切制铜块", + "block.minecraft.cut_copper_slab": "切制铜台阶", + "block.minecraft.cut_copper_stairs": "切制铜楼梯", + "block.minecraft.cut_red_sandstone": "切制红砂岩", + "block.minecraft.cut_red_sandstone_slab": "切制红砂岩台阶", + "block.minecraft.cut_sandstone": "切制砂岩", + "block.minecraft.cut_sandstone_slab": "切制砂岩台阶", + "block.minecraft.cyan_banner": "青色旗帜", + "block.minecraft.cyan_bed": "青色床", + "block.minecraft.cyan_candle": "青色蜡烛", + "block.minecraft.cyan_candle_cake": "插上青色蜡烛的蛋糕", + "block.minecraft.cyan_carpet": "青色地毯", + "block.minecraft.cyan_concrete": "青色混凝土", + "block.minecraft.cyan_concrete_powder": "青色混凝土粉末", + "block.minecraft.cyan_concrete_slab": "青色混凝土台阶", + "block.minecraft.cyan_concrete_stairs": "青色混凝土楼梯", + "block.minecraft.cyan_glazed_terracotta": "青色带釉陶瓦", + "block.minecraft.cyan_shulker_box": "青色潜影盒", + "block.minecraft.cyan_stained_glass": "青色染色玻璃", + "block.minecraft.cyan_stained_glass_pane": "青色染色玻璃板", + "block.minecraft.cyan_terracotta": "青色陶瓦", + "block.minecraft.cyan_wool": "青色羊毛", + "block.minecraft.cyan_wool_slab": "青色羊毛台阶", + "block.minecraft.cyan_wool_stairs": "青色羊毛楼梯", + "block.minecraft.damaged_anvil": "损坏的铁砧", + "block.minecraft.dandelion": "蒲公英", + "block.minecraft.dark_oak_button": "深色橡木按钮", + "block.minecraft.dark_oak_door": "深色橡木门", + "block.minecraft.dark_oak_fence": "深色橡木栅栏", + "block.minecraft.dark_oak_fence_gate": "深色橡木栅栏门", + "block.minecraft.dark_oak_hanging_sign": "悬挂式深色橡木告示牌", + "block.minecraft.dark_oak_leaves": "深色橡树树叶", + "block.minecraft.dark_oak_log": "深色橡木原木", + "block.minecraft.dark_oak_planks": "深色橡木木板", + "block.minecraft.dark_oak_pressure_plate": "深色橡木压力板", + "block.minecraft.dark_oak_sapling": "深色橡树树苗", + "block.minecraft.dark_oak_shelf": "深色橡木展示架", + "block.minecraft.dark_oak_sign": "深色橡木告示牌", + "block.minecraft.dark_oak_slab": "深色橡木台阶", + "block.minecraft.dark_oak_stairs": "深色橡木楼梯", + "block.minecraft.dark_oak_trapdoor": "深色橡木活板门", + "block.minecraft.dark_oak_wall_hanging_sign": "墙上的悬挂式深色橡木告示牌", + "block.minecraft.dark_oak_wall_sign": "墙上的深色橡木告示牌", + "block.minecraft.dark_oak_wood": "深色橡木", + "block.minecraft.dark_prismarine": "暗海晶石", + "block.minecraft.dark_prismarine_slab": "暗海晶石台阶", + "block.minecraft.dark_prismarine_stairs": "暗海晶石楼梯", + "block.minecraft.daylight_detector": "阳光探测器", + "block.minecraft.dead_brain_coral": "失活的脑纹珊瑚", + "block.minecraft.dead_brain_coral_block": "失活的脑纹珊瑚块", + "block.minecraft.dead_brain_coral_fan": "失活的脑纹珊瑚扇", + "block.minecraft.dead_brain_coral_wall_fan": "墙上的失活脑纹珊瑚扇", + "block.minecraft.dead_bubble_coral": "失活的气泡珊瑚", + "block.minecraft.dead_bubble_coral_block": "失活的气泡珊瑚块", + "block.minecraft.dead_bubble_coral_fan": "失活的气泡珊瑚扇", + "block.minecraft.dead_bubble_coral_wall_fan": "墙上的失活气泡珊瑚扇", + "block.minecraft.dead_bush": "枯萎的灌木", + "block.minecraft.dead_fire_coral": "失活的火珊瑚", + "block.minecraft.dead_fire_coral_block": "失活的火珊瑚块", + "block.minecraft.dead_fire_coral_fan": "失活的火珊瑚扇", + "block.minecraft.dead_fire_coral_wall_fan": "墙上的失活火珊瑚扇", + "block.minecraft.dead_horn_coral": "失活的鹿角珊瑚", + "block.minecraft.dead_horn_coral_block": "失活的鹿角珊瑚块", + "block.minecraft.dead_horn_coral_fan": "失活的鹿角珊瑚扇", + "block.minecraft.dead_horn_coral_wall_fan": "墙上的失活鹿角珊瑚扇", + "block.minecraft.dead_tube_coral": "失活的管珊瑚", + "block.minecraft.dead_tube_coral_block": "失活的管珊瑚块", + "block.minecraft.dead_tube_coral_fan": "失活的管珊瑚扇", + "block.minecraft.dead_tube_coral_wall_fan": "墙上的失活管珊瑚扇", + "block.minecraft.decorated_pot": "饰纹陶罐", + "block.minecraft.deepslate": "深板岩", + "block.minecraft.deepslate_brick_slab": "深板岩砖台阶", + "block.minecraft.deepslate_brick_stairs": "深板岩砖楼梯", + "block.minecraft.deepslate_brick_wall": "深板岩砖墙", + "block.minecraft.deepslate_bricks": "深板岩砖", + "block.minecraft.deepslate_coal_ore": "深层煤矿石", + "block.minecraft.deepslate_copper_ore": "深层铜矿石", + "block.minecraft.deepslate_diamond_ore": "深层钻石矿石", + "block.minecraft.deepslate_emerald_ore": "深层绿宝石矿石", + "block.minecraft.deepslate_gold_ore": "深层金矿石", + "block.minecraft.deepslate_iron_ore": "深层铁矿石", + "block.minecraft.deepslate_lapis_ore": "深层青金石矿石", + "block.minecraft.deepslate_redstone_ore": "深层红石矿石", + "block.minecraft.deepslate_tile_slab": "深板岩瓦台阶", + "block.minecraft.deepslate_tile_stairs": "深板岩瓦楼梯", + "block.minecraft.deepslate_tile_wall": "深板岩瓦墙", + "block.minecraft.deepslate_tiles": "深板岩瓦", + "block.minecraft.detector_rail": "探测铁轨", + "block.minecraft.diamond_block": "钻石块", + "block.minecraft.diamond_ore": "钻石矿石", + "block.minecraft.diorite": "闪长岩", + "block.minecraft.diorite_slab": "闪长岩台阶", + "block.minecraft.diorite_stairs": "闪长岩楼梯", + "block.minecraft.diorite_wall": "闪长岩墙", + "block.minecraft.dirt": "泥土", + "block.minecraft.dirt_path": "土径", + "block.minecraft.dispenser": "发射器", + "block.minecraft.dragon_egg": "龙蛋", + "block.minecraft.dragon_head": "龙首", + "block.minecraft.dragon_wall_head": "墙上的龙首", + "block.minecraft.dried_ghast": "失水恶魂", + "block.minecraft.dried_kelp_block": "干海带块", + "block.minecraft.dripstone_block": "滴水石块", + "block.minecraft.dropper": "投掷器", + "block.minecraft.emerald_block": "绿宝石块", + "block.minecraft.emerald_ore": "绿宝石矿石", + "block.minecraft.enchanting_table": "附魔台", + "block.minecraft.end_gateway": "末地折跃门", + "block.minecraft.end_portal": "末地传送门", + "block.minecraft.end_portal_frame": "末地传送门框架", + "block.minecraft.end_rod": "末地烛", + "block.minecraft.end_stone": "末地石", + "block.minecraft.end_stone_brick_slab": "末地石砖台阶", + "block.minecraft.end_stone_brick_stairs": "末地石砖楼梯", + "block.minecraft.end_stone_brick_wall": "末地石砖墙", + "block.minecraft.end_stone_bricks": "末地石砖", + "block.minecraft.ender_chest": "末影箱", + "block.minecraft.exposed_chiseled_copper": "斑驳的雕纹铜块", + "block.minecraft.exposed_copper": "斑驳的铜块", + "block.minecraft.exposed_copper_bars": "斑驳的铜栏杆", + "block.minecraft.exposed_copper_bulb": "斑驳的铜灯", + "block.minecraft.exposed_copper_chain": "斑驳的铜链", + "block.minecraft.exposed_copper_chest": "斑驳的铜箱子", + "block.minecraft.exposed_copper_door": "斑驳的铜门", + "block.minecraft.exposed_copper_golem_statue": "斑驳的铜傀儡像", + "block.minecraft.exposed_copper_grate": "斑驳的铜格栅", + "block.minecraft.exposed_copper_lantern": "斑驳的铜灯笼", + "block.minecraft.exposed_copper_trapdoor": "斑驳的铜活板门", + "block.minecraft.exposed_cut_copper": "斑驳的切制铜块", + "block.minecraft.exposed_cut_copper_slab": "斑驳的切制铜台阶", + "block.minecraft.exposed_cut_copper_stairs": "斑驳的切制铜楼梯", + "block.minecraft.exposed_lightning_rod": "斑驳的避雷针", + "block.minecraft.farmland": "耕地", + "block.minecraft.fern": "蕨", + "block.minecraft.fire": "火", + "block.minecraft.fire_coral": "火珊瑚", + "block.minecraft.fire_coral_block": "火珊瑚块", + "block.minecraft.fire_coral_fan": "火珊瑚扇", + "block.minecraft.fire_coral_wall_fan": "墙上的火珊瑚扇", + "block.minecraft.firefly_bush": "萤火虫灌木丛", + "block.minecraft.fletching_table": "制箭台", + "block.minecraft.flower_pot": "花盆", + "block.minecraft.flowering_azalea": "盛开的杜鹃花丛", + "block.minecraft.flowering_azalea_leaves": "盛开的杜鹃树叶", + "block.minecraft.frogspawn": "青蛙卵", + "block.minecraft.frosted_ice": "霜冰", + "block.minecraft.furnace": "熔炉", + "block.minecraft.gilded_blackstone": "镶金黑石", + "block.minecraft.glass": "玻璃", + "block.minecraft.glass_pane": "玻璃板", + "block.minecraft.glow_lichen": "发光地衣", + "block.minecraft.glowstone": "荧石", + "block.minecraft.gold_block": "金块", + "block.minecraft.gold_ore": "金矿石", + "block.minecraft.golden_dandelion": "金蒲公英", + "block.minecraft.granite": "花岗岩", + "block.minecraft.granite_slab": "花岗岩台阶", + "block.minecraft.granite_stairs": "花岗岩楼梯", + "block.minecraft.granite_wall": "花岗岩墙", + "block.minecraft.grass": "草", + "block.minecraft.grass_block": "草方块", + "block.minecraft.gravel": "沙砾", + "block.minecraft.gray_banner": "灰色旗帜", + "block.minecraft.gray_bed": "灰色床", + "block.minecraft.gray_candle": "灰色蜡烛", + "block.minecraft.gray_candle_cake": "插上灰色蜡烛的蛋糕", + "block.minecraft.gray_carpet": "灰色地毯", + "block.minecraft.gray_concrete": "灰色混凝土", + "block.minecraft.gray_concrete_powder": "灰色混凝土粉末", + "block.minecraft.gray_concrete_slab": "灰色混凝土台阶", + "block.minecraft.gray_concrete_stairs": "灰色混凝土楼梯", + "block.minecraft.gray_glazed_terracotta": "灰色带釉陶瓦", + "block.minecraft.gray_shulker_box": "灰色潜影盒", + "block.minecraft.gray_stained_glass": "灰色染色玻璃", + "block.minecraft.gray_stained_glass_pane": "灰色染色玻璃板", + "block.minecraft.gray_terracotta": "灰色陶瓦", + "block.minecraft.gray_wool": "灰色羊毛", + "block.minecraft.gray_wool_slab": "灰色羊毛台阶", + "block.minecraft.gray_wool_stairs": "灰色羊毛楼梯", + "block.minecraft.green_banner": "绿色旗帜", + "block.minecraft.green_bed": "绿色床", + "block.minecraft.green_candle": "绿色蜡烛", + "block.minecraft.green_candle_cake": "插上绿色蜡烛的蛋糕", + "block.minecraft.green_carpet": "绿色地毯", + "block.minecraft.green_concrete": "绿色混凝土", + "block.minecraft.green_concrete_powder": "绿色混凝土粉末", + "block.minecraft.green_concrete_slab": "绿色混凝土台阶", + "block.minecraft.green_concrete_stairs": "绿色混凝土楼梯", + "block.minecraft.green_glazed_terracotta": "绿色带釉陶瓦", + "block.minecraft.green_shulker_box": "绿色潜影盒", + "block.minecraft.green_stained_glass": "绿色染色玻璃", + "block.minecraft.green_stained_glass_pane": "绿色染色玻璃板", + "block.minecraft.green_terracotta": "绿色陶瓦", + "block.minecraft.green_wool": "绿色羊毛", + "block.minecraft.green_wool_slab": "绿色羊毛台阶", + "block.minecraft.green_wool_stairs": "绿色羊毛楼梯", + "block.minecraft.grindstone": "砂轮", + "block.minecraft.hanging_roots": "垂根", + "block.minecraft.hay_block": "干草捆", + "block.minecraft.heavy_core": "沉重核心", + "block.minecraft.heavy_weighted_pressure_plate": "重质测重压力板", + "block.minecraft.honey_block": "蜂蜜块", + "block.minecraft.honeycomb_block": "蜜脾块", + "block.minecraft.hopper": "漏斗", + "block.minecraft.horn_coral": "鹿角珊瑚", + "block.minecraft.horn_coral_block": "鹿角珊瑚块", + "block.minecraft.horn_coral_fan": "鹿角珊瑚扇", + "block.minecraft.horn_coral_wall_fan": "墙上的鹿角珊瑚扇", + "block.minecraft.ice": "冰", + "block.minecraft.ice_crystal": "冰晶", + "block.minecraft.icicle": "冰锥", + "block.minecraft.infested_chiseled_stone_bricks": "虫蚀雕纹石砖", + "block.minecraft.infested_cobblestone": "虫蚀圆石", + "block.minecraft.infested_cracked_stone_bricks": "虫蚀裂纹石砖", + "block.minecraft.infested_deepslate": "虫蚀深板岩", + "block.minecraft.infested_mossy_stone_bricks": "虫蚀苔石砖", + "block.minecraft.infested_stone": "虫蚀石头", + "block.minecraft.infested_stone_bricks": "虫蚀石砖", + "block.minecraft.iron_bars": "铁栏杆", + "block.minecraft.iron_block": "铁块", + "block.minecraft.iron_chain": "铁链", + "block.minecraft.iron_door": "铁门", + "block.minecraft.iron_ore": "铁矿石", + "block.minecraft.iron_trapdoor": "铁活板门", + "block.minecraft.jack_o_lantern": "南瓜灯", + "block.minecraft.jigsaw": "拼图方块", + "block.minecraft.jukebox": "唱片机", + "block.minecraft.jungle_button": "丛林木按钮", + "block.minecraft.jungle_door": "丛林木门", + "block.minecraft.jungle_fence": "丛林木栅栏", + "block.minecraft.jungle_fence_gate": "丛林木栅栏门", + "block.minecraft.jungle_hanging_sign": "悬挂式丛林木告示牌", + "block.minecraft.jungle_leaves": "丛林树叶", + "block.minecraft.jungle_log": "丛林原木", + "block.minecraft.jungle_planks": "丛林木板", + "block.minecraft.jungle_pressure_plate": "丛林木压力板", + "block.minecraft.jungle_sapling": "丛林树苗", + "block.minecraft.jungle_shelf": "丛林木展示架", + "block.minecraft.jungle_sign": "丛林木告示牌", + "block.minecraft.jungle_slab": "丛林木台阶", + "block.minecraft.jungle_stairs": "丛林木楼梯", + "block.minecraft.jungle_trapdoor": "丛林木活板门", + "block.minecraft.jungle_wall_hanging_sign": "墙上的悬挂式丛林木告示牌", + "block.minecraft.jungle_wall_sign": "墙上的丛林木告示牌", + "block.minecraft.jungle_wood": "丛林木", + "block.minecraft.kelp": "海带", + "block.minecraft.kelp_plant": "海带植株", + "block.minecraft.ladder": "梯子", + "block.minecraft.lantern": "灯笼", + "block.minecraft.lapis_block": "青金石块", + "block.minecraft.lapis_ore": "青金石矿石", + "block.minecraft.large_amethyst_bud": "大型紫晶芽", + "block.minecraft.large_fern": "大型蕨", + "block.minecraft.lava": "熔岩", + "block.minecraft.lava_cauldron": "装有熔岩的炼药锅", + "block.minecraft.leaf_litter": "枯叶堆", + "block.minecraft.lectern": "讲台", + "block.minecraft.lever": "拉杆", + "block.minecraft.light": "光源方块", + "block.minecraft.light_blue_banner": "淡蓝色旗帜", + "block.minecraft.light_blue_bed": "淡蓝色床", + "block.minecraft.light_blue_candle": "淡蓝色蜡烛", + "block.minecraft.light_blue_candle_cake": "插上淡蓝色蜡烛的蛋糕", + "block.minecraft.light_blue_carpet": "淡蓝色地毯", + "block.minecraft.light_blue_concrete": "淡蓝色混凝土", + "block.minecraft.light_blue_concrete_powder": "淡蓝色混凝土粉末", + "block.minecraft.light_blue_concrete_slab": "淡蓝色混凝土台阶", + "block.minecraft.light_blue_concrete_stairs": "淡蓝色混凝土楼梯", + "block.minecraft.light_blue_glazed_terracotta": "淡蓝色带釉陶瓦", + "block.minecraft.light_blue_shulker_box": "淡蓝色潜影盒", + "block.minecraft.light_blue_stained_glass": "淡蓝色染色玻璃", + "block.minecraft.light_blue_stained_glass_pane": "淡蓝色染色玻璃板", + "block.minecraft.light_blue_terracotta": "淡蓝色陶瓦", + "block.minecraft.light_blue_wool": "淡蓝色羊毛", + "block.minecraft.light_blue_wool_slab": "淡蓝色羊毛台阶", + "block.minecraft.light_blue_wool_stairs": "淡蓝色羊毛楼梯", + "block.minecraft.light_gray_banner": "淡灰色旗帜", + "block.minecraft.light_gray_bed": "淡灰色床", + "block.minecraft.light_gray_candle": "淡灰色蜡烛", + "block.minecraft.light_gray_candle_cake": "插上淡灰色蜡烛的蛋糕", + "block.minecraft.light_gray_carpet": "淡灰色地毯", + "block.minecraft.light_gray_concrete": "淡灰色混凝土", + "block.minecraft.light_gray_concrete_powder": "淡灰色混凝土粉末", + "block.minecraft.light_gray_concrete_slab": "淡灰色混凝土台阶", + "block.minecraft.light_gray_concrete_stairs": "淡灰色混凝土楼梯", + "block.minecraft.light_gray_glazed_terracotta": "淡灰色带釉陶瓦", + "block.minecraft.light_gray_shulker_box": "淡灰色潜影盒", + "block.minecraft.light_gray_stained_glass": "淡灰色染色玻璃", + "block.minecraft.light_gray_stained_glass_pane": "淡灰色染色玻璃板", + "block.minecraft.light_gray_terracotta": "淡灰色陶瓦", + "block.minecraft.light_gray_wool": "淡灰色羊毛", + "block.minecraft.light_gray_wool_slab": "淡灰色羊毛台阶", + "block.minecraft.light_gray_wool_stairs": "淡灰色羊毛楼梯", + "block.minecraft.light_weighted_pressure_plate": "轻质测重压力板", + "block.minecraft.lightning_rod": "避雷针", + "block.minecraft.lilac": "丁香", + "block.minecraft.lily_of_the_valley": "铃兰", + "block.minecraft.lily_pad": "睡莲", + "block.minecraft.lime_banner": "黄绿色旗帜", + "block.minecraft.lime_bed": "黄绿色床", + "block.minecraft.lime_candle": "黄绿色蜡烛", + "block.minecraft.lime_candle_cake": "插上黄绿色蜡烛的蛋糕", + "block.minecraft.lime_carpet": "黄绿色地毯", + "block.minecraft.lime_concrete": "黄绿色混凝土", + "block.minecraft.lime_concrete_powder": "黄绿色混凝土粉末", + "block.minecraft.lime_concrete_slab": "黄绿色混凝土台阶", + "block.minecraft.lime_concrete_stairs": "黄绿色混凝土楼梯", + "block.minecraft.lime_glazed_terracotta": "黄绿色带釉陶瓦", + "block.minecraft.lime_shulker_box": "黄绿色潜影盒", + "block.minecraft.lime_stained_glass": "黄绿色染色玻璃", + "block.minecraft.lime_stained_glass_pane": "黄绿色染色玻璃板", + "block.minecraft.lime_terracotta": "黄绿色陶瓦", + "block.minecraft.lime_wool": "黄绿色羊毛", + "block.minecraft.lime_wool_slab": "黄绿色羊毛台阶", + "block.minecraft.lime_wool_stairs": "黄绿色羊毛楼梯", + "block.minecraft.lodestone": "磁石", + "block.minecraft.loom": "织布机", + "block.minecraft.magenta_banner": "品红色旗帜", + "block.minecraft.magenta_bed": "品红色床", + "block.minecraft.magenta_candle": "品红色蜡烛", + "block.minecraft.magenta_candle_cake": "插上品红色蜡烛的蛋糕", + "block.minecraft.magenta_carpet": "品红色地毯", + "block.minecraft.magenta_concrete": "品红色混凝土", + "block.minecraft.magenta_concrete_powder": "品红色混凝土粉末", + "block.minecraft.magenta_concrete_slab": "品红色混凝土台阶", + "block.minecraft.magenta_concrete_stairs": "品红色混凝土楼梯", + "block.minecraft.magenta_glazed_terracotta": "品红色带釉陶瓦", + "block.minecraft.magenta_shulker_box": "品红色潜影盒", + "block.minecraft.magenta_stained_glass": "品红色染色玻璃", + "block.minecraft.magenta_stained_glass_pane": "品红色染色玻璃板", + "block.minecraft.magenta_terracotta": "品红色陶瓦", + "block.minecraft.magenta_wool": "品红色羊毛", + "block.minecraft.magenta_wool_slab": "品红色羊毛台阶", + "block.minecraft.magenta_wool_stairs": "品红色羊毛楼梯", + "block.minecraft.magma_block": "岩浆块", + "block.minecraft.mangrove_button": "红树木按钮", + "block.minecraft.mangrove_door": "红树木门", + "block.minecraft.mangrove_fence": "红树木栅栏", + "block.minecraft.mangrove_fence_gate": "红树木栅栏门", + "block.minecraft.mangrove_hanging_sign": "悬挂式红树木告示牌", + "block.minecraft.mangrove_leaves": "红树树叶", + "block.minecraft.mangrove_log": "红树原木", + "block.minecraft.mangrove_planks": "红树木板", + "block.minecraft.mangrove_pressure_plate": "红树木压力板", + "block.minecraft.mangrove_propagule": "红树胎生苗", + "block.minecraft.mangrove_roots": "红树根", + "block.minecraft.mangrove_shelf": "红树木展示架", + "block.minecraft.mangrove_sign": "红树木告示牌", + "block.minecraft.mangrove_slab": "红树木台阶", + "block.minecraft.mangrove_stairs": "红树木楼梯", + "block.minecraft.mangrove_trapdoor": "红树木活板门", + "block.minecraft.mangrove_wall_hanging_sign": "墙上的悬挂式红树木告示牌", + "block.minecraft.mangrove_wall_sign": "墙上的红树木告示牌", + "block.minecraft.mangrove_wood": "红树木", + "block.minecraft.medium_amethyst_bud": "中型紫晶芽", + "block.minecraft.melon": "西瓜", + "block.minecraft.melon_stem": "西瓜茎", + "block.minecraft.moss_block": "苔藓块", + "block.minecraft.moss_carpet": "覆地苔藓", + "block.minecraft.mossy_cobblestone": "苔石", + "block.minecraft.mossy_cobblestone_slab": "苔石台阶", + "block.minecraft.mossy_cobblestone_stairs": "苔石楼梯", + "block.minecraft.mossy_cobblestone_wall": "苔石墙", + "block.minecraft.mossy_stone_brick_slab": "苔石砖台阶", + "block.minecraft.mossy_stone_brick_stairs": "苔石砖楼梯", + "block.minecraft.mossy_stone_brick_wall": "苔石砖墙", + "block.minecraft.mossy_stone_bricks": "苔石砖", + "block.minecraft.moving_piston": "移动的活塞", + "block.minecraft.mud": "泥巴", + "block.minecraft.mud_brick_slab": "泥砖台阶", + "block.minecraft.mud_brick_stairs": "泥砖楼梯", + "block.minecraft.mud_brick_wall": "泥砖墙", + "block.minecraft.mud_bricks": "泥砖", + "block.minecraft.muddy_mangrove_roots": "沾泥的红树根", + "block.minecraft.mushroom_stem": "蘑菇柄", + "block.minecraft.mycelium": "菌丝体", + "block.minecraft.nether_brick_fence": "下界砖栅栏", + "block.minecraft.nether_brick_slab": "下界砖台阶", + "block.minecraft.nether_brick_stairs": "下界砖楼梯", + "block.minecraft.nether_brick_wall": "下界砖墙", + "block.minecraft.nether_bricks": "下界砖块", + "block.minecraft.nether_gold_ore": "下界金矿石", + "block.minecraft.nether_portal": "下界传送门", + "block.minecraft.nether_quartz_ore": "下界石英矿石", + "block.minecraft.nether_sprouts": "下界苗", + "block.minecraft.nether_wart": "下界疣", + "block.minecraft.nether_wart_block": "下界疣块", + "block.minecraft.netherite_block": "下界合金块", + "block.minecraft.netherrack": "下界岩", + "block.minecraft.note_block": "音符盒", + "block.minecraft.oak_button": "橡木按钮", + "block.minecraft.oak_door": "橡木门", + "block.minecraft.oak_fence": "橡木栅栏", + "block.minecraft.oak_fence_gate": "橡木栅栏门", + "block.minecraft.oak_hanging_sign": "悬挂式橡木告示牌", + "block.minecraft.oak_leaves": "橡树树叶", + "block.minecraft.oak_log": "橡木原木", + "block.minecraft.oak_planks": "橡木木板", + "block.minecraft.oak_pressure_plate": "橡木压力板", + "block.minecraft.oak_sapling": "橡树树苗", + "block.minecraft.oak_shelf": "橡木展示架", + "block.minecraft.oak_sign": "橡木告示牌", + "block.minecraft.oak_slab": "橡木台阶", + "block.minecraft.oak_stairs": "橡木楼梯", + "block.minecraft.oak_trapdoor": "橡木活板门", + "block.minecraft.oak_wall_hanging_sign": "墙上的悬挂式橡木告示牌", + "block.minecraft.oak_wall_sign": "墙上的橡木告示牌", + "block.minecraft.oak_wood": "橡木", + "block.minecraft.observer": "侦测器", + "block.minecraft.obsidian": "黑曜石", + "block.minecraft.ochre_froglight": "赭黄蛙明灯", + "block.minecraft.ominous_banner": "不祥旗帜", + "block.minecraft.open_eyeblossom": "张开的眼眸花", + "block.minecraft.orange_banner": "橙色旗帜", + "block.minecraft.orange_bed": "橙色床", + "block.minecraft.orange_candle": "橙色蜡烛", + "block.minecraft.orange_candle_cake": "插上橙色蜡烛的蛋糕", + "block.minecraft.orange_carpet": "橙色地毯", + "block.minecraft.orange_concrete": "橙色混凝土", + "block.minecraft.orange_concrete_powder": "橙色混凝土粉末", + "block.minecraft.orange_concrete_slab": "橙色混凝土台阶", + "block.minecraft.orange_concrete_stairs": "橙色混凝土楼梯", + "block.minecraft.orange_glazed_terracotta": "橙色带釉陶瓦", + "block.minecraft.orange_poplar_leaves": "橙色杨树树叶", + "block.minecraft.orange_shulker_box": "橙色潜影盒", + "block.minecraft.orange_stained_glass": "橙色染色玻璃", + "block.minecraft.orange_stained_glass_pane": "橙色染色玻璃板", + "block.minecraft.orange_terracotta": "橙色陶瓦", + "block.minecraft.orange_tulip": "橙色郁金香", + "block.minecraft.orange_wool": "橙色羊毛", + "block.minecraft.orange_wool_slab": "橙色羊毛台阶", + "block.minecraft.orange_wool_stairs": "橙色羊毛楼梯", + "block.minecraft.oxeye_daisy": "滨菊", + "block.minecraft.oxidized_chiseled_copper": "氧化的雕纹铜块", + "block.minecraft.oxidized_copper": "氧化的铜块", + "block.minecraft.oxidized_copper_bars": "氧化的铜栏杆", + "block.minecraft.oxidized_copper_bulb": "氧化的铜灯", + "block.minecraft.oxidized_copper_chain": "氧化的铜链", + "block.minecraft.oxidized_copper_chest": "氧化的铜箱子", + "block.minecraft.oxidized_copper_door": "氧化的铜门", + "block.minecraft.oxidized_copper_golem_statue": "氧化的铜傀儡像", + "block.minecraft.oxidized_copper_grate": "氧化的铜格栅", + "block.minecraft.oxidized_copper_lantern": "氧化的铜灯笼", + "block.minecraft.oxidized_copper_trapdoor": "氧化的铜活板门", + "block.minecraft.oxidized_cut_copper": "氧化的切制铜块", + "block.minecraft.oxidized_cut_copper_slab": "氧化的切制铜台阶", + "block.minecraft.oxidized_cut_copper_stairs": "氧化的切制铜楼梯", + "block.minecraft.oxidized_lightning_rod": "氧化的避雷针", + "block.minecraft.packed_ice": "浮冰", + "block.minecraft.packed_mud": "泥坯", + "block.minecraft.pale_hanging_moss": "苍白垂须", + "block.minecraft.pale_moss_block": "苍白苔藓块", + "block.minecraft.pale_moss_carpet": "苍白覆地苔藓", + "block.minecraft.pale_oak_button": "苍白橡木按钮", + "block.minecraft.pale_oak_door": "苍白橡木门", + "block.minecraft.pale_oak_fence": "苍白橡木栅栏", + "block.minecraft.pale_oak_fence_gate": "苍白橡木栅栏门", + "block.minecraft.pale_oak_hanging_sign": "悬挂式苍白橡木告示牌", + "block.minecraft.pale_oak_leaves": "苍白橡树树叶", + "block.minecraft.pale_oak_log": "苍白橡木原木", + "block.minecraft.pale_oak_planks": "苍白橡木木板", + "block.minecraft.pale_oak_pressure_plate": "苍白橡木压力板", + "block.minecraft.pale_oak_sapling": "苍白橡树树苗", + "block.minecraft.pale_oak_shelf": "苍白橡木展示架", + "block.minecraft.pale_oak_sign": "苍白橡木告示牌", + "block.minecraft.pale_oak_slab": "苍白橡木台阶", + "block.minecraft.pale_oak_stairs": "苍白橡木楼梯", + "block.minecraft.pale_oak_trapdoor": "苍白橡木活板门", + "block.minecraft.pale_oak_wall_hanging_sign": "墙上的悬挂式苍白橡木告示牌", + "block.minecraft.pale_oak_wall_sign": "墙上的苍白橡木告示牌", + "block.minecraft.pale_oak_wood": "苍白橡木", + "block.minecraft.pearlescent_froglight": "珠光蛙明灯", + "block.minecraft.peony": "牡丹", + "block.minecraft.petrified_oak_slab": "石化橡木台阶", + "block.minecraft.piglin_head": "猪灵的头", + "block.minecraft.piglin_wall_head": "墙上的猪灵的头", + "block.minecraft.pink_banner": "粉红色旗帜", + "block.minecraft.pink_bed": "粉红色床", + "block.minecraft.pink_candle": "粉红色蜡烛", + "block.minecraft.pink_candle_cake": "插上粉红色蜡烛的蛋糕", + "block.minecraft.pink_carpet": "粉红色地毯", + "block.minecraft.pink_concrete": "粉红色混凝土", + "block.minecraft.pink_concrete_powder": "粉红色混凝土粉末", + "block.minecraft.pink_concrete_slab": "粉红色混凝土台阶", + "block.minecraft.pink_concrete_stairs": "粉红色混凝土楼梯", + "block.minecraft.pink_glazed_terracotta": "粉红色带釉陶瓦", + "block.minecraft.pink_petals": "粉红色花簇", + "block.minecraft.pink_shulker_box": "粉红色潜影盒", + "block.minecraft.pink_stained_glass": "粉红色染色玻璃", + "block.minecraft.pink_stained_glass_pane": "粉红色染色玻璃板", + "block.minecraft.pink_terracotta": "粉红色陶瓦", + "block.minecraft.pink_tulip": "粉红色郁金香", + "block.minecraft.pink_wool": "粉红色羊毛", + "block.minecraft.pink_wool_slab": "粉红色羊毛台阶", + "block.minecraft.pink_wool_stairs": "粉红色羊毛楼梯", + "block.minecraft.piston": "活塞", + "block.minecraft.piston_head": "活塞头", + "block.minecraft.pitcher_crop": "瓶子草植株", + "block.minecraft.pitcher_plant": "瓶子草", + "block.minecraft.player_head": "玩家的头", + "block.minecraft.player_head.named": "%s的头", + "block.minecraft.player_wall_head": "墙上的玩家的头", + "block.minecraft.podzol": "灰化土", + "block.minecraft.pointed_dripstone": "滴水石锥", + "block.minecraft.polished_andesite": "磨制安山岩", + "block.minecraft.polished_andesite_slab": "磨制安山岩台阶", + "block.minecraft.polished_andesite_stairs": "磨制安山岩楼梯", + "block.minecraft.polished_basalt": "磨制玄武岩", + "block.minecraft.polished_blackstone": "磨制黑石", + "block.minecraft.polished_blackstone_brick_slab": "磨制黑石砖台阶", + "block.minecraft.polished_blackstone_brick_stairs": "磨制黑石砖楼梯", + "block.minecraft.polished_blackstone_brick_wall": "磨制黑石砖墙", + "block.minecraft.polished_blackstone_bricks": "磨制黑石砖", + "block.minecraft.polished_blackstone_button": "磨制黑石按钮", + "block.minecraft.polished_blackstone_pressure_plate": "磨制黑石压力板", + "block.minecraft.polished_blackstone_slab": "磨制黑石台阶", + "block.minecraft.polished_blackstone_stairs": "磨制黑石楼梯", + "block.minecraft.polished_blackstone_wall": "磨制黑石墙", + "block.minecraft.polished_cinnabar": "磨制朱砂", + "block.minecraft.polished_cinnabar_slab": "磨制朱砂台阶", + "block.minecraft.polished_cinnabar_stairs": "磨制朱砂楼梯", + "block.minecraft.polished_cinnabar_wall": "磨制朱砂墙", + "block.minecraft.polished_deepslate": "磨制深板岩", + "block.minecraft.polished_deepslate_slab": "磨制深板岩台阶", + "block.minecraft.polished_deepslate_stairs": "磨制深板岩楼梯", + "block.minecraft.polished_deepslate_wall": "磨制深板岩墙", + "block.minecraft.polished_diorite": "磨制闪长岩", + "block.minecraft.polished_diorite_slab": "磨制闪长岩台阶", + "block.minecraft.polished_diorite_stairs": "磨制闪长岩楼梯", + "block.minecraft.polished_granite": "磨制花岗岩", + "block.minecraft.polished_granite_slab": "磨制花岗岩台阶", + "block.minecraft.polished_granite_stairs": "磨制花岗岩楼梯", + "block.minecraft.polished_sulfur": "磨制硫黄", + "block.minecraft.polished_sulfur_slab": "磨制硫黄台阶", + "block.minecraft.polished_sulfur_stairs": "磨制硫黄楼梯", + "block.minecraft.polished_sulfur_wall": "磨制硫黄墙", + "block.minecraft.polished_tuff": "磨制凝灰岩", + "block.minecraft.polished_tuff_slab": "磨制凝灰岩台阶", + "block.minecraft.polished_tuff_stairs": "磨制凝灰岩楼梯", + "block.minecraft.polished_tuff_wall": "磨制凝灰岩墙", + "block.minecraft.poplar_button": "杨木按钮", + "block.minecraft.poplar_door": "杨木门", + "block.minecraft.poplar_fence": "杨木栅栏", + "block.minecraft.poplar_fence_gate": "杨木栅栏门", + "block.minecraft.poplar_hanging_sign": "悬挂式杨木告示牌", + "block.minecraft.poplar_leaves": "杨树树叶", + "block.minecraft.poplar_log": "杨木原木", + "block.minecraft.poplar_planks": "杨木木板", + "block.minecraft.poplar_pressure_plate": "杨木压力板", + "block.minecraft.poplar_sapling": "杨树树苗", + "block.minecraft.poplar_shelf": "杨木展示架", + "block.minecraft.poplar_sign": "杨木告示牌", + "block.minecraft.poplar_slab": "杨木台阶", + "block.minecraft.poplar_stairs": "杨木楼梯", + "block.minecraft.poplar_trapdoor": "杨木活板门", + "block.minecraft.poplar_wall_hanging_sign": "墙上的悬挂式杨木告示牌", + "block.minecraft.poplar_wall_sign": "墙上的杨木告示牌", + "block.minecraft.poplar_wood": "杨木", + "block.minecraft.poppy": "虞美人", + "block.minecraft.potatoes": "马铃薯", + "block.minecraft.potent_sulfur": "烈性硫黄", + "block.minecraft.potted_acacia_sapling": "金合欢树苗盆栽", + "block.minecraft.potted_allium": "绒球葱盆栽", + "block.minecraft.potted_azalea_bush": "杜鹃花丛盆栽", + "block.minecraft.potted_azure_bluet": "蓝花美耳草盆栽", + "block.minecraft.potted_bamboo": "竹子盆栽", + "block.minecraft.potted_birch_sapling": "白桦树苗盆栽", + "block.minecraft.potted_blue_orchid": "兰花盆栽", + "block.minecraft.potted_brown_mushroom": "棕色蘑菇盆栽", + "block.minecraft.potted_cactus": "仙人掌盆栽", + "block.minecraft.potted_cherry_sapling": "樱花树苗盆栽", + "block.minecraft.potted_closed_eyeblossom": "闭合的眼眸花盆栽", + "block.minecraft.potted_cornflower": "矢车菊盆栽", + "block.minecraft.potted_crimson_fungus": "绯红菌盆栽", + "block.minecraft.potted_crimson_roots": "绯红菌索盆栽", + "block.minecraft.potted_dandelion": "蒲公英盆栽", + "block.minecraft.potted_dark_oak_sapling": "深色橡树树苗盆栽", + "block.minecraft.potted_dead_bush": "枯萎的灌木盆栽", + "block.minecraft.potted_fern": "蕨盆栽", + "block.minecraft.potted_flowering_azalea_bush": "盛开的杜鹃花丛盆栽", + "block.minecraft.potted_golden_dandelion": "金蒲公英盆栽", + "block.minecraft.potted_jungle_sapling": "丛林树苗盆栽", + "block.minecraft.potted_lily_of_the_valley": "铃兰盆栽", + "block.minecraft.potted_mangrove_propagule": "红树胎生苗盆栽", + "block.minecraft.potted_oak_sapling": "橡树树苗盆栽", + "block.minecraft.potted_open_eyeblossom": "张开的眼眸花盆栽", + "block.minecraft.potted_orange_tulip": "橙色郁金香盆栽", + "block.minecraft.potted_oxeye_daisy": "滨菊盆栽", + "block.minecraft.potted_pale_oak_sapling": "苍白橡树树苗盆栽", + "block.minecraft.potted_pink_tulip": "粉红色郁金香盆栽", + "block.minecraft.potted_poplar_sapling": "杨树树苗盆栽", + "block.minecraft.potted_poppy": "虞美人盆栽", + "block.minecraft.potted_red_mushroom": "红色蘑菇盆栽", + "block.minecraft.potted_red_tulip": "红色郁金香盆栽", + "block.minecraft.potted_spruce_sapling": "云杉树苗盆栽", + "block.minecraft.potted_torchflower": "火把花盆栽", + "block.minecraft.potted_warped_fungus": "诡异菌盆栽", + "block.minecraft.potted_warped_roots": "诡异菌索盆栽", + "block.minecraft.potted_white_tulip": "白色郁金香盆栽", + "block.minecraft.potted_wither_rose": "凋灵玫瑰盆栽", + "block.minecraft.powder_snow": "细雪", + "block.minecraft.powder_snow_cauldron": "装有细雪的炼药锅", + "block.minecraft.powered_rail": "动力铁轨", + "block.minecraft.prismarine": "海晶石", + "block.minecraft.prismarine_brick_slab": "海晶石砖台阶", + "block.minecraft.prismarine_brick_stairs": "海晶石砖楼梯", + "block.minecraft.prismarine_bricks": "海晶石砖", + "block.minecraft.prismarine_slab": "海晶石台阶", + "block.minecraft.prismarine_stairs": "海晶石楼梯", + "block.minecraft.prismarine_wall": "海晶石墙", + "block.minecraft.pumpkin": "南瓜", + "block.minecraft.pumpkin_stem": "南瓜茎", + "block.minecraft.purple_banner": "紫色旗帜", + "block.minecraft.purple_bed": "紫色床", + "block.minecraft.purple_candle": "紫色蜡烛", + "block.minecraft.purple_candle_cake": "插上紫色蜡烛的蛋糕", + "block.minecraft.purple_carpet": "紫色地毯", + "block.minecraft.purple_concrete": "紫色混凝土", + "block.minecraft.purple_concrete_powder": "紫色混凝土粉末", + "block.minecraft.purple_concrete_slab": "紫色混凝土台阶", + "block.minecraft.purple_concrete_stairs": "紫色混凝土楼梯", + "block.minecraft.purple_glazed_terracotta": "紫色带釉陶瓦", + "block.minecraft.purple_shulker_box": "紫色潜影盒", + "block.minecraft.purple_stained_glass": "紫色染色玻璃", + "block.minecraft.purple_stained_glass_pane": "紫色染色玻璃板", + "block.minecraft.purple_terracotta": "紫色陶瓦", + "block.minecraft.purple_wool": "紫色羊毛", + "block.minecraft.purple_wool_slab": "紫色羊毛台阶", + "block.minecraft.purple_wool_stairs": "紫色羊毛楼梯", + "block.minecraft.purpur_block": "紫珀块", + "block.minecraft.purpur_pillar": "紫珀柱", + "block.minecraft.purpur_slab": "紫珀台阶", + "block.minecraft.purpur_stairs": "紫珀楼梯", + "block.minecraft.quartz_block": "石英块", + "block.minecraft.quartz_bricks": "石英砖", + "block.minecraft.quartz_pillar": "石英柱", + "block.minecraft.quartz_slab": "石英台阶", + "block.minecraft.quartz_stairs": "石英楼梯", + "block.minecraft.rail": "铁轨", + "block.minecraft.raw_copper_block": "粗铜块", + "block.minecraft.raw_gold_block": "粗金块", + "block.minecraft.raw_iron_block": "粗铁块", + "block.minecraft.red_banner": "红色旗帜", + "block.minecraft.red_bed": "红色床", + "block.minecraft.red_candle": "红色蜡烛", + "block.minecraft.red_candle_cake": "插上红色蜡烛的蛋糕", + "block.minecraft.red_carpet": "红色地毯", + "block.minecraft.red_concrete": "红色混凝土", + "block.minecraft.red_concrete_powder": "红色混凝土粉末", + "block.minecraft.red_concrete_slab": "红色混凝土台阶", + "block.minecraft.red_concrete_stairs": "红色混凝土楼梯", + "block.minecraft.red_glazed_terracotta": "红色带釉陶瓦", + "block.minecraft.red_mushroom": "红色蘑菇", + "block.minecraft.red_mushroom_block": "红色蘑菇方块", + "block.minecraft.red_nether_brick_slab": "红色下界砖台阶", + "block.minecraft.red_nether_brick_stairs": "红色下界砖楼梯", + "block.minecraft.red_nether_brick_wall": "红色下界砖墙", + "block.minecraft.red_nether_bricks": "红色下界砖块", + "block.minecraft.red_poplar_leaves": "红色杨树树叶", + "block.minecraft.red_sand": "红沙", + "block.minecraft.red_sandstone": "红砂岩", + "block.minecraft.red_sandstone_slab": "红砂岩台阶", + "block.minecraft.red_sandstone_stairs": "红砂岩楼梯", + "block.minecraft.red_sandstone_wall": "红砂岩墙", + "block.minecraft.red_shrub": "红灌木", + "block.minecraft.red_shulker_box": "红色潜影盒", + "block.minecraft.red_stained_glass": "红色染色玻璃", + "block.minecraft.red_stained_glass_pane": "红色染色玻璃板", + "block.minecraft.red_terracotta": "红色陶瓦", + "block.minecraft.red_tulip": "红色郁金香", + "block.minecraft.red_wool": "红色羊毛", + "block.minecraft.red_wool_slab": "红色羊毛台阶", + "block.minecraft.red_wool_stairs": "红色羊毛楼梯", + "block.minecraft.redstone_block": "红石块", + "block.minecraft.redstone_lamp": "红石灯", + "block.minecraft.redstone_ore": "红石矿石", + "block.minecraft.redstone_torch": "红石火把", + "block.minecraft.redstone_wall_torch": "墙上的红石火把", + "block.minecraft.redstone_wire": "红石线", + "block.minecraft.reinforced_deepslate": "强化深板岩", + "block.minecraft.repeater": "红石中继器", + "block.minecraft.repeating_command_block": "循环型命令方块", + "block.minecraft.resin_block": "树脂块", + "block.minecraft.resin_brick_slab": "树脂砖台阶", + "block.minecraft.resin_brick_stairs": "树脂砖楼梯", + "block.minecraft.resin_brick_wall": "树脂砖墙", + "block.minecraft.resin_bricks": "树脂砖块", + "block.minecraft.resin_clump": "树脂团", + "block.minecraft.respawn_anchor": "重生锚", + "block.minecraft.rooted_dirt": "缠根泥土", + "block.minecraft.rose_bush": "玫瑰丛", + "block.minecraft.sand": "沙子", + "block.minecraft.sandstone": "砂岩", + "block.minecraft.sandstone_slab": "砂岩台阶", + "block.minecraft.sandstone_stairs": "砂岩楼梯", + "block.minecraft.sandstone_wall": "砂岩墙", + "block.minecraft.scaffolding": "脚手架", + "block.minecraft.sculk": "幽匿块", + "block.minecraft.sculk_catalyst": "幽匿催发体", + "block.minecraft.sculk_sensor": "幽匿感测体", + "block.minecraft.sculk_shrieker": "幽匿尖啸体", + "block.minecraft.sculk_vein": "幽匿脉络", + "block.minecraft.sea_lantern": "海晶灯", + "block.minecraft.sea_pickle": "海泡菜", + "block.minecraft.seagrass": "海草", + "block.minecraft.set_spawn": "已设置重生点", + "block.minecraft.shelf_mushroom": "层孔菇", + "block.minecraft.short_dry_grass": "矮枯草丛", + "block.minecraft.short_grass": "矮草丛", + "block.minecraft.shroomlight": "菌光体", + "block.minecraft.shulker_box": "潜影盒", + "block.minecraft.skeleton_skull": "骷髅头颅", + "block.minecraft.skeleton_wall_skull": "墙上的骷髅头颅", + "block.minecraft.slime_block": "黏液块", + "block.minecraft.small_amethyst_bud": "小型紫晶芽", + "block.minecraft.small_dripleaf": "小型垂滴叶", + "block.minecraft.smithing_table": "锻造台", + "block.minecraft.smoker": "烟熏炉", + "block.minecraft.smooth_basalt": "平滑玄武岩", + "block.minecraft.smooth_quartz": "平滑石英块", + "block.minecraft.smooth_quartz_slab": "平滑石英台阶", + "block.minecraft.smooth_quartz_stairs": "平滑石英楼梯", + "block.minecraft.smooth_red_sandstone": "平滑红砂岩", + "block.minecraft.smooth_red_sandstone_slab": "平滑红砂岩台阶", + "block.minecraft.smooth_red_sandstone_stairs": "平滑红砂岩楼梯", + "block.minecraft.smooth_sandstone": "平滑砂岩", + "block.minecraft.smooth_sandstone_slab": "平滑砂岩台阶", + "block.minecraft.smooth_sandstone_stairs": "平滑砂岩楼梯", + "block.minecraft.smooth_stone": "平滑石头", + "block.minecraft.smooth_stone_slab": "平滑石头台阶", + "block.minecraft.sniffer_egg": "嗅探兽蛋", + "block.minecraft.snow": "雪", + "block.minecraft.snow_block": "雪块", + "block.minecraft.soul_campfire": "灵魂营火", + "block.minecraft.soul_fire": "灵魂火", + "block.minecraft.soul_lantern": "灵魂灯笼", + "block.minecraft.soul_sand": "灵魂沙", + "block.minecraft.soul_soil": "灵魂土", + "block.minecraft.soul_torch": "灵魂火把", + "block.minecraft.soul_wall_torch": "墙上的灵魂火把", + "block.minecraft.spawn.not_valid": "你的床或已充能的重生锚不存在或已被阻挡", + "block.minecraft.spawner": "刷怪笼", + "block.minecraft.spawner.desc1": "用刷怪蛋交互时:", + "block.minecraft.spawner.desc2": "设置生物种类", + "block.minecraft.sponge": "海绵", + "block.minecraft.spore_blossom": "孢子花", + "block.minecraft.spruce_button": "云杉木按钮", + "block.minecraft.spruce_door": "云杉木门", + "block.minecraft.spruce_fence": "云杉木栅栏", + "block.minecraft.spruce_fence_gate": "云杉木栅栏门", + "block.minecraft.spruce_hanging_sign": "悬挂式云杉木告示牌", + "block.minecraft.spruce_leaves": "云杉树叶", + "block.minecraft.spruce_log": "云杉原木", + "block.minecraft.spruce_planks": "云杉木板", + "block.minecraft.spruce_pressure_plate": "云杉木压力板", + "block.minecraft.spruce_sapling": "云杉树苗", + "block.minecraft.spruce_shelf": "云杉木展示架", + "block.minecraft.spruce_sign": "云杉木告示牌", + "block.minecraft.spruce_slab": "云杉木台阶", + "block.minecraft.spruce_stairs": "云杉木楼梯", + "block.minecraft.spruce_trapdoor": "云杉木活板门", + "block.minecraft.spruce_wall_hanging_sign": "墙上的悬挂式云杉木告示牌", + "block.minecraft.spruce_wall_sign": "墙上的云杉木告示牌", + "block.minecraft.spruce_wood": "云杉木", + "block.minecraft.sticky_piston": "黏性活塞", + "block.minecraft.stone": "石头", + "block.minecraft.stone_brick_slab": "石砖台阶", + "block.minecraft.stone_brick_stairs": "石砖楼梯", + "block.minecraft.stone_brick_wall": "石砖墙", + "block.minecraft.stone_bricks": "石砖", + "block.minecraft.stone_button": "石头按钮", + "block.minecraft.stone_pressure_plate": "石头压力板", + "block.minecraft.stone_slab": "石头台阶", + "block.minecraft.stone_stairs": "石头楼梯", + "block.minecraft.stonecutter": "切石机", + "block.minecraft.straw_bed": "麦秆床", + "block.minecraft.stripped_acacia_log": "去皮金合欢原木", + "block.minecraft.stripped_acacia_wood": "去皮金合欢木", + "block.minecraft.stripped_bamboo_block": "去皮竹块", + "block.minecraft.stripped_birch_log": "去皮白桦原木", + "block.minecraft.stripped_birch_wood": "去皮白桦木", + "block.minecraft.stripped_cherry_log": "去皮樱花原木", + "block.minecraft.stripped_cherry_wood": "去皮樱花木", + "block.minecraft.stripped_crimson_hyphae": "去皮绯红菌核", + "block.minecraft.stripped_crimson_stem": "去皮绯红菌柄", + "block.minecraft.stripped_dark_oak_log": "去皮深色橡木原木", + "block.minecraft.stripped_dark_oak_wood": "去皮深色橡木", + "block.minecraft.stripped_jungle_log": "去皮丛林原木", + "block.minecraft.stripped_jungle_wood": "去皮丛林木", + "block.minecraft.stripped_mangrove_log": "去皮红树原木", + "block.minecraft.stripped_mangrove_wood": "去皮红树木", + "block.minecraft.stripped_oak_log": "去皮橡木原木", + "block.minecraft.stripped_oak_wood": "去皮橡木", + "block.minecraft.stripped_pale_oak_log": "去皮苍白橡木原木", + "block.minecraft.stripped_pale_oak_wood": "去皮苍白橡木", + "block.minecraft.stripped_poplar_log": "去皮杨木原木", + "block.minecraft.stripped_poplar_wood": "去皮杨木", + "block.minecraft.stripped_spruce_log": "去皮云杉原木", + "block.minecraft.stripped_spruce_wood": "去皮云杉木", + "block.minecraft.stripped_warped_hyphae": "去皮诡异菌核", + "block.minecraft.stripped_warped_stem": "去皮诡异菌柄", + "block.minecraft.structure_block": "结构方块", + "block.minecraft.structure_void": "结构空位", + "block.minecraft.sugar_cane": "甘蔗", + "block.minecraft.sulfur": "硫黄", + "block.minecraft.sulfur_brick_slab": "硫黄砖台阶", + "block.minecraft.sulfur_brick_stairs": "硫黄砖楼梯", + "block.minecraft.sulfur_brick_wall": "硫黄砖墙", + "block.minecraft.sulfur_bricks": "硫黄砖", + "block.minecraft.sulfur_slab": "硫黄台阶", + "block.minecraft.sulfur_spike": "硫黄尖锥", + "block.minecraft.sulfur_stairs": "硫黄楼梯", + "block.minecraft.sulfur_wall": "硫黄墙", + "block.minecraft.sunflower": "向日葵", + "block.minecraft.suspicious_gravel": "可疑的沙砾", + "block.minecraft.suspicious_sand": "可疑的沙子", + "block.minecraft.sweet_berry_bush": "甜浆果丛", + "block.minecraft.tall_dry_grass": "高枯草丛", + "block.minecraft.tall_grass": "高草丛", + "block.minecraft.tall_seagrass": "高海草", + "block.minecraft.target": "标靶", + "block.minecraft.terracotta": "陶瓦", + "block.minecraft.test_block": "测试方块", + "block.minecraft.test_instance_block": "测试实例方块", + "block.minecraft.tinted_glass": "遮光玻璃", + "block.minecraft.tnt": "TNT", + "block.minecraft.tnt.disabled": "TNT爆炸已禁用", + "block.minecraft.torch": "火把", + "block.minecraft.torchflower": "火把花", + "block.minecraft.torchflower_crop": "火把花植株", + "block.minecraft.trapped_chest": "陷阱箱", + "block.minecraft.trial_spawner": "试炼刷怪笼", + "block.minecraft.tripwire": "绊线", + "block.minecraft.tripwire_hook": "绊线钩", + "block.minecraft.tube_coral": "管珊瑚", + "block.minecraft.tube_coral_block": "管珊瑚块", + "block.minecraft.tube_coral_fan": "管珊瑚扇", + "block.minecraft.tube_coral_wall_fan": "墙上的管珊瑚扇", + "block.minecraft.tuff": "凝灰岩", + "block.minecraft.tuff_brick_slab": "凝灰岩砖台阶", + "block.minecraft.tuff_brick_stairs": "凝灰岩砖楼梯", + "block.minecraft.tuff_brick_wall": "凝灰岩砖墙", + "block.minecraft.tuff_bricks": "凝灰岩砖", + "block.minecraft.tuff_slab": "凝灰岩台阶", + "block.minecraft.tuff_stairs": "凝灰岩楼梯", + "block.minecraft.tuff_wall": "凝灰岩墙", + "block.minecraft.turtle_egg": "海龟蛋", + "block.minecraft.twisting_vines": "缠怨藤", + "block.minecraft.twisting_vines_plant": "缠怨藤植株", + "block.minecraft.vault": "宝库", + "block.minecraft.verdant_froglight": "青翠蛙明灯", + "block.minecraft.vine": "藤蔓", + "block.minecraft.void_air": "虚空空气", + "block.minecraft.wall_torch": "墙上的火把", + "block.minecraft.warped_button": "诡异木按钮", + "block.minecraft.warped_door": "诡异木门", + "block.minecraft.warped_fence": "诡异木栅栏", + "block.minecraft.warped_fence_gate": "诡异木栅栏门", + "block.minecraft.warped_fungus": "诡异菌", + "block.minecraft.warped_hanging_sign": "悬挂式诡异木告示牌", + "block.minecraft.warped_hyphae": "诡异菌核", + "block.minecraft.warped_nylium": "诡异菌岩", + "block.minecraft.warped_planks": "诡异木板", + "block.minecraft.warped_pressure_plate": "诡异木压力板", + "block.minecraft.warped_roots": "诡异菌索", + "block.minecraft.warped_shelf": "诡异木展示架", + "block.minecraft.warped_sign": "诡异木告示牌", + "block.minecraft.warped_slab": "诡异木台阶", + "block.minecraft.warped_stairs": "诡异木楼梯", + "block.minecraft.warped_stem": "诡异菌柄", + "block.minecraft.warped_trapdoor": "诡异木活板门", + "block.minecraft.warped_wall_hanging_sign": "墙上的悬挂式诡异木告示牌", + "block.minecraft.warped_wall_sign": "墙上的诡异木告示牌", + "block.minecraft.warped_wart_block": "诡异疣块", + "block.minecraft.water": "水", + "block.minecraft.water_cauldron": "装有水的炼药锅", + "block.minecraft.waxed_chiseled_copper": "涂蜡的雕纹铜块", + "block.minecraft.waxed_copper_bars": "涂蜡的铜栏杆", + "block.minecraft.waxed_copper_block": "涂蜡的铜块", + "block.minecraft.waxed_copper_bulb": "涂蜡的铜灯", + "block.minecraft.waxed_copper_chain": "涂蜡的铜链", + "block.minecraft.waxed_copper_chest": "涂蜡的铜箱子", + "block.minecraft.waxed_copper_door": "涂蜡的铜门", + "block.minecraft.waxed_copper_golem_statue": "涂蜡的铜傀儡像", + "block.minecraft.waxed_copper_grate": "涂蜡的铜格栅", + "block.minecraft.waxed_copper_lantern": "涂蜡的铜灯笼", + "block.minecraft.waxed_copper_trapdoor": "涂蜡的铜活板门", + "block.minecraft.waxed_cut_copper": "涂蜡的切制铜块", + "block.minecraft.waxed_cut_copper_slab": "涂蜡的切制铜台阶", + "block.minecraft.waxed_cut_copper_stairs": "涂蜡的切制铜楼梯", + "block.minecraft.waxed_exposed_chiseled_copper": "涂蜡的斑驳雕纹铜块", + "block.minecraft.waxed_exposed_copper": "涂蜡的斑驳铜块", + "block.minecraft.waxed_exposed_copper_bars": "涂蜡的斑驳铜栏杆", + "block.minecraft.waxed_exposed_copper_bulb": "涂蜡的斑驳铜灯", + "block.minecraft.waxed_exposed_copper_chain": "涂蜡的斑驳铜链", + "block.minecraft.waxed_exposed_copper_chest": "涂蜡的斑驳铜箱子", + "block.minecraft.waxed_exposed_copper_door": "涂蜡的斑驳铜门", + "block.minecraft.waxed_exposed_copper_golem_statue": "涂蜡的斑驳铜傀儡像", + "block.minecraft.waxed_exposed_copper_grate": "涂蜡的斑驳铜格栅", + "block.minecraft.waxed_exposed_copper_lantern": "涂蜡的斑驳铜灯笼", + "block.minecraft.waxed_exposed_copper_trapdoor": "涂蜡的斑驳铜活板门", + "block.minecraft.waxed_exposed_cut_copper": "涂蜡的斑驳切制铜块", + "block.minecraft.waxed_exposed_cut_copper_slab": "涂蜡的斑驳切制铜台阶", + "block.minecraft.waxed_exposed_cut_copper_stairs": "涂蜡的斑驳切制铜楼梯", + "block.minecraft.waxed_exposed_lightning_rod": "涂蜡的斑驳避雷针", + "block.minecraft.waxed_lightning_rod": "涂蜡的避雷针", + "block.minecraft.waxed_oxidized_chiseled_copper": "涂蜡的氧化雕纹铜块", + "block.minecraft.waxed_oxidized_copper": "涂蜡的氧化铜块", + "block.minecraft.waxed_oxidized_copper_bars": "涂蜡的氧化铜栏杆", + "block.minecraft.waxed_oxidized_copper_bulb": "涂蜡的氧化铜灯", + "block.minecraft.waxed_oxidized_copper_chain": "涂蜡的氧化铜链", + "block.minecraft.waxed_oxidized_copper_chest": "涂蜡的氧化铜箱子", + "block.minecraft.waxed_oxidized_copper_door": "涂蜡的氧化铜门", + "block.minecraft.waxed_oxidized_copper_golem_statue": "涂蜡的氧化铜傀儡像", + "block.minecraft.waxed_oxidized_copper_grate": "涂蜡的氧化铜格栅", + "block.minecraft.waxed_oxidized_copper_lantern": "涂蜡的氧化铜灯笼", + "block.minecraft.waxed_oxidized_copper_trapdoor": "涂蜡的氧化铜活板门", + "block.minecraft.waxed_oxidized_cut_copper": "涂蜡的氧化切制铜块", + "block.minecraft.waxed_oxidized_cut_copper_slab": "涂蜡的氧化切制铜台阶", + "block.minecraft.waxed_oxidized_cut_copper_stairs": "涂蜡的氧化切制铜楼梯", + "block.minecraft.waxed_oxidized_lightning_rod": "涂蜡的氧化避雷针", + "block.minecraft.waxed_weathered_chiseled_copper": "涂蜡的锈蚀雕纹铜块", + "block.minecraft.waxed_weathered_copper": "涂蜡的锈蚀铜块", + "block.minecraft.waxed_weathered_copper_bars": "涂蜡的锈蚀铜栏杆", + "block.minecraft.waxed_weathered_copper_bulb": "涂蜡的锈蚀铜灯", + "block.minecraft.waxed_weathered_copper_chain": "涂蜡的锈蚀铜链", + "block.minecraft.waxed_weathered_copper_chest": "涂蜡的锈蚀铜箱子", + "block.minecraft.waxed_weathered_copper_door": "涂蜡的锈蚀铜门", + "block.minecraft.waxed_weathered_copper_golem_statue": "涂蜡的锈蚀铜傀儡像", + "block.minecraft.waxed_weathered_copper_grate": "涂蜡的锈蚀铜格栅", + "block.minecraft.waxed_weathered_copper_lantern": "涂蜡的锈蚀铜灯笼", + "block.minecraft.waxed_weathered_copper_trapdoor": "涂蜡的锈蚀铜活板门", + "block.minecraft.waxed_weathered_cut_copper": "涂蜡的锈蚀切制铜块", + "block.minecraft.waxed_weathered_cut_copper_slab": "涂蜡的锈蚀切制铜台阶", + "block.minecraft.waxed_weathered_cut_copper_stairs": "涂蜡的锈蚀切制铜楼梯", + "block.minecraft.waxed_weathered_lightning_rod": "涂蜡的锈蚀避雷针", + "block.minecraft.weathered_chiseled_copper": "锈蚀的雕纹铜块", + "block.minecraft.weathered_copper": "锈蚀的铜块", + "block.minecraft.weathered_copper_bars": "锈蚀的铜栏杆", + "block.minecraft.weathered_copper_bulb": "锈蚀的铜灯", + "block.minecraft.weathered_copper_chain": "锈蚀的铜链", + "block.minecraft.weathered_copper_chest": "锈蚀的铜箱子", + "block.minecraft.weathered_copper_door": "锈蚀的铜门", + "block.minecraft.weathered_copper_golem_statue": "锈蚀的铜傀儡像", + "block.minecraft.weathered_copper_grate": "锈蚀的铜格栅", + "block.minecraft.weathered_copper_lantern": "锈蚀的铜灯笼", + "block.minecraft.weathered_copper_trapdoor": "锈蚀的铜活板门", + "block.minecraft.weathered_cut_copper": "锈蚀的切制铜块", + "block.minecraft.weathered_cut_copper_slab": "锈蚀的切制铜台阶", + "block.minecraft.weathered_cut_copper_stairs": "锈蚀的切制铜楼梯", + "block.minecraft.weathered_lightning_rod": "锈蚀的避雷针", + "block.minecraft.weeping_vines": "垂泪藤", + "block.minecraft.weeping_vines_plant": "垂泪藤植株", + "block.minecraft.wet_sponge": "湿海绵", + "block.minecraft.wheat": "小麦植株", + "block.minecraft.white_banner": "白色旗帜", + "block.minecraft.white_bed": "白色床", + "block.minecraft.white_candle": "白色蜡烛", + "block.minecraft.white_candle_cake": "插上白色蜡烛的蛋糕", + "block.minecraft.white_carpet": "白色地毯", + "block.minecraft.white_concrete": "白色混凝土", + "block.minecraft.white_concrete_powder": "白色混凝土粉末", + "block.minecraft.white_concrete_slab": "白色混凝土台阶", + "block.minecraft.white_concrete_stairs": "白色混凝土楼梯", + "block.minecraft.white_glazed_terracotta": "白色带釉陶瓦", + "block.minecraft.white_shulker_box": "白色潜影盒", + "block.minecraft.white_stained_glass": "白色染色玻璃", + "block.minecraft.white_stained_glass_pane": "白色染色玻璃板", + "block.minecraft.white_terracotta": "白色陶瓦", + "block.minecraft.white_tulip": "白色郁金香", + "block.minecraft.white_wool": "白色羊毛", + "block.minecraft.white_wool_slab": "白色羊毛台阶", + "block.minecraft.white_wool_stairs": "白色羊毛楼梯", + "block.minecraft.wildflowers": "野花簇", + "block.minecraft.wither_rose": "凋灵玫瑰", + "block.minecraft.wither_skeleton_skull": "凋灵骷髅头颅", + "block.minecraft.wither_skeleton_wall_skull": "墙上的凋灵骷髅头颅", + "block.minecraft.yellow_banner": "黄色旗帜", + "block.minecraft.yellow_bed": "黄色床", + "block.minecraft.yellow_candle": "黄色蜡烛", + "block.minecraft.yellow_candle_cake": "插上黄色蜡烛的蛋糕", + "block.minecraft.yellow_carpet": "黄色地毯", + "block.minecraft.yellow_concrete": "黄色混凝土", + "block.minecraft.yellow_concrete_powder": "黄色混凝土粉末", + "block.minecraft.yellow_concrete_slab": "黄色混凝土台阶", + "block.minecraft.yellow_concrete_stairs": "黄色混凝土楼梯", + "block.minecraft.yellow_glazed_terracotta": "黄色带釉陶瓦", + "block.minecraft.yellow_poplar_leaves": "黄色杨树树叶", + "block.minecraft.yellow_shulker_box": "黄色潜影盒", + "block.minecraft.yellow_stained_glass": "黄色染色玻璃", + "block.minecraft.yellow_stained_glass_pane": "黄色染色玻璃板", + "block.minecraft.yellow_terracotta": "黄色陶瓦", + "block.minecraft.yellow_wool": "黄色羊毛", + "block.minecraft.yellow_wool_slab": "黄色羊毛台阶", + "block.minecraft.yellow_wool_stairs": "黄色羊毛楼梯", + "block.minecraft.zombie_head": "僵尸的头", + "block.minecraft.zombie_wall_head": "墙上的僵尸的头", + "book.byAuthor": "%1$s 著", + "book.edit.title": "书本编辑屏幕", + "book.editTitle": "输入书名:", + "book.finalizeButton": "署名并关闭", + "book.finalizeWarning": "注意!在你署名后,它将不能再被修改。", + "book.generation.0": "原稿", + "book.generation.1": "原稿的副本", + "book.generation.2": "副本的副本", + "book.generation.3": "破烂不堪", + "book.invalid.tag": "* 无效的书本标签 *", + "book.pageIndicator": "第%1$s页/共%2$s页", + "book.page_button.next": "下一页", + "book.page_button.previous": "上一页", + "book.sign.title": "书本署名屏幕", + "book.sign.titlebox": "标题", + "book.signButton": "署名", + "book.view.title": "书本浏览屏幕", + "build.spawn_protection": "%s处于重生点保护下", + "build.tooHigh": "建筑高度上限是%s", + "build.tooLow": "建筑高度下限是%s", + "chat.cannotSend": "无法发送聊天消息", + "chat.coordinates": "%s, %s, %s", + "chat.coordinates.tooltip": "点此传送", + "chat.copy": "复制到剪贴板", + "chat.copy.click": "单击复制到剪贴板", + "chat.deleted_marker": "此聊天消息已被服务器删除。", + "chat.disabled.chain_broken": "由于消息链损坏,聊天已被禁用。请尝试重新连接。", + "chat.disabled.expiredProfileKey": "由于玩家档案公钥过期,聊天已被禁用。请尝试重新连接。", + "chat.disabled.invalid_command_signature": "此命令包含意外或缺失的命令参数签名。", + "chat.disabled.invalid_signature": "聊天签名无效。请尝试重新连接。", + "chat.disabled.launcher": "聊天在启动器选项中被禁用。无法发送消息。", + "chat.disabled.missingProfileKey": "由于玩家档案公钥丢失,聊天已被禁用。请尝试重新连接。", + "chat.disabled.options": "聊天在客户端选项中被禁用。", + "chat.disabled.out_of_order_chat": "接收到了乱序的聊天消息。你的系统时间是否被更改过?", + "chat.disabled.profile": "聊天在账户设置中被禁用。再按一次“%s”以获取更多信息。", + "chat.disabled.profile.moreInfo": "聊天在账户设置中被禁用。无法发送或查看消息。", + "chat.editBox": "聊天", + "chat.filtered": "已被服务器过滤。", + "chat.filtered_full": "服务器已向部分玩家隐藏了你的消息。", + "chat.link.confirm": "你确定要打开以下网页?", + "chat.link.confirmTrusted": "你想要打开这个链接或将其复制到你的剪贴板吗?", + "chat.link.open": "在浏览器中打开", + "chat.link.warning": "永远不要打开你不信任的人提供的链接!", + "chat.queue": "[+%s行待发送]", + "chat.queue.tooltip": "单击显示下一条消息", + "chat.square_brackets": "[%s]", + "chat.tag.error": "服务器发送了无效消息。", + "chat.tag.modified": "消息被服务器修改。原文:", + "chat.tag.not_secure": "未经验证的消息。无法举报。", + "chat.tag.system": "服务器消息。无法举报。", + "chat.tag.system_single_player": "服务器消息。", + "chat.type.admin": "[%s: %s]", + "chat.type.advancement.challenge": "%s完成了挑战%s", + "chat.type.advancement.goal": "%s达成了目标%s", + "chat.type.advancement.task": "%s取得了进度%s", + "chat.type.announcement": "[%s] %s", + "chat.type.emote": "* %s %s", + "chat.type.team.hover": "发送队伍消息", + "chat.type.team.sent": "-> %s <%s> %s", + "chat.type.team.text": "%s <%s> %s", + "chat.type.text": "<%s> %s", + "chat.type.text.narrate": "%s说%s", + "chat.validation_error": "聊天验证错误", + "chat_restriction.chat_and_commands_disabled_by_options": "聊天功能受客户端设置限制。", + "chat_restriction.chat_disabled_by_options": "玩家聊天功能受客户端设置限制。", + "chat_restriction.chat_disabled_by_options.action": "前往聊天设置屏幕", + "chat_restriction.disabled_by_launcher": "聊天功能受启动器限制。", + "chat_restriction.disabled_by_profile": "聊天功能受玩家档案设置限制。", + "chat_restriction.disabled_by_profile.action": "前往玩家档案设置", + "chat_screen.commands_not_allowed": "不允许发送命令", + "chat_screen.message": "要发送的消息:%s", + "chat_screen.messages_not_allowed": "不允许发送聊天消息", + "chat_screen.restricted": "聊天功能受限。点击此消息查看详情。", + "chat_screen.restricted.narration": "聊天功能受限。请前往世界选项中的行为限制屏幕。", + "chat_screen.title": "聊天框", + "chat_screen.usage": "输入消息并按Enter键发送", + "chunk.toast.checkLog": "查看日志获取详情", + "chunk.toast.loadFailure": "无法加载%s处的区块", + "chunk.toast.lowDiskSpace": "磁盘空间不足!", + "chunk.toast.lowDiskSpace.description": "可能无法保存世界。", + "chunk.toast.saveFailure": "无法保存%s处的区块", + "clear.failed.multiple": "未能从%s名玩家找到任何物品", + "clear.failed.single": "未能从玩家%s找到任何物品", + "color.minecraft.black": "黑色", + "color.minecraft.blue": "蓝色", + "color.minecraft.brown": "棕色", + "color.minecraft.cyan": "青色", + "color.minecraft.gray": "灰色", + "color.minecraft.green": "绿色", + "color.minecraft.light_blue": "淡蓝色", + "color.minecraft.light_gray": "淡灰色", + "color.minecraft.lime": "黄绿色", + "color.minecraft.magenta": "品红色", + "color.minecraft.orange": "橙色", + "color.minecraft.pink": "粉红色", + "color.minecraft.purple": "紫色", + "color.minecraft.red": "红色", + "color.minecraft.white": "白色", + "color.minecraft.yellow": "黄色", + "command.compute.result.named.exact": "%s返回了值%s", + "command.compute.result.named.invalid": "%s返回了无效的值(%s)", + "command.compute.result.named.rounded": "%s返回了值%s(取整为%s)", + "command.compute.result.unnamed.exact": "数值提供器返回了值%s", + "command.compute.result.unnamed.invalid": "数值提供器返回了无效的值(%s)", + "command.compute.result.unnamed.rounded": "数值提供器返回了值%s(取整为%s)", + "command.context.here": "<--[此处]", + "command.context.parse_error": "%s,位于第%s个字符:%s", + "command.exception": "无法解析命令:%s", + "command.expected.separator": "参数后应有空格分隔,但发现了尾随数据", + "command.failed": "试图执行该命令时出现意外错误", + "command.forkLimit": "上下文数量已达上限值(%s)", + "command.trailing_data": "发现尾随数据:%s", + "command.unknown.argument": "错误的命令参数", + "command.unknown.command": "未知或不完整的命令。错误见下", + "commands.advancement.criterionNotFound": "进度%1$s并不包含条件“%2$s”", + "commands.advancement.grant.criterion.to.many.failure": "无法将进度%2$s的达成条件“%1$s”赋予%3$s名玩家,因为他们已达成此条件", + "commands.advancement.grant.criterion.to.many.success": "已将进度%2$s的达成条件“%1$s”赋予%3$s名玩家", + "commands.advancement.grant.criterion.to.one.failure": "无法将进度%2$s的达成条件“%1$s”赋予%3$s,因为该玩家已达成此条件", + "commands.advancement.grant.criterion.to.one.success": "已将进度%2$s的达成条件“%1$s”赋予%3$s", + "commands.advancement.grant.many.to.many.failure": "无法将%s项进度赋予%s名玩家,因为他们已达成这些进度", + "commands.advancement.grant.many.to.many.success": "已将%s项进度赋予%s名玩家", + "commands.advancement.grant.many.to.one.failure": "无法将%s项进度赋予%s,因为该玩家已达成这些进度", + "commands.advancement.grant.many.to.one.success": "已将%s项进度赋予%s", + "commands.advancement.grant.one.to.many.failure": "无法将进度%s赋予%s名玩家,因为他们已达成此进度", + "commands.advancement.grant.one.to.many.success": "已将进度%s赋予%s名玩家", + "commands.advancement.grant.one.to.one.failure": "无法将进度%s赋予%s,因为该玩家已达成此进度", + "commands.advancement.grant.one.to.one.success": "已将进度%s赋予%s", + "commands.advancement.revoke.criterion.to.many.failure": "无法撤销%3$s名玩家关于进度%2$s的达成条件“%1$s”,因为他们并未达成此条件", + "commands.advancement.revoke.criterion.to.many.success": "已撤销%3$s名玩家关于进度%2$s的达成条件“%1$s”", + "commands.advancement.revoke.criterion.to.one.failure": "无法撤销%3$s关于进度%2$s的达成条件“%1$s”,因为该玩家并未达成此条件", + "commands.advancement.revoke.criterion.to.one.success": "已撤销%3$s关于进度%2$s的达成条件“%1$s”", + "commands.advancement.revoke.many.to.many.failure": "无法撤销%2$s名玩家的%1$s项进度,因为他们并未达成此进度", + "commands.advancement.revoke.many.to.many.success": "已撤销%2$s名玩家的%1$s项进度", + "commands.advancement.revoke.many.to.one.failure": "无法撤销%2$s的%1$s项进度,因为该玩家并未达成这些进度", + "commands.advancement.revoke.many.to.one.success": "已撤销%2$s的%1$s项进度", + "commands.advancement.revoke.one.to.many.failure": "无法撤销%2$s名玩家的进度%1$s,因为他们并未达成此进度", + "commands.advancement.revoke.one.to.many.success": "已撤销%2$s名玩家的进度%1$s", + "commands.advancement.revoke.one.to.one.failure": "无法撤销%2$s的进度%1$s,因为该玩家并未达成此进度", + "commands.advancement.revoke.one.to.one.success": "已撤销%2$s的进度%1$s", + "commands.attribute.base_value.get.success": "实体%2$s的属性%1$s的基值为%3$s", + "commands.attribute.base_value.reset.success": "实体%2$s的属性%1$s的基值已重置为默认值%3$s", + "commands.attribute.base_value.set.success": "实体%2$s的属性%1$s的基值已设置为%3$s", + "commands.attribute.failed.entity": "%s不是此命令的有效实体", + "commands.attribute.failed.modifier_already_present": "实体%3$s的属性%2$s已存在修饰符%1$s", + "commands.attribute.failed.no_attribute": "实体%s没有属性%s", + "commands.attribute.failed.no_modifier": "实体%2$s的属性%1$s无修饰符%3$s", + "commands.attribute.modifier.add.success": "为实体%3$s的属性%2$s添加了修饰符%1$s", + "commands.attribute.modifier.remove.success": "为实体%3$s的属性%2$s移除了修饰符%1$s", + "commands.attribute.modifier.value.get.success": "实体%3$s的属性%2$s中修饰符%1$s值为%4$s", + "commands.attribute.value.get.success": "实体%2$s的属性%1$s的值为%3$s", + "commands.ban.failed": "无变化,该玩家已被封禁", + "commands.ban.success": "已封禁%s:%s", + "commands.banip.failed": "无变化,该IP地址已被封禁", + "commands.banip.info": "此次封禁涉及%s名玩家:%s", + "commands.banip.invalid": "无效的IP地址或未知的玩家", + "commands.banip.success": "已封禁IP地址%s:%s", + "commands.banlist.entry": "%s被%s封禁:%s", + "commands.banlist.entry.unknown": "(未知)", + "commands.banlist.list": "共有%s条封禁:", + "commands.banlist.none": "没有玩家被封禁", + "commands.bossbar.create.failed": "ID为“%s”的Boss栏已经存在", + "commands.bossbar.create.success": "已创建自定义Boss栏%s", + "commands.bossbar.get.max": "自定义Boss栏%s的最大值为%s", + "commands.bossbar.get.players.none": "自定义Boss栏%s目前没有在线玩家", + "commands.bossbar.get.players.some": "自定义Boss栏%s当前在线的%s名玩家有:%s", + "commands.bossbar.get.value": "自定义Boss栏%s的数值为%s", + "commands.bossbar.get.visible.hidden": "自定义Boss栏%s现为隐藏", + "commands.bossbar.get.visible.visible": "自定义Boss栏%s现为可见", + "commands.bossbar.list.bars.none": "无运行中的自定义Boss栏", + "commands.bossbar.list.bars.some": "有%s项运行中的自定义Boss栏:%s", + "commands.bossbar.remove.success": "已移除自定义Boss栏%s", + "commands.bossbar.set.color.success": "已更改自定义Boss栏%s的颜色", + "commands.bossbar.set.color.unchanged": "无变化,这本就是这个Boss栏的颜色", + "commands.bossbar.set.max.success": "自定义Boss栏%s的最大值已改为%s", + "commands.bossbar.set.max.unchanged": "无变化,这本就是这个Boss栏的最大值", + "commands.bossbar.set.name.success": "已重命名自定义Boss栏%s", + "commands.bossbar.set.name.unchanged": "无变化,这本就是这个Boss栏的名称", + "commands.bossbar.set.players.success.none": "自定义Boss栏%s在当下没有在线玩家", + "commands.bossbar.set.players.success.some": "自定义Boss栏%s在当下在线的%s名玩家有:%s", + "commands.bossbar.set.players.unchanged": "无变化,这些玩家已经在Boss栏上,没有玩家需要被添加或移除", + "commands.bossbar.set.style.success": "已更改自定义Boss栏%s的样式", + "commands.bossbar.set.style.unchanged": "无变化,这本就是这个Boss栏的样式", + "commands.bossbar.set.value.success": "自定义Boss栏%s的值已改为%s", + "commands.bossbar.set.value.unchanged": "无变化,这本就是这个Boss栏的值", + "commands.bossbar.set.visibility.unchanged.hidden": "无变化,该Boss栏原已隐藏", + "commands.bossbar.set.visibility.unchanged.visible": "无变化,该Boss栏原已可见", + "commands.bossbar.set.visible.success.hidden": "已将自定义Boss栏%s改为隐藏", + "commands.bossbar.set.visible.success.visible": "已将自定义Boss栏%s改为可见", + "commands.bossbar.unknown": "不存在ID为“%s”的Boss栏", + "commands.clear.success.multiple": "已移除%2$s名玩家的%1$s个物品", + "commands.clear.success.single": "已移除玩家%2$s的%1$s个物品", + "commands.clear.test.multiple": "已在%2$s名玩家身上找到%1$s个匹配的物品", + "commands.clear.test.single": "已在玩家%2$s身上找到%1$s个匹配的物品", + "commands.clone.failed": "未复制任何方块", + "commands.clone.overlap": "源区域和目标区域不能重叠", + "commands.clone.success": "已成功复制%s个方块", + "commands.clone.toobig": "指定区域内的方块过多(最大值为%s,但指定值为%s)", + "commands.damage.invulnerable": "对象免疫指定的伤害类型", + "commands.damage.success": "已将%s伤害施加于%s", + "commands.data.block.get": "位于%2$s, %3$s, %4$s的方块的%1$s乘以%5$s倍率后的值为%6$s", + "commands.data.block.invalid": "目标方块不是方块实体", + "commands.data.block.modified": "已修改位于%s, %s, %s的方块数据", + "commands.data.block.query": "%s, %s, %s拥有以下方块数据:%s", + "commands.data.entity.get": "%2$s的%1$s在乘以倍率%3$s后的值是%4$s", + "commands.data.entity.invalid": "无法修改玩家数据", + "commands.data.entity.modified": "已修改%s的实体数据", + "commands.data.entity.query": "%s拥有以下实体数据:%s", + "commands.data.get.invalid": "无法获取%s,只接受数字标签", + "commands.data.get.multiple": "该参数只接受单个NBT值", + "commands.data.get.unknown": "无法获取%s,标签不存在", + "commands.data.merge.failed": "无变化,所指定的属性已有这些值", + "commands.data.modify.expected_list": "应为列表:实际为%s", + "commands.data.modify.expected_object": "应为对象:实际为%s", + "commands.data.modify.expected_value": "应为值:实际为%s", + "commands.data.modify.invalid_index": "无效的列表索引“%s”", + "commands.data.modify.invalid_substring": "无效的子串索引:%s到%s", + "commands.data.storage.get": "在存储%2$s中的%1$s在乘以倍率%3$s后的值是%4$s", + "commands.data.storage.modified": "已修改存储%s", + "commands.data.storage.query": "存储%s含有以下内容:%s", + "commands.datapack.create.already_exists": "名为“%s”的包已经存在", + "commands.datapack.create.invalid_full_name": "无效的新包名“%s”", + "commands.datapack.create.invalid_name": "新包名“%s”中包含无效字符", + "commands.datapack.create.io_failure": "无法创建名为“%s”的包。请检查日志", + "commands.datapack.create.metadata_encode_failure": "无法编码名称为“%s”的包的元数据:%s", + "commands.datapack.create.success": "已创建名为“%s”的空包", + "commands.datapack.disable.failed": "数据包“%s”并未启用!", + "commands.datapack.disable.failed.feature": "无法禁用数据包“%s”,因为它属于已启用功能!", + "commands.datapack.enable.failed": "数据包“%s”已经启用!", + "commands.datapack.enable.failed.no_flags": "无法启用数据包“%s”,因为所需的功能未在此世界启用:%s!", + "commands.datapack.list.available.none": "已无更多可用的数据包", + "commands.datapack.list.available.success": "共有%s个数据包可用:%s", + "commands.datapack.list.enabled.none": "没有启用中的数据包", + "commands.datapack.list.enabled.success": "已启用%s个数据包:%s", + "commands.datapack.modify.disable": "正在禁用数据包%s", + "commands.datapack.modify.enable": "正在启用数据包%s", + "commands.datapack.unknown": "未知的数据包“%s”", + "commands.debug.alreadyRunning": "刻分析器原已开启", + "commands.debug.function.noRecursion": "无法从函数内部开始追踪", + "commands.debug.function.noReturnRun": "使用/return run时无法追踪", + "commands.debug.function.success.multiple": "已追踪%2$s个函数内的%1$s条命令至输出文件%3$s", + "commands.debug.function.success.single": "已追踪函数“%2$s”内的%1$s条命令至输出文件%3$s", + "commands.debug.function.traceFailed": "追踪函数失败", + "commands.debug.notRunning": "尚未启动刻分析器", + "commands.debug.started": "已开始刻分析", + "commands.debug.stopped": "已停止刻分析,用时%s秒和%s刻(每秒%s刻)", + "commands.defaultgamemode.success": "默认游戏模式现在为%s", + "commands.deop.failed": "无变化,此玩家不是管理员", + "commands.deop.success": "%s不再是服务器管理员了", + "commands.dialog.clear.multiple": "已清除%s名玩家的对话框", + "commands.dialog.clear.single": "已清除%s的对话框", + "commands.dialog.show.multiple": "已将对话框展示给%s名玩家", + "commands.dialog.show.single": "已将对话框展示给%s", + "commands.difficulty.failure": "难度未变,它原来就是%s", + "commands.difficulty.query": "目前难度为%s", + "commands.difficulty.success": "难度已被设置为%s", + "commands.drop.no_held_items": "该实体无法持有任何物品", + "commands.drop.no_loot_table": "实体%s没有战利品表", + "commands.drop.no_loot_table.block": "方块%s没有战利品表", + "commands.drop.success.multiple": "掉落了%s个物品", + "commands.drop.success.multiple_with_table": "从战利品表%2$s中掉落了%1$s个物品", + "commands.drop.success.single": "掉落了%s个%s", + "commands.drop.success.single_with_table": "从战利品表%3$s中掉落了%1$s个%2$s", + "commands.effect.clear.everything.failed": "对象没有可以移除的效果", + "commands.effect.clear.everything.success.multiple": "已移除%s个对象的所有效果", + "commands.effect.clear.everything.success.single": "已移除%s的所有效果", + "commands.effect.clear.specific.failed": "对象没有指定效果", + "commands.effect.clear.specific.success.multiple": "已移除%2$s个对象的%1$s效果", + "commands.effect.clear.specific.success.single": "已移除%2$s的%1$s效果", + "commands.effect.give.failed": "无法应用此效果(对象对效果免疫,或已有更强的效果)", + "commands.effect.give.success.multiple": "已将%s效果应用于%s个对象", + "commands.effect.give.success.single": "已将%s效果应用于%s", + "commands.enchant.failed": "无变化,对象未持有物品或其不支持此魔咒", + "commands.enchant.failed.entity": "%s不是此命令的有效实体", + "commands.enchant.failed.incompatible": "%s不支持此魔咒", + "commands.enchant.failed.itemless": "%s未手持任何物品", + "commands.enchant.failed.level": "%s高于该魔咒支持的最高等级%s", + "commands.enchant.success.multiple": "已将%s魔咒应用于%s个实体", + "commands.enchant.success.single": "已将%s魔咒应用于%s的物品上", + "commands.execute.blocks.toobig": "指定区域内的方块过多(最大值为%s,但指定值为%s)", + "commands.execute.conditional.fail": "测试失败", + "commands.execute.conditional.fail_count": "测试失败。计数:%s", + "commands.execute.conditional.pass": "测试通过", + "commands.execute.conditional.pass_count": "测试通过。计数:%s", + "commands.execute.function.instantiationFailure": "无法实例化函数%s:%s", + "commands.experience.add.levels.success.multiple": "已给予%2$s名玩家%1$s级经验", + "commands.experience.add.levels.success.single": "已给予%2$s %1$s级经验", + "commands.experience.add.points.success.multiple": "已给予%2$s名玩家%1$s点经验", + "commands.experience.add.points.success.single": "已给予%2$s %1$s点经验", + "commands.experience.query.levels": "%s拥有%s级经验", + "commands.experience.query.points": "%s拥有%s点经验", + "commands.experience.set.levels.success.multiple": "已将%2$s名玩家的经验等级设为%1$s", + "commands.experience.set.levels.success.single": "已将%2$s的经验等级设为%1$s", + "commands.experience.set.points.invalid": "无法将此玩家的经验值设置为超过现有等级的最大值", + "commands.experience.set.points.success.multiple": "已将%2$s名玩家的经验值设为%1$s", + "commands.experience.set.points.success.single": "已将%2$s的经验值设为%1$s", + "commands.fetchprofile.copy_component": "复制组件", + "commands.fetchprofile.copy_text": "复制%s", + "commands.fetchprofile.entity.success": "已解析实体%s的玩家档案:%s", + "commands.fetchprofile.failed_to_serialize": "无法序列化玩家档案:%s", + "commands.fetchprofile.give_item": "给予物品", + "commands.fetchprofile.id.failure": "无法解析ID为%s的玩家档案", + "commands.fetchprofile.id.success": "已解析ID为%s的玩家档案:%s", + "commands.fetchprofile.name.failure": "无法解析名称为%s的玩家档案", + "commands.fetchprofile.name.success": "已解析名称为%s的玩家档案:%s", + "commands.fetchprofile.no_profile": "实体%s没有玩家档案", + "commands.fetchprofile.summon_mannequin": "召唤玩家模型", + "commands.fill.failed": "没有方块被填充", + "commands.fill.success": "已成功填充%s个方块", + "commands.fill.toobig": "指定区域内的方块过多(最大值为%s,但指定值为%s)", + "commands.fillbiome.no_changes": "未更改任何生物群系", + "commands.fillbiome.success": "已在%s, %s, %s与%s, %s, %s之间设置生物群系", + "commands.fillbiome.success.count": "已在%2$s, %3$s, %4$s与%5$s, %6$s, %7$s之间设置%1$s个生物群系单元", + "commands.fillbiome.toobig": "指定范围内的方块过多(最大值为%s,但指定值为%s)", + "commands.forceload.added.failure": "没有被标记为强制加载的区块", + "commands.forceload.added.multiple": "已将%2$s中的%3$s至%4$s间的%1$s个区块标记为强制加载", + "commands.forceload.added.none": "在%s中未找到强制加载的区块", + "commands.forceload.added.single": "已将%2$s中的区块%1$s标记为强制加载", + "commands.forceload.list.multiple": "在%2$s内找到%1$s个强制加载的区块:%3$s", + "commands.forceload.list.single": "在%s内找到一个强制加载的区块:%s", + "commands.forceload.query.failure": "在%2$s中%1$s内的区块未被标记为强制加载", + "commands.forceload.query.success": "在%2$s中%1$s内的区块被标记为强制加载", + "commands.forceload.removed.all": "已解除标记%s内所有的强制加载区块", + "commands.forceload.removed.failure": "没有强制加载的区块被移除", + "commands.forceload.removed.multiple": "已将%2$s中的%3$s至%4$s间的%1$s个区块取消强制加载", + "commands.forceload.removed.single": "已将%2$s中的区块%1$s解除强制加载", + "commands.forceload.toobig": "指定区域内区块过多(最大值为%s,但指定值为%s)", + "commands.function.error.argument_not_compound": "无效的参数类型“%s”。应为Compound类型", + "commands.function.error.missing_argument": "函数%1$s缺少传入参数%2$s", + "commands.function.error.missing_arguments": "函数%s缺少传入参数", + "commands.function.error.parse": "实例化宏%s时:命令“%s”发生错误:%s", + "commands.function.instantiationFailure": "无法实例化函数%s:%s", + "commands.function.result": "函数%s返回了%s", + "commands.function.scheduled.multiple": "正在运行函数%s", + "commands.function.scheduled.no_functions": "无法找到名称为%s的函数", + "commands.function.scheduled.single": "正在运行函数%s", + "commands.function.success.multiple": "已执行%2$s个函数中的%1$s条命令", + "commands.function.success.multiple.result": "执行了%s个函数", + "commands.function.success.single": "已执行函数%2$s中的%1$s条命令", + "commands.function.success.single.result": "函数“%2$s”返回了%1$s", + "commands.gamemode.success.other": "已将%s的游戏模式改为%s", + "commands.gamemode.success.self": "已将自己的游戏模式设置为%s", + "commands.gamerule.not_set": "游戏规则%s原已设为%s", + "commands.gamerule.query": "游戏规则%s目前为:%s", + "commands.gamerule.set": "已将游戏规则%s设为%s", + "commands.give.failed.toomanyitems": "最多只能给予%s个%s", + "commands.give.success.multiple": "已将%s个%s给予%s名玩家", + "commands.give.success.single": "已将%s个%s给予%s", + "commands.help.failed": "未知的命令或权限不足", + "commands.item.block.modify.success": "已修改位于%2$s, %3$s, %4$s的%1$s个槽位", + "commands.item.block.replace.success": "已替换位于%2$s, %3$s, %4$s的%1$s个槽位", + "commands.item.block.replace.success.known_item": "已用%5$s替换位于%2$s, %3$s, %4$s的%1$s个槽位", + "commands.item.block.set.success": "已用%4$s替换位于%1$s, %2$s, %3$s的槽位", + "commands.item.entity.modify.success.multiple": "已修改%s个实体的槽位", + "commands.item.entity.modify.success.single": "已修改%2$s的%1$s个槽位", + "commands.item.entity.replace.success.multiple": "已替换%s个实体的槽位", + "commands.item.entity.replace.success.multiple.known_item": "已用%2$s替换%1$s个实体的槽位", + "commands.item.entity.replace.success.single": "已替换%2$s的%1$s个槽位", + "commands.item.entity.replace.success.single.known_item": "已用%3$s替换%2$s的%1$s个槽位", + "commands.item.entity.set.success.multiple": "已用%2$s替换%1$s个实体的槽位", + "commands.item.entity.set.success.single": "已用%2$s替换%1$s的槽位", + "commands.item.source.no_such_slot": "来源没有%s槽位", + "commands.item.source.no_such_slot.unnamed": "来源没有指定的槽位", + "commands.item.source.not_a_container": "在来源位置%s, %s, %s上的不是一个容器", + "commands.item.target.failed": "没有对象在指定槽位接受物品", + "commands.item.target.failed.known_item": "没有对象在指定槽位接受了物品%s", + "commands.item.target.no_changed.known_item": "没有对象在%2$s槽位接受了物品%1$s", + "commands.item.target.no_changes": "没有对象在%s槽位接受了物品", + "commands.item.target.no_such_slot": "对象没有%s槽位", + "commands.item.target.no_such_slot.unnamed": "对象没有指定的槽位", + "commands.item.target.not_a_container": "在目标位置%s, %s, %s上的不是一个容器", + "commands.jfr.dump.failed": "无法转储JFR记录:%s", + "commands.jfr.start.failed": "无法开始JFR分析", + "commands.jfr.started": "已开始JFR分析", + "commands.jfr.stopped": "JFR分析已结束,已转储至%s", + "commands.kick.owner.failed": "无法在局域网游戏中踢出服务器所有者", + "commands.kick.singleplayer.failed": "无法在离线单人游戏中踢出玩家", + "commands.kick.success": "已踢出%s:%s", + "commands.kill.success.multiple": "杀死了%s个实体", + "commands.kill.success.single": "杀死了%s", + "commands.list.nameAndId": "%s(%s)", + "commands.list.players": "当前共有%s名玩家在线(最大玩家数为%s):%s", + "commands.locate.biome.not_found": "无法在合理距离内找到类型为“%s”的生物群系", + "commands.locate.biome.success": "最近的%s位于%s(%s个方块外)", + "commands.locate.poi.not_found": "在合理距离内找不到类型为“%s”的兴趣点", + "commands.locate.poi.success": "最近的%s位于%s(%s个方块外)", + "commands.locate.structure.invalid": "没有类型为“%s”的结构", + "commands.locate.structure.not_found": "无法在附近找到类型为“%s”的结构", + "commands.locate.structure.success": "最近的%s位于%s(%s个方块外)", + "commands.message.display.incoming": "%s悄悄地对你说:%s", + "commands.message.display.outgoing": "你悄悄地对%s说:%s", + "commands.op.failed": "无变化,该玩家已是管理员", + "commands.op.success": "已将%s设为服务器管理员", + "commands.pardon.failed": "无变化,该玩家未被封禁", + "commands.pardon.success": "已解封%s", + "commands.pardonip.failed": "无变化,该IP地址未被封禁", + "commands.pardonip.invalid": "无效的IP地址", + "commands.pardonip.success": "已解封IP地址%s", + "commands.particle.failed": "该粒子无法被任何玩家看见", + "commands.particle.success": "正在显示粒子%s", + "commands.perf.alreadyRunning": "性能分析器已在运行", + "commands.perf.notRunning": "性能分析器尚未启动", + "commands.perf.reportFailed": "生成调试报告失败", + "commands.perf.reportSaved": "已在%s生成调试报告", + "commands.perf.started": "已开始时长为10秒的性能分析测试(使用“/perf stop”以提前结束)", + "commands.perf.stopped": "已停止性能分析,用时%s秒和%s刻(每秒%s刻)", + "commands.place.feature.failed": "放置地物失败", + "commands.place.feature.invalid": "没有类型为“%s”的地物", + "commands.place.feature.success": "已在%2$s, %3$s, %4$s处放置“%1$s”", + "commands.place.feature.success.inline": "已在%s, %s, %s处放置地物", + "commands.place.jigsaw.failed": "生成拼图失败", + "commands.place.jigsaw.invalid": "没有类型为“%s”的模板池", + "commands.place.jigsaw.success": "已在%s, %s, %s处生成拼图", + "commands.place.structure.failed": "放置结构失败", + "commands.place.structure.invalid": "没有类型为“%s”的结构", + "commands.place.structure.success": "已在%2$s, %3$s, %4$s处生成结构“%1$s”", + "commands.place.template.failed": "放置模板失败", + "commands.place.template.invalid": "没有ID为“%s”的模板", + "commands.place.template.success": "已在%2$s, %3$s, %4$s处加载模板“%1$s”", + "commands.playsound.failed": "声音过远而无法被听见", + "commands.playsound.success.multiple": "已将声音%s播放给%s名玩家", + "commands.playsound.success.single": "已将声音%s播放给%s", + "commands.posteffect.add.failed": "玩家原已拥有指定的后处理效果", + "commands.posteffect.add.success.multiple": "已向%2$s名玩家添加后处理效果%1$s", + "commands.posteffect.add.success.single": "已向%2$s添加后处理效果%1$s", + "commands.posteffect.clear.failed": "玩家没有可移除的后处理效果", + "commands.posteffect.clear.success.multiple": "已移除%s名玩家的所有后处理效果", + "commands.posteffect.clear.success.single": "已移除%s的所有后处理效果", + "commands.posteffect.list.empty": "玩家%s没有任何后处理效果", + "commands.posteffect.list.success": "玩家%s拥有%s个后处理效果:%s", + "commands.posteffect.remove.failed": "玩家没有指定的后处理效果", + "commands.posteffect.remove.success.multiple": "已移除%2$s名玩家的后处理效果%1$s", + "commands.posteffect.remove.success.single": "已移除%2$s的后处理效果%1$s", + "commands.profile_fetch.copy_component": "复制组件", + "commands.profile_fetch.failed_to_serialize": "无法序列化玩家档案:%s", + "commands.profile_fetch.give_item": "给予物品", + "commands.profile_fetch.id.failure": "无法解析ID为%s的玩家档案", + "commands.profile_fetch.id.success": "已解析ID为%s的玩家档案:%s", + "commands.profile_fetch.name.failure": "无法解析名称为%s的玩家档案", + "commands.profile_fetch.name.success": "已解析名称为%s的玩家档案:%s", + "commands.publish.alreadyPublished": "已存在开放于%s端口的多人游戏", + "commands.publish.alreadyPublished.lan": "已存在开放于%s端口的本地游戏", + "commands.publish.alreadyPublished.online": "已存在开放的在线游戏", + "commands.publish.failed": "无法建立本地游戏", + "commands.publish.started": "本地游戏已在端口%s上开启", + "commands.publish.started.lan": "本地游戏已在端口%s上开放", + "commands.publish.started.online": "已开放在线游戏", + "commands.publish.success": "多人游戏已在%s端口上开启", + "commands.random.error.range_too_large": "随机值的范围最大为2147483647", + "commands.random.error.range_too_small": "随机值的范围最小为1", + "commands.random.reset.all.success": "已重置%s个随机序列", + "commands.random.reset.success": "已重置随机序列%s", + "commands.random.roll": "%s掷出了%s(从%s到%s)", + "commands.random.sample.success": "随机值:%s", + "commands.recipe.give.failed": "没有新配方被解锁", + "commands.recipe.give.success.multiple": "已为%2$s名玩家解锁了%1$s条配方", + "commands.recipe.give.success.single": "已为%2$s解锁了%1$s条配方", + "commands.recipe.take.failed": "没有可遗忘的配方", + "commands.recipe.take.success.multiple": "已剥夺%2$s名玩家的%1$s条配方", + "commands.recipe.take.success.single": "已剥夺%2$s的%1$s条配方", + "commands.reload.failure": "重新加载失败,保留原有数据", + "commands.reload.success": "重新加载中!", + "commands.ride.already_riding": "%s已经在骑乘%s", + "commands.ride.dismount.success": "%s已停止骑乘%s", + "commands.ride.mount.failure.cant_ride_players": "玩家无法被骑乘", + "commands.ride.mount.failure.generic": "%s无法骑乘%s", + "commands.ride.mount.failure.loop": "无法让实体骑乘自身或其乘客", + "commands.ride.mount.failure.wrong_dimension": "无法骑乘位于不同维度的实体", + "commands.ride.mount.success": "%s已开始骑乘%s", + "commands.ride.not_riding": "%s未骑乘任何载具", + "commands.rotate.success": "已旋转%s", + "commands.save.alreadyOff": "已经关闭世界保存", + "commands.save.alreadyOn": "已经打开世界保存", + "commands.save.disabled": "自动保存已禁用", + "commands.save.enabled": "自动保存已启用", + "commands.save.failed": "无法保存游戏(磁盘空间是否足够?)", + "commands.save.saving": "正在保存游戏(这可能需要一些时间!)", + "commands.save.success": "游戏已保存", + "commands.schedule.cleared.failure": "没有ID为%s的计划", + "commands.schedule.cleared.success": "已移除%s个ID为%s的计划", + "commands.schedule.created.function": "已将函数“%s”计划在%s刻后,即游戏时间%s时执行", + "commands.schedule.created.tag": "已将标签“%s”计划在%s刻后,即游戏时间%s时执行", + "commands.schedule.macro": "无法计划宏", + "commands.schedule.same_tick": "无法将函数计划在当前刻", + "commands.scoreboard.objectives.add.duplicate": "已经存在同名记分项", + "commands.scoreboard.objectives.add.success": "创建了新的记分项%s", + "commands.scoreboard.objectives.display.alreadyEmpty": "无变化,该显示位置本就是空的", + "commands.scoreboard.objectives.display.alreadySet": "无变化,该显示位置已经存在该记分项", + "commands.scoreboard.objectives.display.cleared": "清空了显示位置%s的所有记分项", + "commands.scoreboard.objectives.display.set": "已将显示位置%s设置为展示记分项%s", + "commands.scoreboard.objectives.list.empty": "不存在任何记分项", + "commands.scoreboard.objectives.list.success": "共有%s个记分项:%s", + "commands.scoreboard.objectives.modify.displayAutoUpdate.disable": "已禁用记分项%s的展示自动更新", + "commands.scoreboard.objectives.modify.displayAutoUpdate.enable": "已启用记分项%s的展示自动更新", + "commands.scoreboard.objectives.modify.displayname": "已将%s的显示名称更改为%s", + "commands.scoreboard.objectives.modify.objectiveFormat.clear": "已清除记分项%s的默认数字格式", + "commands.scoreboard.objectives.modify.objectiveFormat.set": "已更改记分项%s的默认数字格式", + "commands.scoreboard.objectives.modify.rendertype": "已更改记分项%s的渲染类型", + "commands.scoreboard.objectives.remove.success": "移除了记分项%s", + "commands.scoreboard.players.add.success.multiple": "将%3$s个实体的%2$s增加了%1$s", + "commands.scoreboard.players.add.success.single": "将%3$s的%2$s增加了%1$s(现在是%4$s)", + "commands.scoreboard.players.display.name.clear.success.multiple": "已清除%s个实体在%s中的显示名称", + "commands.scoreboard.players.display.name.clear.success.single": "已清除%s在%s中的显示名称", + "commands.scoreboard.players.display.name.set.success.multiple": "已将%2$s个实体在%3$s中的显示名称更改为%1$s", + "commands.scoreboard.players.display.name.set.success.single": "已将%2$s在%3$s中的显示名称更改为%1$s", + "commands.scoreboard.players.display.numberFormat.clear.success.multiple": "已清除%s个实体在%s中的数字格式", + "commands.scoreboard.players.display.numberFormat.clear.success.single": "已清除%s在%s中的数字格式", + "commands.scoreboard.players.display.numberFormat.set.success.multiple": "已更改%s个实体在%s中的数字格式", + "commands.scoreboard.players.display.numberFormat.set.success.single": "已更改%s在%s中的数字格式", + "commands.scoreboard.players.enable.failed": "无变化,触发器原已开启", + "commands.scoreboard.players.enable.invalid": "只能启用trigger类记分项", + "commands.scoreboard.players.enable.success.multiple": "已为%2$s个实体启用了触发器%1$s", + "commands.scoreboard.players.enable.success.single": "已为%2$s启用了触发器%1$s", + "commands.scoreboard.players.get.null": "无法获取%2$s的%1$s的值,其尚未被赋值", + "commands.scoreboard.players.get.success": "%1$s在%3$s记分项里拥有%2$s分", + "commands.scoreboard.players.list.empty": "没有正被追踪的实体", + "commands.scoreboard.players.list.entity.empty": "%s无可显示的分数", + "commands.scoreboard.players.list.entity.entry": "%s:%s", + "commands.scoreboard.players.list.entity.success": "%s拥有%s项分数:", + "commands.scoreboard.players.list.success": "共有%s个正被追踪的实体:%s", + "commands.scoreboard.players.operation.success.multiple": "更新了%2$s个实体的%1$s", + "commands.scoreboard.players.operation.success.single": "已将%2$s的%1$s设为%3$s", + "commands.scoreboard.players.remove.success.multiple": "已将%3$s个实体的%2$s分数减少%1$s", + "commands.scoreboard.players.remove.success.single": "已将%3$s的%2$s减少%1$s(现在是%4$s)", + "commands.scoreboard.players.reset.all.multiple": "重置了%s个实体的所有分数", + "commands.scoreboard.players.reset.all.single": "重置了%s的所有分数", + "commands.scoreboard.players.reset.specific.multiple": "重置了%2$s个实体的%1$s", + "commands.scoreboard.players.reset.specific.single": "重置了%2$s的%1$s", + "commands.scoreboard.players.set.success.multiple": "已将%2$s个实体的%1$s设为%3$s", + "commands.scoreboard.players.set.success.single": "已将%2$s的%1$s分数设为%3$s", + "commands.seed.success": "种子:%s", + "commands.setblock.failed": "无法放置方块", + "commands.setblock.success": "已更改位于%s, %s, %s的方块", + "commands.setidletimeout.success": "玩家的闲置限时现在为%s分钟", + "commands.setidletimeout.success.disabled": "玩家的闲置限时已禁用", + "commands.setworldspawn.failure.not_overworld": "只能为主世界设置世界出生点", + "commands.setworldspawn.success": "已将世界的出生点设置为%s, %s, %s [%s]", + "commands.setworldspawn.success.new": "已将世界出生点设为%6$s中的%1$s, %2$s, %3$s [%4$s, %5$s]", + "commands.spawnpoint.success.multiple": "已将%6$s名玩家的出生点设为%5$s中的%1$s, %2$s, %3$s [%4$s]", + "commands.spawnpoint.success.multiple.new": "已将%7$s名玩家的出生点设为%6$s中的%1$s, %2$s, %3$s [%4$s, %5$s]", + "commands.spawnpoint.success.single": "已将%6$s的出生点设为%5$s中的%1$s, %2$s, %3$s [%4$s]", + "commands.spawnpoint.success.single.new": "已将%7$s的出生点设为%6$s中的%1$s, %2$s, %3$s [%4$s, %5$s]", + "commands.spectate.cannot_spectate": "无法旁观%s", + "commands.spectate.not_spectator": "%s尚未处于旁观模式", + "commands.spectate.self": "不能旁观自己", + "commands.spectate.success.started": "正在旁观%s", + "commands.spectate.success.stopped": "不再旁观实体", + "commands.spreadplayers.failed.entities": "%1$s个实体未能围绕%2$s, %3$s分散(空间过小而实体过多,请将分散间距设为%4$s以下)", + "commands.spreadplayers.failed.invalid.height": "无效的maxHeight值“%s”,应高于世界最小高度%s", + "commands.spreadplayers.failed.teams": "%1$s支队伍未能围绕%2$s, %3$s分散(空间过小而实体过多,请将分散间距设为%4$s以下)", + "commands.spreadplayers.success.entities": "已将%s个实体围绕%s, %s分散,平均距离为%s个方块", + "commands.spreadplayers.success.teams": "已将%s支队伍围绕%s, %s分散,平均距离为%s个方块", + "commands.stop.stopping": "正在关闭服务器", + "commands.stopsound.success.source.any": "已停止播放所有“%s”的声音", + "commands.stopsound.success.source.sound": "已停止播放来源为“%2$s”的音效“%1$s”", + "commands.stopsound.success.sourceless.any": "已停止播放所有声音", + "commands.stopsound.success.sourceless.sound": "已停止播放声音“%s”", + "commands.stopwatch.already_exists": "秒表“%s”已经存在", + "commands.stopwatch.create.success": "已创建秒表“%s”", + "commands.stopwatch.does_not_exist": "秒表“%s”不存在", + "commands.stopwatch.query": "秒表“%s”已计时%s秒", + "commands.stopwatch.remove.success": "已移除秒表“%s”", + "commands.stopwatch.restart.success": "已重新开始秒表“%s”", + "commands.summon.failed": "无法召唤实体", + "commands.summon.failed.peaceful": "无法在和平难度下召唤怪物", + "commands.summon.failed.uuid": "UUID重复,无法召唤实体", + "commands.summon.invalidPosition": "无效的召唤坐标", + "commands.summon.success": "召唤了新的%s", + "commands.swing.failed.notliving": "未找到可挥臂的实体", + "commands.swing.success.multiple": "已使%s个实体挥臂", + "commands.swing.success.single": "已使%s挥臂", + "commands.tag.add.failed": "对象已拥有此标签或拥有过多标签", + "commands.tag.add.success.multiple": "已为%2$s个实体添加了标签“%1$s”", + "commands.tag.add.success.single": "已为%2$s添加了标签“%1$s”", + "commands.tag.list.multiple.empty": "%s个实体没有任何标签", + "commands.tag.list.multiple.success": "%s个实体拥有共计%s项标签:%s", + "commands.tag.list.single.empty": "%s没有标签", + "commands.tag.list.single.success": "%s拥有%s个标签:%s", + "commands.tag.remove.failed": "对象没有这个标签", + "commands.tag.remove.success.multiple": "已移除%2$s个实体的标签“%1$s”", + "commands.tag.remove.success.single": "已移除%2$s的标签“%1$s”", + "commands.team.add.duplicate": "已经存在同名队伍", + "commands.team.add.success": "已创建队伍%s", + "commands.team.empty.success": "已将%s名成员从队伍%s中移除", + "commands.team.empty.unchanged": "无变化,该队伍本就是空的", + "commands.team.join.success.multiple": "已将%s名成员加入队伍%s", + "commands.team.join.success.single": "已将%s加入队伍%s", + "commands.team.leave.success.multiple": "已将%s名成员从所有队伍中移除", + "commands.team.leave.success.single": "已将%s从所有队伍中移除", + "commands.team.list.members.empty": "队伍%s中没有成员", + "commands.team.list.members.success": "队伍%s含有%s名成员:%s", + "commands.team.list.teams.empty": "没有队伍存在", + "commands.team.list.teams.success": "共有%s支队伍:%s", + "commands.team.option.collisionRule.success": "队伍%s的碰撞规则现在是“%s”", + "commands.team.option.collisionRule.unchanged": "无变化,碰撞规则已经是此值", + "commands.team.option.color.clear.success": "已清除队伍%s的颜色", + "commands.team.option.color.success": "队伍%s的颜色已更新为%s", + "commands.team.option.color.unchanged": "无变化,此队伍本就为此颜色", + "commands.team.option.deathMessageVisibility.success": "队伍%s的死亡消息可见性现在为“%s”", + "commands.team.option.deathMessageVisibility.unchanged": "无变化,死亡消息的可见性已经是此值", + "commands.team.option.friendlyfire.alreadyDisabled": "无变化,友军伤害本就在此队伍上禁用", + "commands.team.option.friendlyfire.alreadyEnabled": "无变化,友军伤害本就在此队伍上启用", + "commands.team.option.friendlyfire.disabled": "已禁用队伍%s的友军伤害", + "commands.team.option.friendlyfire.enabled": "已启用队伍%s的友军伤害", + "commands.team.option.name.success": "已更新队伍%s的名称", + "commands.team.option.name.unchanged": "无变化,此队伍本就为该名称", + "commands.team.option.nametagVisibility.success": "队伍%s的名称标签可见性现在为“%s”", + "commands.team.option.nametagVisibility.unchanged": "无变化,名称标签的可见性已经是此值", + "commands.team.option.prefix.success": "队伍前缀已设为%s", + "commands.team.option.seeFriendlyInvisibles.alreadyDisabled": "无变化,此队伍本就不可看见隐身的队友", + "commands.team.option.seeFriendlyInvisibles.alreadyEnabled": "无变化,此队伍本就可以看见隐身的队友", + "commands.team.option.seeFriendlyInvisibles.disabled": "队伍%s不再可以看见隐身的队友了", + "commands.team.option.seeFriendlyInvisibles.enabled": "队伍%s现在可以看见隐身的队友了", + "commands.team.option.suffix.success": "队伍后缀已设为%s", + "commands.team.remove.success": "移除了队伍%s", + "commands.teammsg.failed.noteam": "你必须在一支队伍内才能发出队伍消息", + "commands.teleport.invalidPosition": "无效的传送坐标", + "commands.teleport.success.entity.multiple": "已传送%s个实体至%s", + "commands.teleport.success.entity.single": "已将%s传送至%s", + "commands.teleport.success.location.multiple": "已传送%s个实体至%s, %s, %s", + "commands.teleport.success.location.single": "已将%s传送至%s, %s, %s", + "commands.test.batch.starting": "正在启动环境%s批次%s", + "commands.test.clear.error.no_tests": "无法找到任何需要清除的测试", + "commands.test.clear.success": "清除了%s个结构", + "commands.test.coordinates": "%s, %s, %s", + "commands.test.coordinates.copy": "单击复制到剪贴板", + "commands.test.create.success": "已为测试%s创建测试设置", + "commands.test.error.no_test_containing_pos": "找不到包含%s, %s, %s的测试实例", + "commands.test.error.no_test_instances": "未找到测试实例", + "commands.test.error.non_existant_test": "测试%s不存在", + "commands.test.error.structure_not_found": "无法找到测试结构%s", + "commands.test.error.test_instance_not_found": "无法找到测试实例方块实体", + "commands.test.error.test_instance_not_found.position": "无法在%s, %s, %s处找到测试实例方块实体", + "commands.test.error.too_large": "结构在每条轴上的大小必须小于%s个方块", + "commands.test.locate.done": "定位完成:找到%s个结构", + "commands.test.locate.found": "在以下位置找到结构:%s(距离:%s)", + "commands.test.locate.started": "已开始定位测试结构。这可能需要一点时间…", + "commands.test.no_tests": "没有需要运行的测试", + "commands.test.relative_position": "相对于%s的位置:%s", + "commands.test.reset.error.no_tests": "无法找到任何需要重置的测试", + "commands.test.reset.success": "重置了%s个结构", + "commands.test.run.no_tests": "未找到测试", + "commands.test.run.running": "正在运行%s个测试…", + "commands.test.summary": "游戏测试完成!已运行%s个测试", + "commands.test.summary.all_required_passed": "必要测试均已通过 :)", + "commands.test.summary.failed": "%s个必要测试失败 :(", + "commands.test.summary.optional_failed": "%s个可选测试失败", + "commands.tick.query.percentiles": "百分位数:50%%:%s毫秒 95%%:%s毫秒 99%%:%s毫秒。共采样:%s", + "commands.tick.query.rate.running": "目标速率:%s刻每秒。\n每刻平均时间:%s毫秒(目标:%s毫秒)", + "commands.tick.query.rate.sprinting": "目标速率:%s刻每秒(已忽略,仅供参考)。\n每刻平均时间:%s毫秒", + "commands.tick.rate.success": "已设置目标速率为每秒%s刻", + "commands.tick.sprint.report": "游戏加速已完成,速率为每秒%s刻,即每刻%s毫秒", + "commands.tick.sprint.stop.fail": "没有正在执行的游戏刻加速", + "commands.tick.sprint.stop.success": "当前游戏刻加速已中止", + "commands.tick.status.frozen": "游戏冻结中", + "commands.tick.status.lagging": "游戏正在运行,但无法维持目标速率", + "commands.tick.status.running": "游戏正在以正常速度运行", + "commands.tick.status.sprinting": "游戏加速运行中", + "commands.tick.step.fail": "无法步进游戏,请先冻结游戏", + "commands.tick.step.stop.fail": "没有正在执行的游戏刻步进", + "commands.tick.step.stop.success": "当前游戏刻步进已中止", + "commands.tick.step.success": "正在步进%s刻", + "commands.time.no_default_clock": "维度%s中没有默认时钟", + "commands.time.no_time_marker_found": "时钟%2$s不存在时间标记%1$s", + "commands.time.pause": "已暂停时钟%s", + "commands.time.pause.already_paused": "时钟%s原已暂停", + "commands.time.pause.already_running": "时钟%s原已在运行中", + "commands.time.query": "当前时间为%s", + "commands.time.query.absolute": "时钟%s处于%s刻", + "commands.time.query.gametime": "游戏时间为%s刻", + "commands.time.query.timeline": "时间线%s处于%s刻", + "commands.time.query.timeline.repetitions": "时间线%s已重复%s次", + "commands.time.rate": "时钟%s现在以正常速度的%s倍运行", + "commands.time.rate.already_same": "时钟%s原已以正常速度的%s倍运行", + "commands.time.resume": "已恢复时钟%s", + "commands.time.set": "已将时间设为%s", + "commands.time.set.absolute": "已将%s设为%s刻", + "commands.time.set.already_at_time": "时钟%s原已处于%s刻", + "commands.time.set.already_at_time_marker": "时钟%s原已处于时间标记%s的时刻", + "commands.time.set.time_marker": "已将%s调至时间标记%s的时刻", + "commands.time.wrong_timeline_for_clock": "时间线%s对时钟%s无效", + "commands.title.cleared.multiple": "已清除%s名玩家的标题", + "commands.title.cleared.single": "已清除%s的标题", + "commands.title.reset.multiple": "已重置%s名玩家的标题设置", + "commands.title.reset.single": "已重置%s的标题设置", + "commands.title.show.actionbar.multiple": "正在向%s名玩家显示新的动作栏标题", + "commands.title.show.actionbar.single": "正在向%s显示新的动作栏标题", + "commands.title.show.subtitle.multiple": "正在向%s名玩家显示新的副标题", + "commands.title.show.subtitle.single": "正在向%s显示新的副标题", + "commands.title.show.title.multiple": "正在向%s名玩家显示新的标题", + "commands.title.show.title.single": "正在向%s显示新的标题", + "commands.title.times.multiple": "已更改%s名玩家的标题显示时间", + "commands.title.times.single": "已更改%s的标题显示时间", + "commands.transfer.error.no_players": "必须至少指定一个待转移的玩家", + "commands.transfer.success.multiple": "正在转移%s个玩家至%s:%s", + "commands.transfer.success.single": "正在转移%s至%s:%s", + "commands.trigger.add.success": "已触发%s(数值已增加%s)", + "commands.trigger.failed.invalid": "你只能触发“trigger”类型的记分项", + "commands.trigger.failed.unprimed": "你尚无法触发这个记分项", + "commands.trigger.set.success": "已触发%s(数值已设为%s)", + "commands.trigger.simple.success": "已触发%s", + "commands.unpublish.notPublished": "目前尚未开放多人游戏", + "commands.unpublish.success": "多人游戏已取消开放", + "commands.version.build_time": "build_time = %s", + "commands.version.data": "data = %s", + "commands.version.header": "Server version info:", + "commands.version.id": "id = %s", + "commands.version.name": "name = %s", + "commands.version.pack.data": "pack_data = %s", + "commands.version.pack.resource": "pack_resource = %s", + "commands.version.protocol": "protocol = %s (%s)", + "commands.version.series": "series = %s", + "commands.version.stable.no": "stable = no", + "commands.version.stable.yes": "stable = yes", + "commands.waypoint.list.empty": "在%s内没有路径点", + "commands.waypoint.list.success": "在%2$s内找到%1$s个路径点:%3$s", + "commands.waypoint.modify.color": "路径点颜色现在为%s", + "commands.waypoint.modify.color.reset": "已重置路径点颜色", + "commands.waypoint.modify.style": "已更改路径点样式", + "commands.weather.set.clear": "天气已设为晴天", + "commands.weather.set.rain": "天气已设为雨天", + "commands.weather.set.thunder": "天气已设为雷雨", + "commands.whitelist.add.failed": "玩家已在白名单内", + "commands.whitelist.add.success": "已将%s加入白名单", + "commands.whitelist.alreadyOff": "白名单原已关闭", + "commands.whitelist.alreadyOn": "白名单原已开启", + "commands.whitelist.disabled": "白名单已关闭", + "commands.whitelist.enabled": "白名单已开启", + "commands.whitelist.list": "白名单中共有%s名玩家:%s", + "commands.whitelist.none": "白名单中没有玩家", + "commands.whitelist.reloaded": "已重新读取白名单", + "commands.whitelist.remove.failed": "玩家不在白名单内", + "commands.whitelist.remove.success": "已将%s移出白名单", + "commands.worldborder.center.failed": "无变化,世界边界中心点已经在该处", + "commands.worldborder.center.success": "已将世界边界的中心设为%s, %s", + "commands.worldborder.damage.amount.failed": "无变化,世界边界伤害已经是此值", + "commands.worldborder.damage.amount.success": "已将世界边界的伤害设置为%s每秒每个方块", + "commands.worldborder.damage.buffer.failed": "无变化,世界边界伤害缓冲区已经是此距离", + "commands.worldborder.damage.buffer.success": "已将世界边界的伤害缓冲区设置为%s个方块", + "commands.worldborder.get": "世界边界的当前宽度为%s个方块", + "commands.worldborder.set.failed.big": "世界边界的宽度不能大于%s格", + "commands.worldborder.set.failed.far": "世界边界的位置不能远于%s格", + "commands.worldborder.set.failed.nochange": "无变化,世界边界已经是此大小", + "commands.worldborder.set.failed.small": "世界边界的宽度不能小于1格", + "commands.worldborder.set.grow": "正在将世界边界的宽度扩大为%s个方块,用时%s秒", + "commands.worldborder.set.immediate": "已将世界边界的宽度设为%s", + "commands.worldborder.set.shrink": "正在将世界边界的宽度缩小为%s个方块,用时%s秒", + "commands.worldborder.warning.distance.failed": "无变化,世界边界伤害警告区已经是此距离", + "commands.worldborder.warning.distance.success": "已将世界边界的警告距离设置为%s个方块", + "commands.worldborder.warning.time.failed": "无变化,世界边界警告时间已经是此时长", + "commands.worldborder.warning.time.success": "已将世界边界的警告时间设置为%s秒", + "compliance.playtime.greaterThan24Hours": "你的游戏时长已超过24小时", + "compliance.playtime.hours": "你的游戏时长已达%s小时", + "compliance.playtime.message": "适度游戏益脑,沉迷游戏伤身", + "component.profile.dynamic": "实时更新", + "connect.aborted": "连接中断", + "connect.authorizing": "登录中…", + "connect.connecting": "正在连接至服务器…", + "connect.encrypting": "通讯加密中…", + "connect.failed": "无法连接至服务器", + "connect.failed.transfer": "转移至服务器时连接失败", + "connect.joining": "加入世界中…", + "connect.negotiating": "连接协商中…", + "connect.p2p.establishing": "建立连接中…", + "connect.p2p.failed.generic": "无法连接至该玩家", + "connect.p2p.failed.signaling": "暂时无法连接。\n请稍后再试。", + "connect.p2p.failed.timeout": "连接请求超时", + "connect.p2p.failed.unreachable": "玩家无法连接", + "connect.reconfiging": "重新配置中…", + "connect.reconfiguring": "重新配置中…", + "connect.transferring": "正在转移至新的服务器…", + "container.barrel": "木桶", + "container.beacon": "信标", + "container.beehive.bees": "蜜蜂:%s/%s", + "container.beehive.honey": "蜂蜜:%s/%s", + "container.blast_furnace": "高炉", + "container.brewing": "酿造台", + "container.cartography_table": "制图台", + "container.chest": "箱子", + "container.chestDouble": "大型箱子", + "container.crafter": "合成器", + "container.crafting": "合成", + "container.creative": "物品选栏", + "container.dispenser": "发射器", + "container.dropper": "投掷器", + "container.enchant": "附魔", + "container.enchant.clue": "%s…?", + "container.enchant.lapis.many": "花费:%s颗青金石", + "container.enchant.lapis.one": "花费:1颗青金石", + "container.enchant.level.many": "+ %s级经验", + "container.enchant.level.one": "+ 1级经验", + "container.enchant.level.requirement": "等级要求:%s", + "container.enderchest": "末影箱", + "container.furnace": "熔炉", + "container.grindstone_title": "修复和祛魔", + "container.hopper": "漏斗", + "container.inventory": "物品栏", + "container.isLocked": "%s已被上锁!", + "container.lectern": "讲台", + "container.loom": "织布机", + "container.repair": "修复和命名", + "container.repair.cost": "附魔花费:%1$s", + "container.repair.expensive": "过于昂贵!", + "container.shulkerBox": "潜影盒", + "container.shulkerBox.itemCount": "%s × %s", + "container.shulkerBox.more": "还有%s项未显示…", + "container.shulkerBox.unknownContents": "???????", + "container.smoker": "烟熏炉", + "container.spectatorCantOpen": "无法打开:战利品尚未生成。", + "container.stonecutter": "切石机", + "container.upgrade": "升级装备", + "container.upgrade.error_tooltip": "该物品无法用此方式升级", + "container.upgrade.missing_template_tooltip": "放入锻造模板", + "controls.keybinds": "按键绑定…", + "controls.keybinds.duplicateKeybinds": "该按键也用于:\n%s", + "controls.keybinds.title": "按键绑定", + "controls.reset": "重置", + "controls.resetAll": "重置按键", + "controls.title": "按键控制", + "createWorld.customize.buffet.biome": "请选择一种生物群系", + "createWorld.customize.buffet.search": "搜索…", + "createWorld.customize.buffet.title": "自定义单一生物群系", + "createWorld.customize.flat.height": "高度", + "createWorld.customize.flat.layer": "%s", + "createWorld.customize.flat.layer.bottom": "底层 - %s", + "createWorld.customize.flat.layer.top": "顶层 - %s", + "createWorld.customize.flat.removeLayer": "移除层面", + "createWorld.customize.flat.tile": "各层的材料", + "createWorld.customize.flat.title": "自定义超平坦世界", + "createWorld.customize.presets": "预设", + "createWorld.customize.presets.list": "另外,这里是些我们早期制作好的!", + "createWorld.customize.presets.select": "使用预设", + "createWorld.customize.presets.share": "想要与别人分享你的预设方案吗?使用下面的输入框吧!", + "createWorld.customize.presets.title": "选择一种预设", + "createWorld.preparing": "正在准备生成世界…", + "createWorld.tab.game.title": "游戏", + "createWorld.tab.more.title": "更多", + "createWorld.tab.world.title": "世界", + "credits_and_attribution.button.attribution": "著作权说明", + "credits_and_attribution.button.credits": "鸣谢名单", + "credits_and_attribution.button.licenses": "许可协议", + "credits_and_attribution.screen.title": "鸣谢与著作权说明", + "dataPack.bundle.description": "启用实验性收纳袋物品", + "dataPack.bundle.name": "收纳袋", + "dataPack.locator_bar.description": "在多人游戏中显示其他玩家的方位", + "dataPack.locator_bar.name": "定位栏", + "dataPack.minecart_improvements.description": "改进了矿车的运动", + "dataPack.minecart_improvements.name": "矿车改进", + "dataPack.redstone_experiments.description": "实验性红石更改", + "dataPack.redstone_experiments.name": "红石实验性内容", + "dataPack.title": "选择数据包", + "dataPack.trade_rebalance.description": "新版村民交易", + "dataPack.trade_rebalance.name": "村民交易的平衡性调整", + "dataPack.update_1_20.description": "Minecraft 1.20的新功能与内容", + "dataPack.update_1_20.name": "1.20更新", + "dataPack.update_1_21.description": "Minecraft 1.21的新功能与内容", + "dataPack.update_1_21.name": "1.21更新", + "dataPack.validation.back": "返回", + "dataPack.validation.failed": "数据包验证失败!", + "dataPack.validation.reset": "重置为默认", + "dataPack.validation.working": "正在验证选中的数据包…", + "dataPack.vanilla.description": "Minecraft的默认数据包", + "dataPack.vanilla.name": "默认", + "dataPack.winter_drop.description": "冬季小更新的新功能与内容", + "dataPack.winter_drop.name": "冬季小更新", + "datapackFailure.safeMode": "安全模式", + "datapackFailure.safeMode.failed.description": "这个世界包含无效或损坏的存档数据。", + "datapackFailure.safeMode.failed.title": "无法在安全模式下加载世界", + "datapackFailure.title": "当前选中的数据包中出现了错误,导致世界无法加载。\n你可以尝试仅加载原版数据包(“安全模式”)或回到标题屏幕手动修复该问题。", + "death.attack.anvil": "%1$s被下落的铁砧压扁了", + "death.attack.anvil.player": "%1$s在与%2$s战斗时被下落的铁砧压扁了", + "death.attack.arrow": "%1$s被%2$s射杀", + "death.attack.arrow.item": "%1$s被%2$s用%3$s射杀", + "death.attack.badRespawnPoint.link": "刻意的游戏设计", + "death.attack.badRespawnPoint.message": "%1$s被%2$s杀死了", + "death.attack.cactus": "%1$s被戳死了", + "death.attack.cactus.player": "%1$s在试图逃离%2$s时撞上了仙人掌", + "death.attack.cramming": "%1$s因被过度挤压而死", + "death.attack.cramming.player": "%1$s被%2$s挤扁了", + "death.attack.dragonBreath": "%1$s被龙息烤熟了", + "death.attack.dragonBreath.player": "%1$s被%2$s的龙息烤熟了", + "death.attack.drown": "%1$s淹死了", + "death.attack.drown.player": "%1$s在试图逃离%2$s时淹死了", + "death.attack.dryout": "%1$s因脱水而死", + "death.attack.dryout.player": "%1$s在试图逃离%2$s时因脱水而死", + "death.attack.even_more_magic": "%1$s被不为人知的魔法杀死了", + "death.attack.explosion": "%1$s爆炸了", + "death.attack.explosion.player": "%1$s被%2$s炸死了", + "death.attack.explosion.player.item": "%1$s被%2$s用%3$s炸死了", + "death.attack.fall": "%1$s落地过猛", + "death.attack.fall.player": "%1$s在试图逃离%2$s时落地过猛", + "death.attack.fallingBlock": "%1$s被下落的方块压扁了", + "death.attack.fallingBlock.player": "%1$s在与%2$s战斗时被下落的方块压扁了", + "death.attack.fallingStalactite": "%1$s被下落的钟乳石刺穿了", + "death.attack.fallingStalactite.player": "%1$s在与%2$s战斗时被下落的钟乳石刺穿了", + "death.attack.fireball": "%1$s被%2$s用火球烧死了", + "death.attack.fireball.item": "%1$s被%2$s用%3$s发射的火球烧死了", + "death.attack.fireworks": "%1$s随着一声巨响消失了", + "death.attack.fireworks.item": "%1$s随着%2$s用%3$s发射的烟花发出的巨响消失了", + "death.attack.fireworks.player": "%1$s在与%2$s战斗时随着一声巨响消失了", + "death.attack.flyIntoWall": "%1$s感受到了动能", + "death.attack.flyIntoWall.player": "%1$s在试图逃离%2$s时感受到了动能", + "death.attack.freeze": "%1$s被冻死了", + "death.attack.freeze.player": "%1$s被%2$s冻死了", + "death.attack.generic": "%1$s死了", + "death.attack.generic.player": "%1$s死于%2$s", + "death.attack.genericKill": "%1$s被杀死了", + "death.attack.genericKill.player": "%1$s在与%2$s战斗时被杀死了", + "death.attack.hotFloor": "%1$s发现了地板是熔岩做的", + "death.attack.hotFloor.player": "%1$s因%2$s而步入危险之地", + "death.attack.inFire": "%1$s浴火焚身", + "death.attack.inFire.player": "%1$s在与%2$s战斗时不慎走入了火中", + "death.attack.inWall": "%1$s在墙里窒息而亡", + "death.attack.inWall.player": "%1$s在与%2$s战斗时在墙里窒息而亡", + "death.attack.indirectMagic": "%1$s被%2$s使用的魔法杀死了", + "death.attack.indirectMagic.item": "%1$s被%2$s用%3$s杀死了", + "death.attack.lava": "%1$s试图在熔岩里游泳", + "death.attack.lava.player": "%1$s在逃离%2$s时试图在熔岩里游泳", + "death.attack.lightningBolt": "%1$s被闪电击中", + "death.attack.lightningBolt.player": "%1$s在与%2$s战斗时被闪电击中", + "death.attack.mace_smash": "%1$s被%2$s一锤毙命", + "death.attack.mace_smash.item": "%1$s被%2$s用%3$s一锤毙命", + "death.attack.magic": "%1$s被魔法杀死了", + "death.attack.magic.player": "%1$s在试图逃离%2$s时被魔法杀死了", + "death.attack.message_too_long": "抱歉!消息太长,无法完整显示。截断后的消息:%s", + "death.attack.mob": "%1$s被%2$s杀死了", + "death.attack.mob.item": "%1$s被%2$s用%3$s杀死了", + "death.attack.onFire": "%1$s被烧死了", + "death.attack.onFire.item": "%1$s在与持有%3$s的%2$s战斗时被烤得酥脆", + "death.attack.onFire.player": "%1$s在与%2$s战斗时被烤得酥脆", + "death.attack.outOfWorld": "%1$s掉出了这个世界", + "death.attack.outOfWorld.player": "%1$s与%2$s不共戴天", + "death.attack.outsideBorder": "%1$s脱离了这个世界", + "death.attack.outsideBorder.player": "%1$s在与%2$s战斗时脱离了这个世界", + "death.attack.player": "%1$s被%2$s杀死了", + "death.attack.player.item": "%1$s被%2$s用%3$s杀死了", + "death.attack.sonic_boom": "%1$s被一道音波尖啸抹除了", + "death.attack.sonic_boom.item": "%1$s在试图逃离持有%3$s的%2$s时被一道音波尖啸抹除了", + "death.attack.sonic_boom.player": "%1$s在试图逃离%2$s时被一道音波尖啸抹除了", + "death.attack.spear": "%1$s被%2$s刺穿了", + "death.attack.spear.item": "%1$s被%2$s用%3$s刺穿了", + "death.attack.stalagmite": "%1$s被石笋刺穿了", + "death.attack.stalagmite.player": "%1$s在与%2$s战斗时被石笋刺穿了", + "death.attack.starve": "%1$s饿死了", + "death.attack.starve.player": "%1$s在与%2$s战斗时饿死了", + "death.attack.sting": "%1$s被蜇死了", + "death.attack.sting.item": "%1$s被%2$s用%3$s蜇死了", + "death.attack.sting.player": "%1$s被%2$s蜇死了", + "death.attack.sulfurCubeHot": "%1$s发现了不只有地板是熔岩做的", + "death.attack.sulfurCubeHot.item": "%2$s用%3$s让%1$s知道了不只有地板是熔岩做的", + "death.attack.sulfurCubeHot.player": "%2$s让%1$s知道了不只有地板是熔岩做的", + "death.attack.sweetBerryBush": "%1$s被甜浆果丛刺死了", + "death.attack.sweetBerryBush.player": "%1$s在试图逃离%2$s时被甜浆果丛刺死了", + "death.attack.thorns": "%1$s在试图伤害%2$s时被杀", + "death.attack.thorns.item": "%1$s在试图伤害%2$s时被%3$s杀死", + "death.attack.thrown": "%1$s被%2$s给砸死了", + "death.attack.thrown.item": "%1$s被%2$s用%3$s给砸死了", + "death.attack.trident": "%1$s被%2$s刺穿了", + "death.attack.trident.item": "%1$s被%2$s用%3$s刺穿了", + "death.attack.wither": "%1$s凋零了", + "death.attack.wither.player": "%1$s在与%2$s战斗时凋零了", + "death.attack.witherSkull": "%1$s被%2$s发射的头颅射杀", + "death.attack.witherSkull.item": "%1$s被%2$s用%3$s发射的头颅射杀", + "death.fell.accident.generic": "%1$s从高处摔了下来", + "death.fell.accident.ladder": "%1$s从梯子上摔了下来", + "death.fell.accident.other_climbable": "%1$s在攀爬时摔了下来", + "death.fell.accident.scaffolding": "%1$s从脚手架上摔了下来", + "death.fell.accident.twisting_vines": "%1$s从缠怨藤上摔了下来", + "death.fell.accident.vines": "%1$s从藤蔓上摔了下来", + "death.fell.accident.weeping_vines": "%1$s从垂泪藤上摔了下来", + "death.fell.assist": "%1$s因为%2$s注定要摔死", + "death.fell.assist.item": "%1$s因为%2$s使用了%3$s注定要摔死", + "death.fell.finish": "%1$s摔伤得太重并被%2$s完结了生命", + "death.fell.finish.item": "%1$s摔伤得太重并被%2$s用%3$s完结了生命", + "death.fell.killer": "%1$s注定要摔死", + "deathScreen.quit.confirm": "你确定要退出吗?", + "deathScreen.respawn": "重生", + "deathScreen.score": "分数", + "deathScreen.score.value": "分数:%s", + "deathScreen.spectate": "旁观世界", + "deathScreen.title": "你死了!", + "deathScreen.title.hardcore": "游戏结束!", + "deathScreen.titleScreen": "标题屏幕", + "debug.advanced_tooltips.help": "F3 + H = 显示高级提示框", + "debug.advanced_tooltips.off": "高级提示框:隐藏", + "debug.advanced_tooltips.on": "高级提示框:显示", + "debug.chunk_boundaries.help": "F3 + G = 显示区块边界", + "debug.chunk_boundaries.off": "区块边界:隐藏", + "debug.chunk_boundaries.on": "区块边界:显示", + "debug.clear_chat.help": "F3 + D = 清空聊天记录", + "debug.copy_location.help": "F3 + C = 将位置复制为/tp命令,或长按10秒使游戏崩溃", + "debug.copy_location.message": "坐标已复制到剪贴板", + "debug.crash.message": "F3 + C已被按下。若不放开按键则会使游戏崩溃。", + "debug.crash.message.rebindable": "%s + %s已被按下。若不放开按键则会使游戏崩溃。", + "debug.crash.warning": "将在%s秒后崩溃…", + "debug.creative_spectator.error": "你没有切换游戏模式的权限", + "debug.creative_spectator.help": "F3 + N = 在上一个模式和旁观模式间切换", + "debug.dump_dynamic_textures": "已将动态纹理保存至%s", + "debug.dump_dynamic_textures.help": "F3 + S = 转储动态纹理", + "debug.entry.always": "始终显示", + "debug.entry.currently.alwaysOn": "%s:当前始终显示", + "debug.entry.currently.inF3": "%s:当前仅F3", + "debug.entry.currently.inOverlay": "%s:当前仅显示在调试叠加层", + "debug.entry.currently.never": "%s:当前关闭", + "debug.entry.f3": "仅F3", + "debug.entry.overlay": "叠加层", + "debug.gamemodes.error": "你没有权限打开游戏模式切换器", + "debug.gamemodes.help": "F3 + F4 = 打开游戏模式切换器", + "debug.gamemodes.press_f4": "[ F4 ]", + "debug.gamemodes.select_next": "%s 下一个", + "debug.help.help": "F3 + Q = 显示此列表", + "debug.help.message": "按键设置:", + "debug.improvedTransparency.off": "改进透明显示:禁用", + "debug.improvedTransparency.on": "改进透明显示:启用", + "debug.inspect.client.block": "客户端方块数据已复制到剪贴板", + "debug.inspect.client.entity": "客户端实体数据已复制到剪贴板", + "debug.inspect.help": "F3 + I = 将实体或方块的数据复制到剪贴板", + "debug.inspect.server.block": "服务端方块数据已复制到剪贴板", + "debug.inspect.server.entity": "服务端实体数据已复制到剪贴板", + "debug.options.category.renderer": "调试渲染器", + "debug.options.category.text": "调试屏幕文本", + "debug.options.help": "F3 + F6 = 编辑调试选项", + "debug.options.notAllowed.tooltip": "简化调试信息时隐藏。", + "debug.options.profile.default": "默认配置", + "debug.options.profile.performance": "性能监控配置", + "debug.options.search": "搜索…", + "debug.options.title": "调试选项", + "debug.options.warning": "这些选项仅用于测试目的。它们可能会拖慢你的电脑,使游戏崩溃,或者吃掉你的宠物石头。", + "debug.pause.help": "F3 + Esc = 暂停但不显示菜单(如果可以暂停的话)", + "debug.pause_focus.help": "F3 + P = 失去焦点时暂停", + "debug.pause_focus.off": "失去焦点时暂停:禁用", + "debug.pause_focus.on": "失去焦点时暂停:启用", + "debug.prefix": "[调试]:", + "debug.profiling.help": "F3 + L = 开始/停止分析", + "debug.profiling.start": "分析已启动%s秒。使用F3 + L以提前结束", + "debug.profiling.start.rebindable": "分析已启动%s秒。使用%s + %s以提前结束", + "debug.profiling.stop": "分析已结束。结果已保存至%s", + "debug.reload_chunks.help": "F3 + A = 重新加载区块", + "debug.reload_chunks.message": "重新加载所有区块中", + "debug.reload_resourcepacks.help": "F3 + T = 重新加载资源包", + "debug.reload_resourcepacks.message": "已重新加载资源包", + "debug.show_hitboxes.help": "F3 + B = 显示判定箱", + "debug.show_hitboxes.off": "判定箱:隐藏", + "debug.show_hitboxes.on": "判定箱:显示", + "debug.version.header": "客户端版本信息:", + "debug.version.help": "F3 + V = 显示客户端版本信息", + "demo.day.1": "此试玩版会在5个游戏日后结束,尽力而为吧!", + "demo.day.2": "第二天", + "demo.day.3": "第三天", + "demo.day.4": "第四天", + "demo.day.5": "这是你游戏内的最后一天!", + "demo.day.6": "你已经度过了5个游戏日的试玩时间,按下%s来为你的成果截图留念。", + "demo.day.warning": "你的试玩时间即将结束!", + "demo.demoExpired": "试玩的时间结束了!", + "demo.help.buy": "即刻购买!", + "demo.help.fullWrapped": "这个试玩将会持续游戏内5天的时间(现实时间大约为1小时40分钟)。查看进度来获得提示!祝你玩得开心!", + "demo.help.inventory": "按下%1$s键打开物品栏", + "demo.help.jump": "按下%1$s键来跳跃", + "demo.help.later": "继续游戏!", + "demo.help.movement": "使用%1$s、%2$s、%3$s、%4$s以及鼠标来移动", + "demo.help.movementMouse": "使用鼠标来环顾四周", + "demo.help.movementShort": "通过按下%1$s、%2$s、%3$s、%4$s来移动", + "demo.help.title": "Minecraft试玩模式", + "demo.remainingTime": "剩余时间:%s", + "demo.reminder": "试玩时间已经结束,请购买游戏来继续或开始一个新的世界!", + "difficulty.lock.question": "你确定你要锁定这个世界的难度吗?这会将这个世界的难度锁定为%1$s,并且永远无法再次改变难度。", + "difficulty.lock.title": "锁定世界难度", + "disconnect.endOfStream": "数据流终止", + "disconnect.exceeded_packet_rate": "由于超出数据包速率限制而被踢出游戏", + "disconnect.genericReason": "%s", + "disconnect.ignoring_status_request": "忽略状态请求", + "disconnect.loginFailedInfo": "登录失败:%s", + "disconnect.loginFailedInfo.insufficientPrivileges": "多人游戏已被禁用,请检查你的Microsoft账户设置。", + "disconnect.loginFailedInfo.invalidSession": "无效会话(请尝试重启游戏及启动器)", + "disconnect.loginFailedInfo.serversUnavailable": "暂时无法连接到身份验证服务器,请稍后再试。", + "disconnect.loginFailedInfo.userBanned": "你已被禁止进行在线游戏", + "disconnect.lost": "连接已丢失", + "disconnect.packetError": "网络协议错误", + "disconnect.spam": "由于滥发消息而被踢出游戏", + "disconnect.timeout": "连接超时", + "disconnect.transfer": "已转移至另一个服务器", + "disconnect.unknownHost": "未知的主机", + "download.pack.failed": "有%s个包下载失败,共%s个包", + "download.pack.progress.bytes": "进度:%s(总大小未知)", + "download.pack.progress.percent": "进度:%s%%", + "download.pack.title": "正在下载资源包%s/%s", + "editGamerule.default": "默认:%s", + "editGamerule.inGame.button": "编辑游戏规则…", + "editGamerule.inGame.disabled.hardcore.tooltip": "无法在极限模式世界中更改游戏规则。", + "editGamerule.inGame.disabled.tooltip": "更新游戏规则需要管理员权限。", + "editGamerule.inGame.discardChanges.message": "你确定要放弃未保存的游戏规则更改吗?", + "editGamerule.inGame.discardChanges.title": "游戏规则更改", + "editGamerule.inGame.downloadingGamerules": "正在获取游戏规则…", + "editGamerule.title": "编辑游戏规则", + "effect.duration.infinite": "∞", + "effect.minecraft.absorption": "伤害吸收", + "effect.minecraft.bad_omen": "不祥之兆", + "effect.minecraft.blindness": "失明", + "effect.minecraft.breath_of_the_nautilus": "鹦鹉螺之息", + "effect.minecraft.conduit_power": "潮涌能量", + "effect.minecraft.darkness": "黑暗", + "effect.minecraft.dolphins_grace": "海豚的恩惠", + "effect.minecraft.fire_resistance": "抗火", + "effect.minecraft.freezing": "冰冻", + "effect.minecraft.glowing": "发光", + "effect.minecraft.haste": "急迫", + "effect.minecraft.health_boost": "生命提升", + "effect.minecraft.hero_of_the_village": "村庄英雄", + "effect.minecraft.hunger": "饥饿", + "effect.minecraft.infested": "寄生", + "effect.minecraft.instant_damage": "瞬间伤害", + "effect.minecraft.instant_health": "瞬间治疗", + "effect.minecraft.invisibility": "隐身", + "effect.minecraft.jump_boost": "跳跃提升", + "effect.minecraft.levitation": "飘浮", + "effect.minecraft.luck": "幸运", + "effect.minecraft.mining_fatigue": "挖掘疲劳", + "effect.minecraft.nausea": "反胃", + "effect.minecraft.night_vision": "夜视", + "effect.minecraft.oozing": "渗浆", + "effect.minecraft.poison": "中毒", + "effect.minecraft.raid_omen": "袭击之兆", + "effect.minecraft.regeneration": "生命恢复", + "effect.minecraft.resistance": "抗性提升", + "effect.minecraft.saturation": "饱和", + "effect.minecraft.slow_falling": "缓降", + "effect.minecraft.slowness": "缓慢", + "effect.minecraft.speed": "迅捷", + "effect.minecraft.strength": "力量", + "effect.minecraft.trial_omen": "试炼之兆", + "effect.minecraft.unluck": "霉运", + "effect.minecraft.water_breathing": "水下呼吸", + "effect.minecraft.weakness": "虚弱", + "effect.minecraft.weaving": "盘丝", + "effect.minecraft.wind_charged": "蓄风", + "effect.minecraft.wither": "凋零", + "effect.none": "无效果", + "enchantment.level.1": "I", + "enchantment.level.10": "X", + "enchantment.level.2": "II", + "enchantment.level.3": "III", + "enchantment.level.4": "IV", + "enchantment.level.5": "V", + "enchantment.level.6": "VI", + "enchantment.level.7": "VII", + "enchantment.level.8": "VIII", + "enchantment.level.9": "IX", + "enchantment.minecraft.aqua_affinity": "水下速掘", + "enchantment.minecraft.bane_of_arthropods": "节肢杀手", + "enchantment.minecraft.binding_curse": "绑定诅咒", + "enchantment.minecraft.blast_protection": "爆炸保护", + "enchantment.minecraft.breach": "破甲", + "enchantment.minecraft.channeling": "引雷", + "enchantment.minecraft.density": "致密", + "enchantment.minecraft.depth_strider": "深海探索者", + "enchantment.minecraft.efficiency": "效率", + "enchantment.minecraft.feather_falling": "摔落缓冲", + "enchantment.minecraft.fire_aspect": "火焰附加", + "enchantment.minecraft.fire_protection": "火焰保护", + "enchantment.minecraft.flame": "火矢", + "enchantment.minecraft.fortune": "时运", + "enchantment.minecraft.frost_walker": "冰霜行者", + "enchantment.minecraft.impaling": "穿刺", + "enchantment.minecraft.infinity": "无限", + "enchantment.minecraft.knockback": "击退", + "enchantment.minecraft.looting": "抢夺", + "enchantment.minecraft.loyalty": "忠诚", + "enchantment.minecraft.luck_of_the_sea": "海之眷顾", + "enchantment.minecraft.lunge": "突进", + "enchantment.minecraft.lure": "饵钓", + "enchantment.minecraft.mending": "经验修补", + "enchantment.minecraft.multishot": "多重射击", + "enchantment.minecraft.piercing": "穿透", + "enchantment.minecraft.power": "力量", + "enchantment.minecraft.projectile_protection": "弹射物保护", + "enchantment.minecraft.protection": "保护", + "enchantment.minecraft.punch": "冲击", + "enchantment.minecraft.quick_charge": "快速装填", + "enchantment.minecraft.respiration": "水下呼吸", + "enchantment.minecraft.riptide": "激流", + "enchantment.minecraft.sharpness": "锋利", + "enchantment.minecraft.silk_touch": "精准采集", + "enchantment.minecraft.smite": "亡灵杀手", + "enchantment.minecraft.soul_speed": "灵魂疾行", + "enchantment.minecraft.sweeping": "横扫之刃", + "enchantment.minecraft.sweeping_edge": "横扫之刃", + "enchantment.minecraft.swift_sneak": "迅捷潜行", + "enchantment.minecraft.thorns": "荆棘", + "enchantment.minecraft.unbreaking": "耐久", + "enchantment.minecraft.vanishing_curse": "消失诅咒", + "enchantment.minecraft.wind_burst": "风爆", + "entity.minecraft.acacia_boat": "金合欢木船", + "entity.minecraft.acacia_chest_boat": "金合欢木运输船", + "entity.minecraft.allay": "悦灵", + "entity.minecraft.area_effect_cloud": "区域效果云", + "entity.minecraft.armadillo": "犰狳", + "entity.minecraft.armor_stand": "盔甲架", + "entity.minecraft.arrow": "箭", + "entity.minecraft.axolotl": "美西螈", + "entity.minecraft.bamboo_chest_raft": "运输竹筏", + "entity.minecraft.bamboo_raft": "竹筏", + "entity.minecraft.bat": "蝙蝠", + "entity.minecraft.bee": "蜜蜂", + "entity.minecraft.birch_boat": "白桦木船", + "entity.minecraft.birch_chest_boat": "白桦木运输船", + "entity.minecraft.blaze": "烈焰人", + "entity.minecraft.block_display": "方块展示实体", + "entity.minecraft.boat": "船", + "entity.minecraft.bogged": "沼骸", + "entity.minecraft.breeze": "旋风人", + "entity.minecraft.breeze_wind_charge": "风弹", + "entity.minecraft.camel": "骆驼", + "entity.minecraft.camel_husk": "骆驼尸壳", + "entity.minecraft.cat": "猫", + "entity.minecraft.cave_spider": "洞穴蜘蛛", + "entity.minecraft.cherry_boat": "樱花木船", + "entity.minecraft.cherry_chest_boat": "樱花木运输船", + "entity.minecraft.chest_boat": "运输船", + "entity.minecraft.chest_minecart": "运输矿车", + "entity.minecraft.chicken": "鸡", + "entity.minecraft.cod": "鳕鱼", + "entity.minecraft.command_block_minecart": "命令方块矿车", + "entity.minecraft.copper_golem": "铜傀儡", + "entity.minecraft.cow": "牛", + "entity.minecraft.creaking": "嘎枝", + "entity.minecraft.creaking_transient": "嘎枝", + "entity.minecraft.creeper": "苦力怕", + "entity.minecraft.cushion": "坐垫", + "entity.minecraft.dark_oak_boat": "深色橡木船", + "entity.minecraft.dark_oak_chest_boat": "深色橡木运输船", + "entity.minecraft.dolphin": "海豚", + "entity.minecraft.donkey": "驴", + "entity.minecraft.dragon_fireball": "末影龙火球", + "entity.minecraft.drowned": "溺尸", + "entity.minecraft.egg": "掷出的鸡蛋", + "entity.minecraft.elder_guardian": "远古守卫者", + "entity.minecraft.end_crystal": "末地水晶", + "entity.minecraft.ender_dragon": "末影龙", + "entity.minecraft.ender_pearl": "掷出的末影珍珠", + "entity.minecraft.enderman": "末影人", + "entity.minecraft.endermite": "末影螨", + "entity.minecraft.evoker": "唤魔者", + "entity.minecraft.evoker_fangs": "唤魔者尖牙", + "entity.minecraft.experience_bottle": "掷出的附魔之瓶", + "entity.minecraft.experience_orb": "经验球", + "entity.minecraft.eye_of_ender": "末影之眼", + "entity.minecraft.falling_block": "下落的方块", + "entity.minecraft.falling_block_type": "下落的%s", + "entity.minecraft.fireball": "火球", + "entity.minecraft.firework_rocket": "烟花火箭", + "entity.minecraft.fishing_bobber": "浮漂", + "entity.minecraft.fox": "狐狸", + "entity.minecraft.frog": "青蛙", + "entity.minecraft.frostbite": "冻尸", + "entity.minecraft.furnace_minecart": "动力矿车", + "entity.minecraft.ghast": "恶魂", + "entity.minecraft.giant": "巨人", + "entity.minecraft.glow_item_frame": "荧光物品展示框", + "entity.minecraft.glow_squid": "发光鱿鱼", + "entity.minecraft.goat": "山羊", + "entity.minecraft.guardian": "守卫者", + "entity.minecraft.happy_ghast": "快乐恶魂", + "entity.minecraft.hoglin": "疣猪兽", + "entity.minecraft.hopper_minecart": "漏斗矿车", + "entity.minecraft.horse": "马", + "entity.minecraft.husk": "尸壳", + "entity.minecraft.ice_ball": "冰球", + "entity.minecraft.illusioner": "幻术师", + "entity.minecraft.interaction": "交互实体", + "entity.minecraft.iron_golem": "铁傀儡", + "entity.minecraft.item": "物品", + "entity.minecraft.item_display": "物品展示实体", + "entity.minecraft.item_frame": "物品展示框", + "entity.minecraft.jungle_boat": "丛林木船", + "entity.minecraft.jungle_chest_boat": "丛林木运输船", + "entity.minecraft.killer_bunny": "杀手兔", + "entity.minecraft.leash_knot": "拴绳结", + "entity.minecraft.lightning_bolt": "闪电束", + "entity.minecraft.lingering_potion": "滞留药水", + "entity.minecraft.llama": "羊驼", + "entity.minecraft.llama_spit": "羊驼唾沫", + "entity.minecraft.magma_cube": "岩浆怪", + "entity.minecraft.mangrove_boat": "红树木船", + "entity.minecraft.mangrove_chest_boat": "红树木运输船", + "entity.minecraft.mannequin": "玩家模型", + "entity.minecraft.mannequin.label": "NPC", + "entity.minecraft.marker": "标记", + "entity.minecraft.minecart": "矿车", + "entity.minecraft.mooshroom": "哞菇", + "entity.minecraft.mule": "骡", + "entity.minecraft.nautilus": "鹦鹉螺", + "entity.minecraft.oak_boat": "橡木船", + "entity.minecraft.oak_chest_boat": "橡木运输船", + "entity.minecraft.ocelot": "豹猫", + "entity.minecraft.ominous_item_spawner": "不祥之物生成器", + "entity.minecraft.painting": "画", + "entity.minecraft.pale_oak_boat": "苍白橡木船", + "entity.minecraft.pale_oak_chest_boat": "苍白橡木运输船", + "entity.minecraft.panda": "熊猫", + "entity.minecraft.parched": "焦骸", + "entity.minecraft.parrot": "鹦鹉", + "entity.minecraft.phantom": "幻翼", + "entity.minecraft.pig": "猪", + "entity.minecraft.piglin": "猪灵", + "entity.minecraft.piglin_brute": "猪灵蛮兵", + "entity.minecraft.pillager": "掠夺者", + "entity.minecraft.player": "玩家", + "entity.minecraft.polar_bear": "北极熊", + "entity.minecraft.poplar_boat": "杨木船", + "entity.minecraft.poplar_chest_boat": "杨木运输船", + "entity.minecraft.potion": "药水", + "entity.minecraft.pufferfish": "河豚", + "entity.minecraft.rabbit": "兔子", + "entity.minecraft.ravager": "劫掠兽", + "entity.minecraft.salmon": "鲑鱼", + "entity.minecraft.sheep": "绵羊", + "entity.minecraft.shulker": "潜影贝", + "entity.minecraft.shulker_bullet": "潜影弹", + "entity.minecraft.silverfish": "蠹虫", + "entity.minecraft.skeleton": "骷髅", + "entity.minecraft.skeleton_horse": "骷髅马", + "entity.minecraft.slime": "史莱姆", + "entity.minecraft.small_fireball": "小火球", + "entity.minecraft.sniffer": "嗅探兽", + "entity.minecraft.snow_golem": "雪傀儡", + "entity.minecraft.snowball": "雪球", + "entity.minecraft.spawner_minecart": "刷怪笼矿车", + "entity.minecraft.spectral_arrow": "光灵箭", + "entity.minecraft.spider": "蜘蛛", + "entity.minecraft.splash_potion": "喷溅药水", + "entity.minecraft.spruce_boat": "云杉木船", + "entity.minecraft.spruce_chest_boat": "云杉木运输船", + "entity.minecraft.squid": "鱿鱼", + "entity.minecraft.stray": "流浪者", + "entity.minecraft.strider": "炽足兽", + "entity.minecraft.sulfur_cube": "硫方怪", + "entity.minecraft.sulfur_cube.content": "包含:%s", + "entity.minecraft.tadpole": "蝌蚪", + "entity.minecraft.text_display": "文本展示实体", + "entity.minecraft.tnt": "被激活的TNT", + "entity.minecraft.tnt_minecart": "TNT矿车", + "entity.minecraft.trader_llama": "行商羊驼", + "entity.minecraft.trident": "三叉戟", + "entity.minecraft.tropical_fish": "热带鱼", + "entity.minecraft.tropical_fish.predefined.0": "海葵鱼", + "entity.minecraft.tropical_fish.predefined.1": "黑刺尾鲷", + "entity.minecraft.tropical_fish.predefined.10": "镰鱼", + "entity.minecraft.tropical_fish.predefined.11": "华丽蝴蝶鱼", + "entity.minecraft.tropical_fish.predefined.12": "鹦嘴鱼", + "entity.minecraft.tropical_fish.predefined.13": "额斑刺蝶鱼", + "entity.minecraft.tropical_fish.predefined.14": "红丽鱼", + "entity.minecraft.tropical_fish.predefined.15": "红唇真蛇鳚", + "entity.minecraft.tropical_fish.predefined.16": "红边笛鲷", + "entity.minecraft.tropical_fish.predefined.17": "马鲅", + "entity.minecraft.tropical_fish.predefined.18": "白条双锯鱼", + "entity.minecraft.tropical_fish.predefined.19": "鳞鲀", + "entity.minecraft.tropical_fish.predefined.2": "蓝刺尾鲷", + "entity.minecraft.tropical_fish.predefined.20": "高鳍鹦嘴鱼", + "entity.minecraft.tropical_fish.predefined.21": "黄刺尾鲷", + "entity.minecraft.tropical_fish.predefined.3": "蝴蝶鱼", + "entity.minecraft.tropical_fish.predefined.4": "丽鱼", + "entity.minecraft.tropical_fish.predefined.5": "小丑鱼", + "entity.minecraft.tropical_fish.predefined.6": "五彩搏鱼", + "entity.minecraft.tropical_fish.predefined.7": "绣雀鲷", + "entity.minecraft.tropical_fish.predefined.8": "川纹笛鲷", + "entity.minecraft.tropical_fish.predefined.9": "拟羊鱼", + "entity.minecraft.tropical_fish.type.betty": "背蒂类", + "entity.minecraft.tropical_fish.type.blockfish": "方身类", + "entity.minecraft.tropical_fish.type.brinely": "咸水类", + "entity.minecraft.tropical_fish.type.clayfish": "陶鱼类", + "entity.minecraft.tropical_fish.type.dasher": "速跃类", + "entity.minecraft.tropical_fish.type.flopper": "飞翼类", + "entity.minecraft.tropical_fish.type.glitter": "闪鳞类", + "entity.minecraft.tropical_fish.type.kob": "石首类", + "entity.minecraft.tropical_fish.type.snooper": "窥伺类", + "entity.minecraft.tropical_fish.type.spotty": "多斑类", + "entity.minecraft.tropical_fish.type.stripey": "条纹类", + "entity.minecraft.tropical_fish.type.sunstreak": "日纹类", + "entity.minecraft.turtle": "海龟", + "entity.minecraft.vex": "恼鬼", + "entity.minecraft.villager": "村民", + "entity.minecraft.villager.armorer": "盔甲匠", + "entity.minecraft.villager.butcher": "屠夫", + "entity.minecraft.villager.cartographer": "制图师", + "entity.minecraft.villager.cleric": "牧师", + "entity.minecraft.villager.farmer": "农民", + "entity.minecraft.villager.fisherman": "渔夫", + "entity.minecraft.villager.fletcher": "制箭师", + "entity.minecraft.villager.leatherworker": "皮匠", + "entity.minecraft.villager.librarian": "图书管理员", + "entity.minecraft.villager.mason": "石匠", + "entity.minecraft.villager.nitwit": "傻子", + "entity.minecraft.villager.none": "村民", + "entity.minecraft.villager.shepherd": "牧羊人", + "entity.minecraft.villager.toolsmith": "工具匠", + "entity.minecraft.villager.weaponsmith": "武器匠", + "entity.minecraft.vindicator": "卫道士", + "entity.minecraft.wandering_trader": "流浪商人", + "entity.minecraft.warden": "监守者", + "entity.minecraft.wind_charge": "风弹", + "entity.minecraft.witch": "女巫", + "entity.minecraft.wither": "凋灵", + "entity.minecraft.wither_skeleton": "凋灵骷髅", + "entity.minecraft.wither_skull": "凋灵之首", + "entity.minecraft.wolf": "狼", + "entity.minecraft.zoglin": "僵尸疣猪兽", + "entity.minecraft.zombie": "僵尸", + "entity.minecraft.zombie_horse": "僵尸马", + "entity.minecraft.zombie_nautilus": "僵尸鹦鹉螺", + "entity.minecraft.zombie_villager": "僵尸村民", + "entity.minecraft.zombified_piglin": "僵尸猪灵", + "entity.not_summonable": "无法召唤类型为%s的实体", + "event.minecraft.raid": "袭击", + "event.minecraft.raid.defeat": "失败", + "event.minecraft.raid.defeat.full": "袭击 - 失败", + "event.minecraft.raid.raiders_remaining": "剩余%s名袭击者", + "event.minecraft.raid.victory": "胜利", + "event.minecraft.raid.victory.full": "袭击 - 胜利", + "filled_map.bamboo_camp_map": "竹林营地地图", + "filled_map.bamboo_jungle_abandoned_camp": "竹林废弃营地地图", + "filled_map.birch_forest_abandoned_camp": "桦木森林废弃营地地图", + "filled_map.birch_forest_camp_map": "桦木森林营地地图", + "filled_map.buried_treasure": "藏宝图", + "filled_map.cherry_grove_abandoned_camp": "樱花树林废弃营地地图", + "filled_map.cherry_grove_camp_map": "樱花树林营地地图", + "filled_map.dappled_forest_abandoned_camp": "斑驳森林废弃营地地图", + "filled_map.dappled_forest_camp_map": "斑驳森林营地地图", + "filled_map.explorer_jungle": "丛林探险家地图", + "filled_map.explorer_swamp": "沼泽探险家地图", + "filled_map.flower_forest_abandoned_camp": "繁花森林废弃营地地图", + "filled_map.flower_forest_camp_map": "繁花森林营地地图", + "filled_map.id": "ID #%s", + "filled_map.jungle_temple": "丛林神庙地图", + "filled_map.level": "(缩放等级%s/%s)", + "filled_map.locked": "已锁定", + "filled_map.mansion": "林地探险家地图", + "filled_map.monument": "海洋探险家地图", + "filled_map.pale_garden_abandoned_camp": "苍白之园废弃营地地图", + "filled_map.pale_garden_camp_map": "苍白之园营地地图", + "filled_map.scale": "比例尺1:%s", + "filled_map.swamp_abandoned_camp": "沼泽废弃营地地图", + "filled_map.swamp_camp_map": "沼泽营地地图", + "filled_map.trial_chambers": "试炼探险家地图", + "filled_map.unknown": "未知地图", + "filled_map.village_desert": "沙漠村庄地图", + "filled_map.village_plains": "平原村庄地图", + "filled_map.village_savanna": "热带草原村庄地图", + "filled_map.village_snowy": "雪原村庄地图", + "filled_map.village_taiga": "针叶林村庄地图", + "filled_map.windswept_forest_abandoned_camp": "风袭森林废弃营地地图", + "filled_map.windswept_forest_camp_map": "风袭森林营地地图", + "flat_world_preset.minecraft.bottomless_pit": "无底深渊", + "flat_world_preset.minecraft.classic_flat": "经典平坦", + "flat_world_preset.minecraft.desert": "沙漠", + "flat_world_preset.minecraft.overworld": "主世界", + "flat_world_preset.minecraft.redstone_ready": "红石俱备", + "flat_world_preset.minecraft.snowy_kingdom": "雪之王国", + "flat_world_preset.minecraft.the_void": "虚空", + "flat_world_preset.minecraft.tunnelers_dream": "挖掘工的梦想", + "flat_world_preset.minecraft.water_world": "水世界", + "flat_world_preset.unknown": "???", + "gameMode.adventure": "冒险模式", + "gameMode.changed": "你的游戏模式已被更新为%s", + "gameMode.creative": "创造模式", + "gameMode.hardcore": "极限模式", + "gameMode.spectator": "旁观模式", + "gameMode.survival": "生存模式", + "gamerule.allowEnteringNetherUsingPortals": "允许进入下界", + "gamerule.allowEnteringNetherUsingPortals.description": "控制玩家能否进入下界。", + "gamerule.allowFireTicksAwayFromPlayer": "允许火在远离玩家处蔓延", + "gamerule.allowFireTicksAwayFromPlayer.description": "控制火和熔岩能否在距离任何玩家超过8个区块处蔓延。", + "gamerule.announceAdvancements": "进度通知", + "gamerule.blockExplosionDropDecay": "在方块交互爆炸中,一些方块不会掉落战利品", + "gamerule.blockExplosionDropDecay.description": "在与方块交互引起的爆炸中,部分被破坏方块的掉落物会被炸毁。", + "gamerule.category.chat": "聊天", + "gamerule.category.drops": "掉落", + "gamerule.category.misc": "杂项", + "gamerule.category.mobs": "生物", + "gamerule.category.player": "玩家", + "gamerule.category.spawning": "生成", + "gamerule.category.updates": "世界更新", + "gamerule.commandBlockOutput": "广播命令方块输出", + "gamerule.commandBlocksEnabled": "启用命令方块", + "gamerule.commandModificationBlockLimit": "命令修改方块数量限制", + "gamerule.commandModificationBlockLimit.description": "单条命令(如fill和clone)最多能更改的方块数量。", + "gamerule.disableElytraMovementCheck": "禁用鞘翅移动检测", + "gamerule.disablePlayerMovementCheck": "禁用玩家移动检测", + "gamerule.disableRaids": "禁用袭击", + "gamerule.doDaylightCycle": "游戏内时间流逝", + "gamerule.doEntityDrops": "非生物实体掉落", + "gamerule.doEntityDrops.description": "控制各种矿车(含内容物)、物品展示框、船等的物品掉落。", + "gamerule.doFireTick": "火焰蔓延", + "gamerule.doImmediateRespawn": "立即重生", + "gamerule.doInsomnia": "生成幻翼", + "gamerule.doLimitedCrafting": "合成需要配方", + "gamerule.doLimitedCrafting.description": "若启用,玩家只能使用已解锁的配方合成。", + "gamerule.doMobLoot": "生物战利品掉落", + "gamerule.doMobLoot.description": "控制生物是否掉落资源,包括经验球。", + "gamerule.doMobSpawning": "生成生物", + "gamerule.doMobSpawning.description": "一些实体可能有其特定的规则。", + "gamerule.doPatrolSpawning": "生成灾厄巡逻队", + "gamerule.doTileDrops": "方块掉落", + "gamerule.doTileDrops.description": "控制方块是否掉落资源,包括经验球。", + "gamerule.doTraderSpawning": "生成流浪商人", + "gamerule.doVinesSpread": "藤蔓蔓延", + "gamerule.doVinesSpread.description": "控制藤蔓方块能否随机向相邻的方块蔓延。不会影响其他藤蔓类方块(例如垂泪藤和缠怨藤等)。", + "gamerule.doWardenSpawning": "生成监守者", + "gamerule.doWeatherCycle": "天气更替", + "gamerule.drowningDamage": "溺水伤害", + "gamerule.enableCommandBlocks": "启用命令方块", + "gamerule.enderPearlsVanishOnDeath": "掷出的末影珍珠在死亡时消失", + "gamerule.enderPearlsVanishOnDeath.description": "玩家投掷的末影珍珠是否在玩家死亡时消失。", + "gamerule.entitiesWithPassengersCanUsePortals": "被骑乘的实体能否使用传送门", + "gamerule.entitiesWithPassengersCanUsePortals.description": "允许被骑乘的实体通过下界传送门、末地传送门和末地折跃门传送。", + "gamerule.fallDamage": "摔落伤害", + "gamerule.fireDamage": "火焰伤害", + "gamerule.forgiveDeadPlayers": "宽恕死亡玩家", + "gamerule.forgiveDeadPlayers.description": "愤怒的中立生物将在其目标玩家于附近死亡后息怒。", + "gamerule.freezeDamage": "冰冻伤害", + "gamerule.globalSoundEvents": "全局声音事件", + "gamerule.globalSoundEvents.description": "特定游戏事件(如Boss生成)发生时,声音可在所有地方听见。", + "gamerule.keepInventory": "死亡后保留物品栏", + "gamerule.lavaSourceConversion": "允许流动熔岩转化为熔岩源", + "gamerule.lavaSourceConversion.description": "相邻两面方块为熔岩源的流动熔岩会转化为熔岩源。", + "gamerule.locatorBar": "启用玩家定位栏", + "gamerule.locatorBar.description": "启用后,屏幕上会显示指示玩家方位的定位栏。", + "gamerule.logAdminCommands": "通告管理员命令", + "gamerule.maxCommandChainLength": "命令连锁执行数量限制", + "gamerule.maxCommandChainLength.description": "应用于命令方块链和函数。", + "gamerule.maxCommandForkCount": "命令上下文数量限制", + "gamerule.maxCommandForkCount.description": "“execute as”等命令可以使用的上下文数量最大值。", + "gamerule.maxEntityCramming": "实体挤压上限", + "gamerule.minecartMaxSpeed": "矿车最大速度", + "gamerule.minecartMaxSpeed.description": "矿车在地面上移动的默认最大速度。", + "gamerule.minecraft.elytra_movement_check": "启用鞘翅移动检测", + "gamerule.minecraft.fire_spread_radius_around_player": "火焰蔓延半径", + "gamerule.minecraft.fire_spread_radius_around_player.description": "玩家周围火可以蔓延的方块半径。", + "gamerule.minecraft.player_movement_check": "启用玩家移动检测", + "gamerule.minecraft.raids": "启用袭击", + "gamerule.minecraft.universal_anger.description": "愤怒的中立生物将攻击附近的所有玩家,而不再限于激怒它们的玩家。禁用forgive_dead_players可达到最佳效果。", + "gamerule.mobExplosionDropDecay": "在生物爆炸中,一些方块不会掉落战利品", + "gamerule.mobExplosionDropDecay.description": "在生物引起的爆炸中,部分被破坏方块的掉落物会被炸毁。", + "gamerule.mobGriefing": "允许破坏性生物行为", + "gamerule.naturalRegeneration": "生命值自然恢复", + "gamerule.playersNetherPortalCreativeDelay": "创造模式下玩家在下界传送门中等待的时间", + "gamerule.playersNetherPortalCreativeDelay.description": "创造模式下的玩家通过下界传送门前往其他维度前需要站在其中等待的时间(以刻为单位)。", + "gamerule.playersNetherPortalDefaultDelay": "非创造模式下玩家在下界传送门中等待的时间", + "gamerule.playersNetherPortalDefaultDelay.description": "非创造模式下的玩家通过下界传送门前往其他维度前需要站在其中等待的时间(以刻为单位)。", + "gamerule.playersSleepingPercentage": "入睡占比", + "gamerule.playersSleepingPercentage.description": "跳过夜晚所需的入睡玩家占比。", + "gamerule.projectilesCanBreakBlocks": "弹射物能否破坏方块", + "gamerule.projectilesCanBreakBlocks.description": "控制弹射物能否破坏可被其破坏的方块。", + "gamerule.pvp": "启用PvP", + "gamerule.pvp.description": "控制玩家间能否互相伤害。", + "gamerule.randomTickSpeed": "随机刻速率", + "gamerule.reducedDebugInfo": "简化调试信息", + "gamerule.reducedDebugInfo.description": "限制调试屏幕内容。", + "gamerule.sendCommandFeedback": "发送命令反馈", + "gamerule.showDeathMessages": "显示死亡消息", + "gamerule.snowAccumulationHeight": "积雪厚度", + "gamerule.snowAccumulationHeight.description": "降雪时,地面上的雪最多堆积到此处指定的层数。", + "gamerule.spawnChunkRadius": "出生区块半径", + "gamerule.spawnChunkRadius.description": "主世界出生点周围保持加载的区块数量。", + "gamerule.spawnMonsters": "生成怪物", + "gamerule.spawnMonsters.description": "控制怪物能否自然生成。", + "gamerule.spawnRadius": "重生点半径", + "gamerule.spawnRadius.description": "控制适合玩家生成的出生点周围区域大小。", + "gamerule.spawnerBlocksEnabled": "启用刷怪笼方块", + "gamerule.spectatorsGenerateChunks": "允许旁观者生成地形", + "gamerule.tntExplodes": "允许TNT被点燃并爆炸", + "gamerule.tntExplosionDropDecay": "在TNT爆炸中,一些方块不会掉落战利品", + "gamerule.tntExplosionDropDecay.description": "在TNT引起的爆炸中,部分被破坏方块的掉落物会被炸毁。", + "gamerule.universalAnger": "无差别愤怒", + "gamerule.universalAnger.description": "愤怒的中立生物将攻击附近的所有玩家,而不再限于激怒它们的玩家。禁用“宽恕死亡玩家”可达到最佳效果。", + "gamerule.waterSourceConversion": "允许流动水转化为水源", + "gamerule.waterSourceConversion.description": "相邻两面方块为水源的流动水会转化为水源。", + "generator.custom": "自定义", + "generator.customized": "旧版自定义", + "generator.minecraft.amplified": "放大化", + "generator.minecraft.amplified.info": "注意:仅供娱乐!需要强劲的电脑。", + "generator.minecraft.debug_all_block_states": "调试模式", + "generator.minecraft.flat": "超平坦", + "generator.minecraft.flat_all_dimensions": "全维度平坦", + "generator.minecraft.large_biomes": "巨型生物群系", + "generator.minecraft.normal": "默认", + "generator.minecraft.single_biome_surface": "单一生物群系", + "generator.single_biome_caves": "洞穴", + "generator.single_biome_floating_islands": "浮岛", + "gui.abuseReport.attestation": "提交此举报,即代表你确认你提供的信息已尽可能准确和完整。", + "gui.abuseReport.comments": "留言", + "gui.abuseReport.describe": "提供详情可以帮助我们更严谨地做出决定。", + "gui.abuseReport.discard.content": "如果离开,该举报及留言将不会被保留。\n确定要离开吗?", + "gui.abuseReport.discard.discard": "离开并放弃举报", + "gui.abuseReport.discard.draft": "保存为草稿", + "gui.abuseReport.discard.draft.overwrite": "你已经保存了一份举报草稿。保存新的草稿会永久覆盖它。", + "gui.abuseReport.discard.return": "继续编辑", + "gui.abuseReport.discard.title": "放弃举报和留言?", + "gui.abuseReport.draft.content": "继续编辑现有举报还是放弃并新建另一份举报?", + "gui.abuseReport.draft.discard": "放弃", + "gui.abuseReport.draft.edit": "继续编辑", + "gui.abuseReport.draft.quittotitle.content": "继续编辑还是放弃?", + "gui.abuseReport.draft.quittotitle.content.new": "你有一份未完成的举报将在退出时丢失。继续编辑还是放弃?", + "gui.abuseReport.draft.quittotitle.discard": "退出并放弃举报", + "gui.abuseReport.draft.quittotitle.title": "你有一份聊天举报草稿将在退出时丢失", + "gui.abuseReport.draft.quittotitle.title.new": "放弃举报草稿?", + "gui.abuseReport.draft.quittotitle.tooltip": "退出将永久删除你的草稿。", + "gui.abuseReport.draft.quittotitle.tooltip.chat": "退出将永久删除你的草稿。之后无法再举报这些聊天消息。", + "gui.abuseReport.draft.restart": "放弃并重新举报", + "gui.abuseReport.draft.title": "编辑聊天举报草稿?", + "gui.abuseReport.draft.title.new": "编辑举报草稿?", + "gui.abuseReport.error.title": "发送举报时出现问题", + "gui.abuseReport.message": "你在哪里观察到了不良行为?\n这有助于我们调查你的案例。", + "gui.abuseReport.more_comments": "请描述发生的状况:", + "gui.abuseReport.name.comment_box_label": "请描述举报该名称的理由:", + "gui.abuseReport.name.reporting": "正在举报“%s”。", + "gui.abuseReport.name.title": "举报不当玩家名称", + "gui.abuseReport.observed_what": "你为什么要举报?", + "gui.abuseReport.read_info": "了解举报功能", + "gui.abuseReport.reason.alcohol_tobacco_drugs": "吸毒或饮酒", + "gui.abuseReport.reason.alcohol_tobacco_drugs.description": "有人教唆他人涉毒或教唆未成年人饮酒。", + "gui.abuseReport.reason.child_sexual_exploitation_or_abuse": "对儿童的性剥削或虐待", + "gui.abuseReport.reason.child_sexual_exploitation_or_abuse.description": "有人谈论或以其他方式宣扬涉及儿童的不当行为。", + "gui.abuseReport.reason.defamation_impersonation_false_information": "诽谤", + "gui.abuseReport.reason.defamation_impersonation_false_information.description": "有人以利用或误导他人为目的,正在损害你或他人的名誉,例如散布虚假信息。", + "gui.abuseReport.reason.description": "描述:", + "gui.abuseReport.reason.false_reporting": "不实举报", + "gui.abuseReport.reason.generic": "我想举报该玩家", + "gui.abuseReport.reason.generic.description": "该玩家使我恼怒或做的事情使我反感。", + "gui.abuseReport.reason.harassment_or_bullying": "骚扰或霸凌", + "gui.abuseReport.reason.harassment_or_bullying.description": "有人羞辱、攻击、霸凌你或其他人。这包括在未经允许的情况下不断尝试联系你或其他人,或发布你或其他人的隐私信息。", + "gui.abuseReport.reason.hate_speech": "仇恨言论", + "gui.abuseReport.reason.hate_speech.description": "有人因身份要素(如宗教信仰、种族或性相关)攻击你或其他玩家。", + "gui.abuseReport.reason.imminent_harm": "威胁伤害他人", + "gui.abuseReport.reason.imminent_harm.description": "有人威胁要在现实生活中伤害你或其他人。", + "gui.abuseReport.reason.narration": "%s:%s", + "gui.abuseReport.reason.non_consensual_intimate_imagery": "未经同意发布私密图像", + "gui.abuseReport.reason.non_consensual_intimate_imagery.description": "有人谈论、分享私密或亲密的图像,或以其他方式宣扬有关行为。", + "gui.abuseReport.reason.self_harm_or_suicide": "自残或自杀", + "gui.abuseReport.reason.self_harm_or_suicide.description": "有人威胁要自残或谈论现实生活中的自残行为。", + "gui.abuseReport.reason.sexually_inappropriate": "性相关不当内容", + "gui.abuseReport.reason.sexually_inappropriate.description": "皮肤图案与性行为、性器官和性暴力相关。", + "gui.abuseReport.reason.terrorism_or_violent_extremism": "恐怖主义或暴力极端主义", + "gui.abuseReport.reason.terrorism_or_violent_extremism.description": "有人因政治、宗教、意识形态或其他原因,谈论、宣扬、威胁实施恐怖主义或极端暴力行为。", + "gui.abuseReport.reason.title": "选择举报类型", + "gui.abuseReport.report_sent_msg": "我们已经成功收到你的举报。非常感谢!\n\n我们的团队将尽快进行审核。", + "gui.abuseReport.select_reason": "选择举报类型", + "gui.abuseReport.send": "发送举报", + "gui.abuseReport.send.comment_too_long": "请缩短留言", + "gui.abuseReport.send.error_message": "发送举报时返回了错误:\n“%s”", + "gui.abuseReport.send.generic_error": "发送举报时遇到意外错误。", + "gui.abuseReport.send.http_error": "发送举报时发生了意外的HTTP错误。", + "gui.abuseReport.send.json_error": "发送举报时遇到了格式错误的负载。", + "gui.abuseReport.send.no_reason": "请选择举报类型", + "gui.abuseReport.send.not_attested": "请先阅读上方文本并勾选复选框后,再发送举报", + "gui.abuseReport.send.service_unavailable": "无法使用举报服务。请检查是否已联网,然后重试。", + "gui.abuseReport.sending.title": "发送举报中…", + "gui.abuseReport.sent.title": "举报已发送", + "gui.abuseReport.skin.title": "举报玩家皮肤", + "gui.abuseReport.title": "举报玩家", + "gui.abuseReport.type.chat": "聊天消息", + "gui.abuseReport.type.name": "玩家名称", + "gui.abuseReport.type.skin": "玩家皮肤", + "gui.acknowledge": "了解", + "gui.advancements": "进度", + "gui.all": "全部", + "gui.back": "返回", + "gui.banned.description": "%s\n\n%s\n\n点击以下链接了解更多:%s", + "gui.banned.description.permanent": "你的账户已被永久封禁,无法进行在线游戏或加入Realms。", + "gui.banned.description.reason": "我们最近收到举报称你的账户存在不良行为。我们的监督员已审核你的案件并将其认定为%s,这违反了Minecraft社区准则。", + "gui.banned.description.reason_id": "代码:%s", + "gui.banned.description.reason_id_message": "代码:%s - %s", + "gui.banned.description.temporary": "%s期限结束前,你无法进行在线游戏或加入Realms。", + "gui.banned.description.temporary.duration": "你的账户已被暂时封禁并将于%s后解封。", + "gui.banned.description.unknownreason": "我们最近收到举报称你的账户存在不良行为。我们的监督员已审核你的案件并认定你违反了Minecraft社区准则。", + "gui.banned.name.description": "你当前的名称(“%s”)违反了我们的社区准则。你可以进行单人游戏,但需要更改名称才能进行在线游戏。\n\n通过以下链接了解更多或提交案件审查:%s", + "gui.banned.name.title": "多人游戏中不允许使用当前名称", + "gui.banned.reason.defamation_impersonation_false_information": "假冒或分享信息以利用或误导他人", + "gui.banned.reason.drugs": "推销非法药品", + "gui.banned.reason.extreme_violence_or_gore": "描述现实生活中过度暴力或血腥的场面", + "gui.banned.reason.false_reporting": "过多虚假或不实举报", + "gui.banned.reason.fraud": "欺骗性获取或使用内容", + "gui.banned.reason.generic_violation": "违反社区准则", + "gui.banned.reason.harassment_or_bullying": "有针对性地、有害地使用辱骂性语言", + "gui.banned.reason.hate_speech": "仇恨或歧视言论", + "gui.banned.reason.hate_terrorism_notorious_figure": "推广仇恨群体、恐怖组织或不法分子", + "gui.banned.reason.imminent_harm_to_person_or_property": "意图在现实生活中造成人身或财产伤害", + "gui.banned.reason.nudity_or_pornography": "展示淫秽或色情材料", + "gui.banned.reason.sexually_inappropriate": "性相关话题或内容", + "gui.banned.reason.spam_or_advertising": "滥发消息或广告宣传", + "gui.banned.skin.description": "你当前的皮肤违反了我们的社区准则。你仍然可以使用默认皮肤,或者选择新皮肤进行游戏。\n\n通过以下链接了解更多或提交案件审查:%s", + "gui.banned.skin.title": "当前皮肤不允许使用", + "gui.banned.title.permanent": "账户已被永久封禁", + "gui.banned.title.temporary": "账户已被暂时封禁", + "gui.cancel": "取消", + "gui.chatReport.comments": "留言", + "gui.chatReport.describe": "提供详情可以帮助我们更严谨地做出决定。", + "gui.chatReport.discard.content": "如果离开,该举报及留言将不会被保留。\n确定要离开吗?", + "gui.chatReport.discard.discard": "离开并放弃举报", + "gui.chatReport.discard.draft": "保存为草稿", + "gui.chatReport.discard.return": "继续编辑", + "gui.chatReport.discard.title": "放弃举报和留言?", + "gui.chatReport.draft.content": "继续编辑现有举报还是放弃并新建另一份举报?", + "gui.chatReport.draft.discard": "放弃", + "gui.chatReport.draft.edit": "继续编辑", + "gui.chatReport.draft.quittotitle.content": "继续编辑还是放弃?", + "gui.chatReport.draft.quittotitle.title": "你有一份聊天举报草稿将在退出时丢失", + "gui.chatReport.draft.title": "编辑聊天举报草稿?", + "gui.chatReport.more_comments": "请描述发生的状况:", + "gui.chatReport.observed_what": "你为什么要举报?", + "gui.chatReport.read_info": "了解举报功能", + "gui.chatReport.report_sent_msg": "我们已经成功收到你的举报。非常感谢!\n\n我们的团队会尽快进行审核。", + "gui.chatReport.select_chat": "选择要举报的聊天消息", + "gui.chatReport.select_reason": "选择举报类型", + "gui.chatReport.selected_chat": "已选中%s条要举报的聊天消息", + "gui.chatReport.send": "发送举报", + "gui.chatReport.send.comments_too_long": "请缩短留言", + "gui.chatReport.send.no_reason": "请选择举报类型", + "gui.chatReport.send.no_reported_messages": "请选择至少一条要举报的聊天消息", + "gui.chatReport.send.too_many_messages": "举报中包含的消息过多", + "gui.chatReport.title": "举报玩家聊天", + "gui.chatSelection.context": "所选消息前后的部分消息将会用于提供额外的辅助信息", + "gui.chatSelection.fold": "已隐藏%s条消息", + "gui.chatSelection.heading": "%s %s", + "gui.chatSelection.join": "%s加入了聊天", + "gui.chatSelection.message.narrate": "%1$s在%3$s说:%2$s", + "gui.chatSelection.selected": "已选中%s条消息,共%s条", + "gui.chatSelection.title": "选择要举报的聊天消息", + "gui.continue": "继续", + "gui.copy_link_to_clipboard": "复制链接到剪贴板", + "gui.days": "%s天", + "gui.done": "完成", + "gui.down": "向下", + "gui.entity_tooltip.type": "类型:%s", + "gui.experience.level": "%s", + "gui.fileDropFailure.detail": "拒绝了%s个文件", + "gui.fileDropFailure.title": "添加文件失败", + "gui.friends.accept": "接受", + "gui.friends.accept.tooltip": "接受好友请求。", + "gui.friends.button.loading": "请稍候…", + "gui.friends.button.loading.invite_request_pending": "正在等待确认", + "gui.friends.button.loading.join_request_pending": "正在等待确认", + "gui.friends.cancel_request": "取消请求", + "gui.friends.confirm_title": "确认", + "gui.friends.confirm_unfriend": "你确定要将该玩家从好友列表中移除吗?", + "gui.friends.copy_to_clipboard": "复制到剪贴板", + "gui.friends.decline": "拒绝", + "gui.friends.decline.tooltip": "拒绝好友请求。", + "gui.friends.empty_nickname": "发送好友请求前,请输入有效的玩家档案名称", + "gui.friends.empty_state": "你添加的好友会在此处列出。\n\n使用你的%s管理在线安全。", + "gui.friends.empty_state.link": "Microsoft账户", + "gui.friends.enter_nickname": "输入玩家档案名称", + "gui.friends.error.connection_issue": "好友列表目前无法使用。\n\n请检查网络连接后再试。", + "gui.friends.error.failed.message": "暂时无法发送好友请求", + "gui.friends.error.forbidden.message": "无法执行此好友操作。这可能是账户的隐私设置导致的。", + "gui.friends.error.generic": "出了些问题——当然是说功能本身,好友们当然好着呢。\n\n请稍后再试。", + "gui.friends.error.rateLimited.message": "执行好友列表操作过快。请稍等片刻再试。", + "gui.friends.error.temporary_unavailable": "好友列表目前无法使用。\n\n请稍后再试。", + "gui.friends.error.unauthorized": "提供的令牌无效。请重启游戏及启动器并重试。", + "gui.friends.error.unavailable.message": "好友列表服务当前不可用。请检查你的网络连接,稍后重试。", + "gui.friends.error.upgrade_needed": "请更新游戏以访问好友列表。", + "gui.friends.error.user_may_lack_active_profile": "没有找到使用该档案名称的玩家", + "gui.friends.join": "加入", + "gui.friends.loading_friends": "加载好友列表中…", + "gui.friends.loading_requests": "加载请求中…", + "gui.friends.manage_account_footer": "使用你的%s管理在线安全", + "gui.friends.multiplayer.invite": "邀请", + "gui.friends.multiplayer.invite_accept": "接受", + "gui.friends.multiplayer.invite_reject": "拒绝", + "gui.friends.multiplayer.join_request": "请求加入", + "gui.friends.multiplayer.join_request_accept": "接受", + "gui.friends.multiplayer.join_request_reject": "拒绝", + "gui.friends.my_profile_name": "我的玩家档案名称:", + "gui.friends.my_profile_name.narration": "将玩家档案名称%s复制到剪贴板", + "gui.friends.narration.button.accept": "按下Enter接受来自%s的好友请求", + "gui.friends.narration.button.cancel_request": "按下Enter取消发给%s的好友请求", + "gui.friends.narration.button.decline": "按下Enter拒绝来自%s的好友请求", + "gui.friends.narration.button.player_options": "按下Enter打开%s的玩家选项", + "gui.friends.narration.button.send_request": "按下Enter向%s发送好友请求", + "gui.friends.narration.button.unfriend": "按下Enter从好友列表中移除%s", + "gui.friends.narration.entry.friend": "好友%s", + "gui.friends.narration.entry.incoming": "来自%s的好友请求", + "gui.friends.narration.entry.outgoing": "发给%s的好友请求", + "gui.friends.open": "好友", + "gui.friends.open.narration": "好友列表", + "gui.friends.open.tooltip": "好友", + "gui.friends.pending.disabled": "好友请求已禁用。\n\n你可以在“在线选项”屏幕中启用好友请求。", + "gui.friends.pending.empty": "没有待处理的好友请求", + "gui.friends.pending.received": "已收到", + "gui.friends.pending.search": "搜索…", + "gui.friends.pending.search.no_results": "此处没有玩家使用该档案名称。", + "gui.friends.pending.sent": "已发送", + "gui.friends.player_options": "玩家选项", + "gui.friends.presence.status.offline": "离线", + "gui.friends.presence.status.online": "在线", + "gui.friends.presence.status.playing_hosted_server": "在线(局域网)", + "gui.friends.presence.status.playing_offline": "在线(世界)", + "gui.friends.presence.status.playing_realms": "在线(Realm)", + "gui.friends.presence.status.playing_server": "在线(服务器)", + "gui.friends.requests_count": "请求(%s)", + "gui.friends.search.no_results": "未找到玩家。", + "gui.friends.send_request": "发送好友请求", + "gui.friends.sort": "排序玩家", + "gui.friends.sort.alphabetical": "按字母顺序排序", + "gui.friends.sort.presence": "按在线玩家排序", + "gui.friends.tab_friends": "好友", + "gui.friends.title": "好友", + "gui.friends.toast.friend_added.message": "%s接受了你的好友请求", + "gui.friends.toast.friend_invited.message": "已邀请%s加入你的世界。", + "gui.friends.toast.friend_removed.message": "已将%s从你的好友列表中移除", + "gui.friends.toast.generic_error": "更新好友列表时出现意外错误。请稍后再试。", + "gui.friends.toast.host_invite_expired.message": "发给%s加入此世界的邀请已过期。", + "gui.friends.toast.invite_from_friend.message": "%s邀请你加入世界。请按“%s”键打开好友列表来加入", + "gui.friends.toast.join_invite_expired.message": "加入%s世界的邀请已过期。", + "gui.friends.toast.join_request.message": "%s想要加入你的世界。按下“%s”或通过游戏菜单打开好友列表。", + "gui.friends.toast.request_accepted.message": "已与%s成为好友", + "gui.friends.toast.request_received.message": "%s向你发送了好友请求", + "gui.friends.toast.request_sent.message": "已向%s发送好友请求", + "gui.friends.toast.request_to_join_friend.message": "已请求加入%s的世界", + "gui.friends.toast.requests_accepted_multiple.message": "%s位好友接受了你的好友请求", + "gui.friends.toast.requests_received_multiple.message": "你收到了%s条好友请求", + "gui.friends.unfriend": "移除好友", + "gui.friends.validation.already_friend": "%s已在你的好友列表中", + "gui.friends.validation.already_incoming": "%s已经向你发送了好友请求", + "gui.friends.validation.already_outgoing": "你已经向%s发送过好友请求", + "gui.friends.validation.cannot_add_self": "你无法向自己发送好友请求", + "gui.game_rule.search": "搜索…", + "gui.hours": "%s小时", + "gui.language.search": "搜索…", + "gui.loading": "请稍候…", + "gui.loadingMinecraft": "正在加载Minecraft", + "gui.minutes": "%s分钟", + "gui.multiLineEditBox.character_limit": "%s/%s", + "gui.narrate.button": "%s按钮", + "gui.narrate.editBox": "%s编辑框:%s", + "gui.narrate.slider": "%s滑块", + "gui.narrate.tab": "%s标签页", + "gui.no": "否", + "gui.none": "无", + "gui.ok": "确定", + "gui.open_report_dir": "打开报告目录", + "gui.packSelection.search": "搜索…", + "gui.player_interactions.empty": "此世界对多人游戏开放,但目前还没有其他玩家!", + "gui.player_interactions.empty_blocked": "聊天中没有被屏蔽的玩家\n\n使用你的%s管理被屏蔽的玩家。", + "gui.player_interactions.empty_blocked.link": "Microsoft账户", + "gui.player_interactions.empty_muted": "聊天中没有被屏蔽消息的玩家", + "gui.player_interactions.manage_account_footer": "使用你的%s管理已屏蔽的玩家", + "gui.player_interactions.search": "搜索…", + "gui.player_interactions.search.no_results": "未找到使用此名称的玩家", + "gui.player_interactions.tab_all": "全部(%s)", + "gui.player_interactions.tab_blocked": "已屏蔽", + "gui.player_interactions.tab_muted": "已屏蔽消息", + "gui.player_interactions.title": "其他玩家", + "gui.player_interactions.title.full": "其他玩家 - %s", + "gui.player_options.cancel_request": "取消好友请求", + "gui.player_options.edit_report": "编辑举报", + "gui.player_options.edit_report.tooltip": "继续编辑现有举报或放弃并新建另一份举报。", + "gui.player_options.mute": "屏蔽消息", + "gui.player_options.mute.feedback": "%s发送的聊天消息会被隐藏", + "gui.player_options.mute.tooltip": "隐藏此玩家的聊天消息。", + "gui.player_options.report": "举报", + "gui.player_options.report.tooltip": "创建关于此玩家外观或行为的举报。", + "gui.player_options.report.tooltip.disabled": "举报服务不可用。", + "gui.player_options.send_request": "发送好友请求", + "gui.player_options.status.blocked": "已屏蔽 - %s", + "gui.player_options.status.muted": "已屏蔽消息 - %s", + "gui.player_options.title": "玩家选项", + "gui.player_options.unfriend": "移除好友", + "gui.player_options.unfriend.confirm": "该玩家将从你的好友中移除。", + "gui.player_options.unmute": "取消屏蔽消息", + "gui.player_options.unmute.feedback": "%s的聊天消息将会被显示", + "gui.player_options.unmute.tooltip": "显示此玩家的聊天消息。", + "gui.privacy_link.message": "打开此链接来管理隐私和在线安全设置。", + "gui.privacy_link.title": "更改Xbox设置可能需要一些时间才能生效。\n\n如果这些设置没有及时更新,请重启游戏。", + "gui.proceed": "继续", + "gui.recipebook.moreRecipes": "单击鼠标右键获取更多信息", + "gui.recipebook.next_page": "下一页", + "gui.recipebook.page": "%s/%s", + "gui.recipebook.previous_page": "上一页", + "gui.recipebook.search_hint": "搜索…", + "gui.recipebook.toggleRecipes.all": "显示全部", + "gui.recipebook.toggleRecipes.blastable": "仅显示可冶炼", + "gui.recipebook.toggleRecipes.craftable": "仅显示可合成", + "gui.recipebook.toggleRecipes.smeltable": "仅显示可烧炼", + "gui.recipebook.toggleRecipes.smokable": "仅显示可熏制", + "gui.remove": "移除", + "gui.report_to_server": "向服务器报告", + "gui.selectWorld.search": "搜索…", + "gui.socialInteractions.blocking_hint": "使用Microsoft账户管理", + "gui.socialInteractions.empty_blocked": "未屏蔽任何玩家的聊天消息", + "gui.socialInteractions.empty_hidden": "未隐藏任何玩家的聊天消息", + "gui.socialInteractions.hidden_in_chat": "%s的聊天消息将会被隐藏", + "gui.socialInteractions.hide": "在聊天中隐藏", + "gui.socialInteractions.narration.hide": "隐藏%s发送的消息", + "gui.socialInteractions.narration.report": "举报玩家%s", + "gui.socialInteractions.narration.show": "显示%s发送的消息", + "gui.socialInteractions.report": "举报", + "gui.socialInteractions.search_empty": "未找到使用此名称的玩家", + "gui.socialInteractions.search_hint": "搜索…", + "gui.socialInteractions.server_label.multiple": "%s - %s名玩家", + "gui.socialInteractions.server_label.single": "%s - %s名玩家", + "gui.socialInteractions.show": "在聊天中显示", + "gui.socialInteractions.shown_in_chat": "%s的聊天消息将会被显示", + "gui.socialInteractions.status_blocked": "已屏蔽", + "gui.socialInteractions.status_blocked_offline": "已屏蔽 - 离线", + "gui.socialInteractions.status_hidden": "隐藏", + "gui.socialInteractions.status_hidden_offline": "隐藏 - 离线", + "gui.socialInteractions.status_offline": "离线", + "gui.socialInteractions.tab_all": "全部", + "gui.socialInteractions.tab_blocked": "已屏蔽", + "gui.socialInteractions.tab_hidden": "已隐藏", + "gui.socialInteractions.title": "社交", + "gui.socialInteractions.tooltip.hide": "隐藏消息", + "gui.socialInteractions.tooltip.report": "举报玩家", + "gui.socialInteractions.tooltip.report.chat_disabled_or_blocked": "无法举报该玩家,聊天已被禁用或屏蔽。", + "gui.socialInteractions.tooltip.report.disabled": "举报服务不可用。", + "gui.socialInteractions.tooltip.report.no_messages": "玩家%s没有可举报的消息。", + "gui.socialInteractions.tooltip.report.not_reportable": "无法举报该玩家,因为其聊天消息无法被服务器验证。", + "gui.socialInteractions.tooltip.report.skin_not_reportable": "无法举报该玩家,因为无法验证其皮肤。", + "gui.socialInteractions.tooltip.show": "显示消息", + "gui.stats": "统计信息", + "gui.stats.none_found": "未找到统计信息。", + "gui.toMenu": "返回到服务器列表", + "gui.toRealms": "返回到Realm列表", + "gui.toTitle": "返回到标题屏幕", + "gui.toWorld": "返回到世界列表", + "gui.togglable_slot": "点击以禁用槽位", + "gui.up": "向上", + "gui.waitingForResponse.button.inactive": "返回(%s秒)", + "gui.waitingForResponse.title": "正在等待服务器响应", + "gui.yes": "是", + "hanging_sign.edit": "编辑悬挂式告示牌消息", + "instrument.minecraft.admire_goat_horn": "仰慕", + "instrument.minecraft.call_goat_horn": "呼唤", + "instrument.minecraft.dream_goat_horn": "想象", + "instrument.minecraft.feel_goat_horn": "感受", + "instrument.minecraft.ponder_goat_horn": "沉思", + "instrument.minecraft.seek_goat_horn": "寻觅", + "instrument.minecraft.sing_goat_horn": "歌颂", + "instrument.minecraft.yearn_goat_horn": "憧憬", + "inventory.binSlot": "摧毁物品", + "inventory.hotbarInfo": "用%1$s+%2$s来保存快捷栏", + "inventory.hotbarSaved": "已保存物品快捷栏(用%1$s+%2$s来加载)", + "item.canBreak": "能破坏:", + "item.canPlace": "可以放在:", + "item.canUse.unknown": "未知", + "item.color": "颜色:%s", + "item.components": "%s个组件", + "item.disabled": "已禁用物品", + "item.durability": "耐久度:%s / %s", + "item.dyed": "已染色", + "item.intangible": "不可回收", + "item.minecraft.abandoned_camp_map": "废弃营地地图", + "item.minecraft.abandoned_campsite_map": "废弃营地地图", + "item.minecraft.acacia_boat": "金合欢木船", + "item.minecraft.acacia_chest_boat": "金合欢木运输船", + "item.minecraft.allay_spawn_egg": "悦灵刷怪蛋", + "item.minecraft.amethyst_shard": "紫水晶碎片", + "item.minecraft.ancient_city_map": "远古城市地图", + "item.minecraft.angler_pottery_shard": "垂钓纹样陶片", + "item.minecraft.angler_pottery_sherd": "垂钓纹样陶片", + "item.minecraft.apple": "苹果", + "item.minecraft.archer_pottery_shard": "弓箭纹样陶片", + "item.minecraft.archer_pottery_sherd": "弓箭纹样陶片", + "item.minecraft.armadillo_scute": "犰狳鳞甲", + "item.minecraft.armadillo_spawn_egg": "犰狳刷怪蛋", + "item.minecraft.armor_stand": "盔甲架", + "item.minecraft.arms_up_pottery_shard": "举臂纹样陶片", + "item.minecraft.arms_up_pottery_sherd": "举臂纹样陶片", + "item.minecraft.arrow": "箭", + "item.minecraft.axolotl_bucket": "美西螈桶", + "item.minecraft.axolotl_spawn_egg": "美西螈刷怪蛋", + "item.minecraft.baked_potato": "烤马铃薯", + "item.minecraft.bamboo_chest_raft": "运输竹筏", + "item.minecraft.bamboo_raft": "竹筏", + "item.minecraft.bat_spawn_egg": "蝙蝠刷怪蛋", + "item.minecraft.bee_spawn_egg": "蜜蜂刷怪蛋", + "item.minecraft.beef": "生牛肉", + "item.minecraft.beetroot": "甜菜根", + "item.minecraft.beetroot_seeds": "甜菜种子", + "item.minecraft.beetroot_soup": "甜菜汤", + "item.minecraft.birch_boat": "白桦木船", + "item.minecraft.birch_chest_boat": "白桦木运输船", + "item.minecraft.black_bundle": "黑色收纳袋", + "item.minecraft.black_cushion": "黑色坐垫", + "item.minecraft.black_dye": "黑色染料", + "item.minecraft.black_harness": "黑色挽具", + "item.minecraft.blade_pottery_shard": "利刃纹样陶片", + "item.minecraft.blade_pottery_sherd": "利刃纹样陶片", + "item.minecraft.blaze_powder": "烈焰粉", + "item.minecraft.blaze_rod": "烈焰棒", + "item.minecraft.blaze_spawn_egg": "烈焰人刷怪蛋", + "item.minecraft.blue_bundle": "蓝色收纳袋", + "item.minecraft.blue_cushion": "蓝色坐垫", + "item.minecraft.blue_dye": "蓝色染料", + "item.minecraft.blue_egg": "蓝色鸡蛋", + "item.minecraft.blue_harness": "蓝色挽具", + "item.minecraft.bogged_spawn_egg": "沼骸刷怪蛋", + "item.minecraft.bolt_armor_trim_smithing_template": "锻造模板", + "item.minecraft.bolt_armor_trim_smithing_template.new": "镶铆盔甲纹饰", + "item.minecraft.bone": "骨头", + "item.minecraft.bone_meal": "骨粉", + "item.minecraft.book": "书", + "item.minecraft.bordure_indented_banner_pattern": "波纹边旗帜图案", + "item.minecraft.bow": "弓", + "item.minecraft.bowl": "碗", + "item.minecraft.bread": "面包", + "item.minecraft.breeze_rod": "旋风棒", + "item.minecraft.breeze_spawn_egg": "旋风人刷怪蛋", + "item.minecraft.brewer_pottery_shard": "佳酿纹样陶片", + "item.minecraft.brewer_pottery_sherd": "佳酿纹样陶片", + "item.minecraft.brewing_stand": "酿造台", + "item.minecraft.brick": "红砖", + "item.minecraft.brown_bundle": "棕色收纳袋", + "item.minecraft.brown_cushion": "棕色坐垫", + "item.minecraft.brown_dye": "棕色染料", + "item.minecraft.brown_egg": "棕色鸡蛋", + "item.minecraft.brown_harness": "棕色挽具", + "item.minecraft.brush": "刷子", + "item.minecraft.bucket": "铁桶", + "item.minecraft.bundle": "收纳袋", + "item.minecraft.bundle.empty": "空", + "item.minecraft.bundle.empty.description": "可容纳一组混合的物品", + "item.minecraft.bundle.full": "满", + "item.minecraft.bundle.fullness": "%s/%s", + "item.minecraft.buried_ancient_city_map": "埋藏的远古城市地图", + "item.minecraft.buried_mineshaft_map": "埋藏的废弃矿井地图", + "item.minecraft.buried_treasure_map": "藏宝图", + "item.minecraft.buried_trial_chambers_map": "埋藏的试炼密室地图", + "item.minecraft.burn_pottery_shard": "烈焰纹样陶片", + "item.minecraft.burn_pottery_sherd": "烈焰纹样陶片", + "item.minecraft.camel_husk_spawn_egg": "骆驼尸壳刷怪蛋", + "item.minecraft.camel_spawn_egg": "骆驼刷怪蛋", + "item.minecraft.carrot": "胡萝卜", + "item.minecraft.carrot_on_a_stick": "胡萝卜钓竿", + "item.minecraft.cat_spawn_egg": "猫刷怪蛋", + "item.minecraft.cauldron": "炼药锅", + "item.minecraft.cave_spider_spawn_egg": "洞穴蜘蛛刷怪蛋", + "item.minecraft.chainmail_boots": "锁链靴子", + "item.minecraft.chainmail_chestplate": "锁链胸甲", + "item.minecraft.chainmail_helmet": "锁链头盔", + "item.minecraft.chainmail_leggings": "锁链护腿", + "item.minecraft.charcoal": "木炭", + "item.minecraft.cherry_boat": "樱花木船", + "item.minecraft.cherry_chest_boat": "樱花木运输船", + "item.minecraft.chest_minecart": "运输矿车", + "item.minecraft.chicken": "生鸡肉", + "item.minecraft.chicken_spawn_egg": "鸡刷怪蛋", + "item.minecraft.chorus_fruit": "紫颂果", + "item.minecraft.clay_ball": "黏土球", + "item.minecraft.clock": "时钟", + "item.minecraft.coal": "煤炭", + "item.minecraft.coast_armor_trim_smithing_template": "锻造模板", + "item.minecraft.coast_armor_trim_smithing_template.new": "海岸盔甲纹饰", + "item.minecraft.cocoa_beans": "可可豆", + "item.minecraft.cod": "生鳕鱼", + "item.minecraft.cod_bucket": "鳕鱼桶", + "item.minecraft.cod_spawn_egg": "鳕鱼刷怪蛋", + "item.minecraft.command_block_minecart": "命令方块矿车", + "item.minecraft.compass": "指南针", + "item.minecraft.cooked_beef": "牛排", + "item.minecraft.cooked_chicken": "熟鸡肉", + "item.minecraft.cooked_cod": "熟鳕鱼", + "item.minecraft.cooked_mutton": "熟羊肉", + "item.minecraft.cooked_porkchop": "熟猪排", + "item.minecraft.cooked_rabbit": "熟兔肉", + "item.minecraft.cooked_salmon": "熟鲑鱼", + "item.minecraft.cookie": "曲奇", + "item.minecraft.copper_axe": "铜斧", + "item.minecraft.copper_boots": "铜靴子", + "item.minecraft.copper_chestplate": "铜胸甲", + "item.minecraft.copper_golem_spawn_egg": "铜傀儡刷怪蛋", + "item.minecraft.copper_helmet": "铜头盔", + "item.minecraft.copper_hoe": "铜锄", + "item.minecraft.copper_horse_armor": "铜马铠", + "item.minecraft.copper_ingot": "铜锭", + "item.minecraft.copper_leggings": "铜护腿", + "item.minecraft.copper_nautilus_armor": "铜鹦鹉螺铠", + "item.minecraft.copper_nugget": "铜粒", + "item.minecraft.copper_pickaxe": "铜镐", + "item.minecraft.copper_shovel": "铜锹", + "item.minecraft.copper_spear": "铜矛", + "item.minecraft.copper_sword": "铜剑", + "item.minecraft.cow_spawn_egg": "牛刷怪蛋", + "item.minecraft.creaking_spawn_egg": "嘎枝刷怪蛋", + "item.minecraft.creeper_banner_pattern": "旗帜图案", + "item.minecraft.creeper_banner_pattern.desc": "苦力怕盾徽", + "item.minecraft.creeper_banner_pattern.new": "苦力怕盾徽旗帜图案", + "item.minecraft.creeper_spawn_egg": "苦力怕刷怪蛋", + "item.minecraft.crossbow": "弩", + "item.minecraft.crossbow.projectile": "弹射物:", + "item.minecraft.crossbow.projectile.multiple": "弹射物:%s × %s", + "item.minecraft.crossbow.projectile.single": "弹射物:%s", + "item.minecraft.cyan_bundle": "青色收纳袋", + "item.minecraft.cyan_cushion": "青色坐垫", + "item.minecraft.cyan_dye": "青色染料", + "item.minecraft.cyan_harness": "青色挽具", + "item.minecraft.danger_pottery_shard": "危机纹样陶片", + "item.minecraft.danger_pottery_sherd": "危机纹样陶片", + "item.minecraft.dark_oak_boat": "深色橡木船", + "item.minecraft.dark_oak_chest_boat": "深色橡木运输船", + "item.minecraft.debug_stick": "调试棒", + "item.minecraft.debug_stick.empty": "%s不具备属性", + "item.minecraft.debug_stick.select": "已选择“%s”(%s)", + "item.minecraft.debug_stick.update": "“%s”设为%s", + "item.minecraft.desert_pyramid_map": "沙漠神殿地图", + "item.minecraft.desert_village_map": "沙漠村庄地图", + "item.minecraft.diamond": "钻石", + "item.minecraft.diamond_axe": "钻石斧", + "item.minecraft.diamond_boots": "钻石靴子", + "item.minecraft.diamond_chestplate": "钻石胸甲", + "item.minecraft.diamond_helmet": "钻石头盔", + "item.minecraft.diamond_hoe": "钻石锄", + "item.minecraft.diamond_horse_armor": "钻石马铠", + "item.minecraft.diamond_leggings": "钻石护腿", + "item.minecraft.diamond_nautilus_armor": "钻石鹦鹉螺铠", + "item.minecraft.diamond_pickaxe": "钻石镐", + "item.minecraft.diamond_shovel": "钻石锹", + "item.minecraft.diamond_spear": "钻石矛", + "item.minecraft.diamond_sword": "钻石剑", + "item.minecraft.disc_fragment_5": "唱片残片", + "item.minecraft.disc_fragment_5.desc": "音乐唱片 - 5", + "item.minecraft.dolphin_spawn_egg": "海豚刷怪蛋", + "item.minecraft.donkey_spawn_egg": "驴刷怪蛋", + "item.minecraft.dragon_breath": "龙息", + "item.minecraft.dried_kelp": "干海带", + "item.minecraft.drowned_spawn_egg": "溺尸刷怪蛋", + "item.minecraft.dune_armor_trim_smithing_template": "锻造模板", + "item.minecraft.dune_armor_trim_smithing_template.new": "沙丘盔甲纹饰", + "item.minecraft.echo_shard": "回响碎片", + "item.minecraft.egg": "鸡蛋", + "item.minecraft.elder_guardian_spawn_egg": "远古守卫者刷怪蛋", + "item.minecraft.elytra": "鞘翅", + "item.minecraft.emerald": "绿宝石", + "item.minecraft.enchanted_book": "附魔书", + "item.minecraft.enchanted_golden_apple": "附魔金苹果", + "item.minecraft.end_crystal": "末地水晶", + "item.minecraft.ender_dragon_spawn_egg": "末影龙刷怪蛋", + "item.minecraft.ender_eye": "末影之眼", + "item.minecraft.ender_pearl": "末影珍珠", + "item.minecraft.enderman_spawn_egg": "末影人刷怪蛋", + "item.minecraft.endermite_spawn_egg": "末影螨刷怪蛋", + "item.minecraft.evoker_spawn_egg": "唤魔者刷怪蛋", + "item.minecraft.experience_bottle": "附魔之瓶", + "item.minecraft.explorer_pottery_shard": "探险纹样陶片", + "item.minecraft.explorer_pottery_sherd": "探险纹样陶片", + "item.minecraft.eye_armor_trim_smithing_template": "锻造模板", + "item.minecraft.eye_armor_trim_smithing_template.new": "眼眸盔甲纹饰", + "item.minecraft.feather": "羽毛", + "item.minecraft.fermented_spider_eye": "发酵蛛眼", + "item.minecraft.field_masoned_banner_pattern": "砖纹旗帜图案", + "item.minecraft.filled_map": "地图", + "item.minecraft.fire_charge": "火焰弹", + "item.minecraft.firework_rocket": "烟花火箭", + "item.minecraft.firework_rocket.flight": "飞行时间:", + "item.minecraft.firework_rocket.flight_duration": "飞行时间:%s", + "item.minecraft.firework_rocket.multiple_stars": "%s × %s", + "item.minecraft.firework_rocket.single_star": "%s", + "item.minecraft.firework_star": "烟火之星", + "item.minecraft.firework_star.black": "黑色", + "item.minecraft.firework_star.blue": "蓝色", + "item.minecraft.firework_star.brown": "棕色", + "item.minecraft.firework_star.custom_color": "自定义", + "item.minecraft.firework_star.cyan": "青色", + "item.minecraft.firework_star.fade_to": "淡化至", + "item.minecraft.firework_star.flicker": "闪烁", + "item.minecraft.firework_star.gray": "灰色", + "item.minecraft.firework_star.green": "绿色", + "item.minecraft.firework_star.light_blue": "淡蓝色", + "item.minecraft.firework_star.light_gray": "淡灰色", + "item.minecraft.firework_star.lime": "黄绿色", + "item.minecraft.firework_star.magenta": "品红色", + "item.minecraft.firework_star.orange": "橙色", + "item.minecraft.firework_star.pink": "粉红色", + "item.minecraft.firework_star.purple": "紫色", + "item.minecraft.firework_star.red": "红色", + "item.minecraft.firework_star.shape": "未知形状", + "item.minecraft.firework_star.shape.burst": "喷发状", + "item.minecraft.firework_star.shape.creeper": "苦力怕状", + "item.minecraft.firework_star.shape.large_ball": "大型球状", + "item.minecraft.firework_star.shape.small_ball": "小型球状", + "item.minecraft.firework_star.shape.star": "星形", + "item.minecraft.firework_star.trail": "踪迹", + "item.minecraft.firework_star.white": "白色", + "item.minecraft.firework_star.yellow": "黄色", + "item.minecraft.fishing_rod": "钓鱼竿", + "item.minecraft.flint": "燧石", + "item.minecraft.flint_and_steel": "打火石", + "item.minecraft.flow_armor_trim_smithing_template": "锻造模板", + "item.minecraft.flow_armor_trim_smithing_template.new": "涡流盔甲纹饰", + "item.minecraft.flow_banner_pattern": "旗帜图案", + "item.minecraft.flow_banner_pattern.desc": "涡流", + "item.minecraft.flow_banner_pattern.new": "涡流旗帜图案", + "item.minecraft.flow_pottery_sherd": "涡流纹样陶片", + "item.minecraft.flower_banner_pattern": "旗帜图案", + "item.minecraft.flower_banner_pattern.desc": "花朵盾徽", + "item.minecraft.flower_banner_pattern.new": "花朵盾徽旗帜图案", + "item.minecraft.flower_pot": "花盆", + "item.minecraft.fox_spawn_egg": "狐狸刷怪蛋", + "item.minecraft.friend_pottery_shard": "挚友纹样陶片", + "item.minecraft.friend_pottery_sherd": "挚友纹样陶片", + "item.minecraft.frog_spawn_egg": "青蛙刷怪蛋", + "item.minecraft.frostbite_spawn_egg": "冻尸刷怪蛋", + "item.minecraft.furnace_minecart": "动力矿车", + "item.minecraft.ghast_spawn_egg": "恶魂刷怪蛋", + "item.minecraft.ghast_tear": "恶魂之泪", + "item.minecraft.glass_bottle": "玻璃瓶", + "item.minecraft.glistering_melon_slice": "闪烁的西瓜片", + "item.minecraft.globe_banner_pattern": "旗帜图案", + "item.minecraft.globe_banner_pattern.desc": "地球", + "item.minecraft.globe_banner_pattern.new": "地球旗帜图案", + "item.minecraft.glow_berries": "发光浆果", + "item.minecraft.glow_ink_sac": "荧光墨囊", + "item.minecraft.glow_item_frame": "荧光物品展示框", + "item.minecraft.glow_squid_spawn_egg": "发光鱿鱼刷怪蛋", + "item.minecraft.glowstone_dust": "荧石粉", + "item.minecraft.goat_horn": "山羊角", + "item.minecraft.goat_spawn_egg": "山羊刷怪蛋", + "item.minecraft.gold_ingot": "金锭", + "item.minecraft.gold_nugget": "金粒", + "item.minecraft.golden_apple": "金苹果", + "item.minecraft.golden_axe": "金斧", + "item.minecraft.golden_boots": "金靴子", + "item.minecraft.golden_carrot": "金胡萝卜", + "item.minecraft.golden_chestplate": "金胸甲", + "item.minecraft.golden_helmet": "金头盔", + "item.minecraft.golden_hoe": "金锄", + "item.minecraft.golden_horse_armor": "金马铠", + "item.minecraft.golden_leggings": "金护腿", + "item.minecraft.golden_nautilus_armor": "金鹦鹉螺铠", + "item.minecraft.golden_pickaxe": "金镐", + "item.minecraft.golden_shovel": "金锹", + "item.minecraft.golden_spear": "金矛", + "item.minecraft.golden_sword": "金剑", + "item.minecraft.gray_bundle": "灰色收纳袋", + "item.minecraft.gray_cushion": "灰色坐垫", + "item.minecraft.gray_dye": "灰色染料", + "item.minecraft.gray_harness": "灰色挽具", + "item.minecraft.green_bundle": "绿色收纳袋", + "item.minecraft.green_cushion": "绿色坐垫", + "item.minecraft.green_dye": "绿色染料", + "item.minecraft.green_harness": "绿色挽具", + "item.minecraft.guardian_spawn_egg": "守卫者刷怪蛋", + "item.minecraft.gunpowder": "火药", + "item.minecraft.guster_banner_pattern": "旗帜图案", + "item.minecraft.guster_banner_pattern.desc": "旋风", + "item.minecraft.guster_banner_pattern.new": "旋风旗帜图案", + "item.minecraft.guster_pottery_sherd": "旋风纹样陶片", + "item.minecraft.happy_ghast_spawn_egg": "快乐恶魂刷怪蛋", + "item.minecraft.harness": "挽具", + "item.minecraft.heart_of_the_sea": "海洋之心", + "item.minecraft.heart_pottery_shard": "爱心纹样陶片", + "item.minecraft.heart_pottery_sherd": "爱心纹样陶片", + "item.minecraft.heartbreak_pottery_shard": "心碎纹样陶片", + "item.minecraft.heartbreak_pottery_sherd": "心碎纹样陶片", + "item.minecraft.hoglin_spawn_egg": "疣猪兽刷怪蛋", + "item.minecraft.honey_bottle": "蜂蜜瓶", + "item.minecraft.honeycomb": "蜜脾", + "item.minecraft.hopper_minecart": "漏斗矿车", + "item.minecraft.horse_spawn_egg": "马刷怪蛋", + "item.minecraft.host_armor_trim_smithing_template": "锻造模板", + "item.minecraft.host_armor_trim_smithing_template.new": "雇主盔甲纹饰", + "item.minecraft.howl_pottery_shard": "狼嚎纹样陶片", + "item.minecraft.howl_pottery_sherd": "狼嚎纹样陶片", + "item.minecraft.husk_spawn_egg": "尸壳刷怪蛋", + "item.minecraft.ice_ball": "冰球", + "item.minecraft.ink_sac": "墨囊", + "item.minecraft.iron_axe": "铁斧", + "item.minecraft.iron_boots": "铁靴子", + "item.minecraft.iron_chestplate": "铁胸甲", + "item.minecraft.iron_golem_spawn_egg": "铁傀儡刷怪蛋", + "item.minecraft.iron_helmet": "铁头盔", + "item.minecraft.iron_hoe": "铁锄", + "item.minecraft.iron_horse_armor": "铁马铠", + "item.minecraft.iron_ingot": "铁锭", + "item.minecraft.iron_leggings": "铁护腿", + "item.minecraft.iron_nautilus_armor": "铁鹦鹉螺铠", + "item.minecraft.iron_nugget": "铁粒", + "item.minecraft.iron_pickaxe": "铁镐", + "item.minecraft.iron_shovel": "铁锹", + "item.minecraft.iron_spear": "铁矛", + "item.minecraft.iron_sword": "铁剑", + "item.minecraft.item_frame": "物品展示框", + "item.minecraft.jungle_boat": "丛林木船", + "item.minecraft.jungle_chest_boat": "丛林木运输船", + "item.minecraft.jungle_explorer_map": "丛林探险家地图", + "item.minecraft.jungle_pyramid_map": "丛林神庙地图", + "item.minecraft.knowledge_book": "知识之书", + "item.minecraft.lapis_lazuli": "青金石", + "item.minecraft.lava_bucket": "熔岩桶", + "item.minecraft.lead": "拴绳", + "item.minecraft.leather": "皮革", + "item.minecraft.leather_boots": "皮革靴子", + "item.minecraft.leather_chestplate": "皮革外套", + "item.minecraft.leather_helmet": "皮革帽子", + "item.minecraft.leather_horse_armor": "皮革马铠", + "item.minecraft.leather_leggings": "皮革裤子", + "item.minecraft.light_blue_bundle": "淡蓝色收纳袋", + "item.minecraft.light_blue_cushion": "淡蓝色坐垫", + "item.minecraft.light_blue_dye": "淡蓝色染料", + "item.minecraft.light_blue_harness": "淡蓝色挽具", + "item.minecraft.light_gray_bundle": "淡灰色收纳袋", + "item.minecraft.light_gray_cushion": "淡灰色坐垫", + "item.minecraft.light_gray_dye": "淡灰色染料", + "item.minecraft.light_gray_harness": "淡灰色挽具", + "item.minecraft.lime_bundle": "黄绿色收纳袋", + "item.minecraft.lime_cushion": "黄绿色坐垫", + "item.minecraft.lime_dye": "黄绿色染料", + "item.minecraft.lime_harness": "黄绿色挽具", + "item.minecraft.lingering_potion": "滞留药水", + "item.minecraft.lingering_potion.effect.awkward": "滞留型粗制的药水", + "item.minecraft.lingering_potion.effect.empty": "不可合成的滞留型药水", + "item.minecraft.lingering_potion.effect.fire_resistance": "滞留型抗火药水", + "item.minecraft.lingering_potion.effect.freezing": "滞留型冰冻药水", + "item.minecraft.lingering_potion.effect.harming": "滞留型伤害药水", + "item.minecraft.lingering_potion.effect.healing": "滞留型治疗药水", + "item.minecraft.lingering_potion.effect.infested": "滞留型虫蚀药水", + "item.minecraft.lingering_potion.effect.invisibility": "滞留型隐身药水", + "item.minecraft.lingering_potion.effect.leaping": "滞留型跳跃药水", + "item.minecraft.lingering_potion.effect.levitation": "滞留型飘浮药水", + "item.minecraft.lingering_potion.effect.luck": "滞留型幸运药水", + "item.minecraft.lingering_potion.effect.mundane": "滞留型平凡的药水", + "item.minecraft.lingering_potion.effect.night_vision": "滞留型夜视药水", + "item.minecraft.lingering_potion.effect.oozing": "滞留型渗浆药水", + "item.minecraft.lingering_potion.effect.poison": "滞留型剧毒药水", + "item.minecraft.lingering_potion.effect.regeneration": "滞留型再生药水", + "item.minecraft.lingering_potion.effect.slow_falling": "滞留型缓降药水", + "item.minecraft.lingering_potion.effect.slowness": "滞留型迟缓药水", + "item.minecraft.lingering_potion.effect.strength": "滞留型力量药水", + "item.minecraft.lingering_potion.effect.swiftness": "滞留型迅捷药水", + "item.minecraft.lingering_potion.effect.thick": "滞留型浓稠的药水", + "item.minecraft.lingering_potion.effect.turtle_master": "滞留型神龟药水", + "item.minecraft.lingering_potion.effect.water": "滞留型水瓶", + "item.minecraft.lingering_potion.effect.water_breathing": "滞留型水肺药水", + "item.minecraft.lingering_potion.effect.weakness": "滞留型虚弱药水", + "item.minecraft.lingering_potion.effect.weaving": "滞留型盘丝药水", + "item.minecraft.lingering_potion.effect.wind_charged": "滞留型蓄风药水", + "item.minecraft.llama_spawn_egg": "羊驼刷怪蛋", + "item.minecraft.lodestone_compass": "磁石指针", + "item.minecraft.mace": "重锤", + "item.minecraft.magenta_bundle": "品红色收纳袋", + "item.minecraft.magenta_cushion": "品红色坐垫", + "item.minecraft.magenta_dye": "品红色染料", + "item.minecraft.magenta_harness": "品红色挽具", + "item.minecraft.magma_cream": "岩浆膏", + "item.minecraft.magma_cube_spawn_egg": "岩浆怪刷怪蛋", + "item.minecraft.mangrove_boat": "红树木船", + "item.minecraft.mangrove_chest_boat": "红树木运输船", + "item.minecraft.map": "空地图", + "item.minecraft.melon_seeds": "西瓜种子", + "item.minecraft.melon_slice": "西瓜片", + "item.minecraft.milk_bucket": "奶桶", + "item.minecraft.minecart": "矿车", + "item.minecraft.miner_pottery_shard": "采矿纹样陶片", + "item.minecraft.miner_pottery_sherd": "采矿纹样陶片", + "item.minecraft.mineshaft_map": "废弃矿井地图", + "item.minecraft.mojang_banner_pattern": "旗帜图案", + "item.minecraft.mojang_banner_pattern.desc": "Mojang徽标", + "item.minecraft.mojang_banner_pattern.new": "Mojang徽标旗帜图案", + "item.minecraft.mooshroom_spawn_egg": "哞菇刷怪蛋", + "item.minecraft.mourner_pottery_shard": "悲恸纹样陶片", + "item.minecraft.mourner_pottery_sherd": "悲恸纹样陶片", + "item.minecraft.mule_spawn_egg": "骡刷怪蛋", + "item.minecraft.mushroom_stew": "蘑菇煲", + "item.minecraft.music_disc_11": "音乐唱片", + "item.minecraft.music_disc_11.desc": "C418 - 11", + "item.minecraft.music_disc_13": "音乐唱片", + "item.minecraft.music_disc_13.desc": "C418 - 13", + "item.minecraft.music_disc_5": "音乐唱片", + "item.minecraft.music_disc_5.desc": "Samuel Åberg - 5", + "item.minecraft.music_disc_blocks": "音乐唱片", + "item.minecraft.music_disc_blocks.desc": "C418 - blocks", + "item.minecraft.music_disc_bounce": "音乐唱片", + "item.minecraft.music_disc_bounce.desc": "fingerspit - Bounce", + "item.minecraft.music_disc_cat": "音乐唱片", + "item.minecraft.music_disc_cat.desc": "C418 - cat", + "item.minecraft.music_disc_chirp": "音乐唱片", + "item.minecraft.music_disc_chirp.desc": "C418 - chirp", + "item.minecraft.music_disc_creator": "音乐唱片", + "item.minecraft.music_disc_creator.desc": "Lena Raine - Creator", + "item.minecraft.music_disc_creator_music_box": "音乐唱片", + "item.minecraft.music_disc_creator_music_box.desc": "Lena Raine - Creator(八音盒)", + "item.minecraft.music_disc_far": "音乐唱片", + "item.minecraft.music_disc_far.desc": "C418 - far", + "item.minecraft.music_disc_lava_chicken": "音乐唱片", + "item.minecraft.music_disc_lava_chicken.desc": "Hyper Potions - Lava Chicken", + "item.minecraft.music_disc_mall": "音乐唱片", + "item.minecraft.music_disc_mall.desc": "C418 - mall", + "item.minecraft.music_disc_mellohi": "音乐唱片", + "item.minecraft.music_disc_mellohi.desc": "C418 - mellohi", + "item.minecraft.music_disc_otherside": "音乐唱片", + "item.minecraft.music_disc_otherside.desc": "Lena Raine - otherside", + "item.minecraft.music_disc_pigstep": "音乐唱片", + "item.minecraft.music_disc_pigstep.desc": "Lena Raine - Pigstep", + "item.minecraft.music_disc_precipice": "音乐唱片", + "item.minecraft.music_disc_precipice.desc": "Aaron Cherof - Precipice", + "item.minecraft.music_disc_relic": "音乐唱片", + "item.minecraft.music_disc_relic.desc": "Aaron Cherof - Relic", + "item.minecraft.music_disc_stal": "音乐唱片", + "item.minecraft.music_disc_stal.desc": "C418 - stal", + "item.minecraft.music_disc_strad": "音乐唱片", + "item.minecraft.music_disc_strad.desc": "C418 - strad", + "item.minecraft.music_disc_tears": "音乐唱片", + "item.minecraft.music_disc_tears.desc": "Amos Roddy - Tears", + "item.minecraft.music_disc_wait": "音乐唱片", + "item.minecraft.music_disc_wait.desc": "C418 - wait", + "item.minecraft.music_disc_ward": "音乐唱片", + "item.minecraft.music_disc_ward.desc": "C418 - ward", + "item.minecraft.mutton": "生羊肉", + "item.minecraft.name_tag": "命名牌", + "item.minecraft.nautilus_shell": "鹦鹉螺壳", + "item.minecraft.nautilus_spawn_egg": "鹦鹉螺刷怪蛋", + "item.minecraft.nether_brick": "下界砖", + "item.minecraft.nether_star": "下界之星", + "item.minecraft.nether_wart": "下界疣", + "item.minecraft.netherite_axe": "下界合金斧", + "item.minecraft.netherite_boots": "下界合金靴子", + "item.minecraft.netherite_chestplate": "下界合金胸甲", + "item.minecraft.netherite_helmet": "下界合金头盔", + "item.minecraft.netherite_hoe": "下界合金锄", + "item.minecraft.netherite_horse_armor": "下界合金马铠", + "item.minecraft.netherite_ingot": "下界合金锭", + "item.minecraft.netherite_leggings": "下界合金护腿", + "item.minecraft.netherite_nautilus_armor": "下界合金鹦鹉螺铠", + "item.minecraft.netherite_pickaxe": "下界合金镐", + "item.minecraft.netherite_scrap": "下界合金碎片", + "item.minecraft.netherite_shovel": "下界合金锹", + "item.minecraft.netherite_spear": "下界合金矛", + "item.minecraft.netherite_sword": "下界合金剑", + "item.minecraft.netherite_upgrade_smithing_template": "锻造模板", + "item.minecraft.netherite_upgrade_smithing_template.new": "下界合金升级", + "item.minecraft.oak_boat": "橡木船", + "item.minecraft.oak_chest_boat": "橡木运输船", + "item.minecraft.ocean_explorer_map": "海洋探险家地图", + "item.minecraft.ocean_monument_map": "海底神殿地图", + "item.minecraft.ocelot_spawn_egg": "豹猫刷怪蛋", + "item.minecraft.ominous_bottle": "不祥之瓶", + "item.minecraft.ominous_trial_key": "不祥试炼钥匙", + "item.minecraft.orange_bundle": "橙色收纳袋", + "item.minecraft.orange_cushion": "橙色坐垫", + "item.minecraft.orange_dye": "橙色染料", + "item.minecraft.orange_harness": "橙色挽具", + "item.minecraft.painting": "画", + "item.minecraft.pale_oak_boat": "苍白橡木船", + "item.minecraft.pale_oak_chest_boat": "苍白橡木运输船", + "item.minecraft.panda_spawn_egg": "熊猫刷怪蛋", + "item.minecraft.paper": "纸", + "item.minecraft.parched_spawn_egg": "焦骸刷怪蛋", + "item.minecraft.parrot_spawn_egg": "鹦鹉刷怪蛋", + "item.minecraft.phantom_membrane": "幻翼膜", + "item.minecraft.phantom_spawn_egg": "幻翼刷怪蛋", + "item.minecraft.pig_spawn_egg": "猪刷怪蛋", + "item.minecraft.piglin_banner_pattern": "旗帜图案", + "item.minecraft.piglin_banner_pattern.desc": "猪鼻", + "item.minecraft.piglin_banner_pattern.new": "猪鼻旗帜图案", + "item.minecraft.piglin_brute_spawn_egg": "猪灵蛮兵刷怪蛋", + "item.minecraft.piglin_spawn_egg": "猪灵刷怪蛋", + "item.minecraft.pillager_spawn_egg": "掠夺者刷怪蛋", + "item.minecraft.pink_bundle": "粉红色收纳袋", + "item.minecraft.pink_cushion": "粉红色坐垫", + "item.minecraft.pink_dye": "粉红色染料", + "item.minecraft.pink_harness": "粉红色挽具", + "item.minecraft.pitcher_plant": "瓶子草", + "item.minecraft.pitcher_pod": "瓶子草荚果", + "item.minecraft.plains_village_map": "平原村庄地图", + "item.minecraft.plenty_pottery_shard": "富饶纹样陶片", + "item.minecraft.plenty_pottery_sherd": "富饶纹样陶片", + "item.minecraft.poisonous_potato": "毒马铃薯", + "item.minecraft.polar_bear_spawn_egg": "北极熊刷怪蛋", + "item.minecraft.poplar_boat": "杨木船", + "item.minecraft.poplar_chest_boat": "杨木运输船", + "item.minecraft.popped_chorus_fruit": "爆裂紫颂果", + "item.minecraft.porkchop": "生猪排", + "item.minecraft.potato": "马铃薯", + "item.minecraft.potion": "药水", + "item.minecraft.potion.effect.awkward": "粗制的药水", + "item.minecraft.potion.effect.empty": "不可合成的药水", + "item.minecraft.potion.effect.fire_resistance": "抗火药水", + "item.minecraft.potion.effect.freezing": "冰冻药水", + "item.minecraft.potion.effect.harming": "伤害药水", + "item.minecraft.potion.effect.healing": "治疗药水", + "item.minecraft.potion.effect.infested": "虫蚀药水", + "item.minecraft.potion.effect.invisibility": "隐身药水", + "item.minecraft.potion.effect.leaping": "跳跃药水", + "item.minecraft.potion.effect.levitation": "飘浮药水", + "item.minecraft.potion.effect.luck": "幸运药水", + "item.minecraft.potion.effect.mundane": "平凡的药水", + "item.minecraft.potion.effect.night_vision": "夜视药水", + "item.minecraft.potion.effect.oozing": "渗浆药水", + "item.minecraft.potion.effect.poison": "剧毒药水", + "item.minecraft.potion.effect.regeneration": "再生药水", + "item.minecraft.potion.effect.slow_falling": "缓降药水", + "item.minecraft.potion.effect.slowness": "迟缓药水", + "item.minecraft.potion.effect.strength": "力量药水", + "item.minecraft.potion.effect.swiftness": "迅捷药水", + "item.minecraft.potion.effect.thick": "浓稠的药水", + "item.minecraft.potion.effect.turtle_master": "神龟药水", + "item.minecraft.potion.effect.water": "水瓶", + "item.minecraft.potion.effect.water_breathing": "水肺药水", + "item.minecraft.potion.effect.weakness": "虚弱药水", + "item.minecraft.potion.effect.weaving": "盘丝药水", + "item.minecraft.potion.effect.wind_charged": "蓄风药水", + "item.minecraft.pottery_shard_archer": "弓箭纹样陶片", + "item.minecraft.pottery_shard_arms_up": "举臂纹样陶片", + "item.minecraft.pottery_shard_prize": "珍宝纹样陶片", + "item.minecraft.pottery_shard_skull": "头颅纹样陶片", + "item.minecraft.powder_snow_bucket": "细雪桶", + "item.minecraft.prismarine_crystals": "海晶砂粒", + "item.minecraft.prismarine_shard": "海晶碎片", + "item.minecraft.prize_pottery_shard": "珍宝纹样陶片", + "item.minecraft.prize_pottery_sherd": "珍宝纹样陶片", + "item.minecraft.pufferfish": "河豚", + "item.minecraft.pufferfish_bucket": "河豚桶", + "item.minecraft.pufferfish_spawn_egg": "河豚刷怪蛋", + "item.minecraft.pumpkin_pie": "南瓜派", + "item.minecraft.pumpkin_seeds": "南瓜种子", + "item.minecraft.purple_bundle": "紫色收纳袋", + "item.minecraft.purple_cushion": "紫色坐垫", + "item.minecraft.purple_dye": "紫色染料", + "item.minecraft.purple_harness": "紫色挽具", + "item.minecraft.quartz": "下界石英", + "item.minecraft.rabbit": "生兔肉", + "item.minecraft.rabbit_foot": "兔子脚", + "item.minecraft.rabbit_hide": "兔子皮", + "item.minecraft.rabbit_spawn_egg": "兔子刷怪蛋", + "item.minecraft.rabbit_stew": "兔肉煲", + "item.minecraft.raiser_armor_trim_smithing_template": "锻造模板", + "item.minecraft.raiser_armor_trim_smithing_template.new": "牧民盔甲纹饰", + "item.minecraft.ravager_spawn_egg": "劫掠兽刷怪蛋", + "item.minecraft.raw_copper": "粗铜", + "item.minecraft.raw_gold": "粗金", + "item.minecraft.raw_iron": "粗铁", + "item.minecraft.recovery_compass": "追溯指针", + "item.minecraft.red_bundle": "红色收纳袋", + "item.minecraft.red_cushion": "红色坐垫", + "item.minecraft.red_dye": "红色染料", + "item.minecraft.red_harness": "红色挽具", + "item.minecraft.redstone": "红石粉", + "item.minecraft.resin_brick": "树脂砖", + "item.minecraft.resin_clump": "树脂团", + "item.minecraft.rib_armor_trim_smithing_template": "锻造模板", + "item.minecraft.rib_armor_trim_smithing_template.new": "肋骨盔甲纹饰", + "item.minecraft.rotten_flesh": "腐肉", + "item.minecraft.saddle": "鞍", + "item.minecraft.salmon": "生鲑鱼", + "item.minecraft.salmon_bucket": "鲑鱼桶", + "item.minecraft.salmon_spawn_egg": "鲑鱼刷怪蛋", + "item.minecraft.savanna_village_map": "热带草原村庄地图", + "item.minecraft.scrape_pottery_sherd": "刮削纹样陶片", + "item.minecraft.scute": "鳞甲", + "item.minecraft.sentry_armor_trim_smithing_template": "锻造模板", + "item.minecraft.sentry_armor_trim_smithing_template.new": "哨兵盔甲纹饰", + "item.minecraft.shaper_armor_trim_smithing_template": "锻造模板", + "item.minecraft.shaper_armor_trim_smithing_template.new": "塑造盔甲纹饰", + "item.minecraft.sheaf_pottery_shard": "麦捆纹样陶片", + "item.minecraft.sheaf_pottery_sherd": "麦捆纹样陶片", + "item.minecraft.shears": "剪刀", + "item.minecraft.sheep_spawn_egg": "绵羊刷怪蛋", + "item.minecraft.shelter_pottery_shard": "树荫纹样陶片", + "item.minecraft.shelter_pottery_sherd": "树荫纹样陶片", + "item.minecraft.shield": "盾牌", + "item.minecraft.shield.black": "黑色盾牌", + "item.minecraft.shield.blue": "蓝色盾牌", + "item.minecraft.shield.brown": "棕色盾牌", + "item.minecraft.shield.cyan": "青色盾牌", + "item.minecraft.shield.gray": "灰色盾牌", + "item.minecraft.shield.green": "绿色盾牌", + "item.minecraft.shield.light_blue": "淡蓝色盾牌", + "item.minecraft.shield.light_gray": "淡灰色盾牌", + "item.minecraft.shield.lime": "黄绿色盾牌", + "item.minecraft.shield.magenta": "品红色盾牌", + "item.minecraft.shield.orange": "橙色盾牌", + "item.minecraft.shield.pink": "粉红色盾牌", + "item.minecraft.shield.purple": "紫色盾牌", + "item.minecraft.shield.red": "红色盾牌", + "item.minecraft.shield.white": "白色盾牌", + "item.minecraft.shield.yellow": "黄色盾牌", + "item.minecraft.shulker_shell": "潜影壳", + "item.minecraft.shulker_spawn_egg": "潜影贝刷怪蛋", + "item.minecraft.sign": "告示牌", + "item.minecraft.silence_armor_trim_smithing_template": "锻造模板", + "item.minecraft.silence_armor_trim_smithing_template.new": "幽静盔甲纹饰", + "item.minecraft.silverfish_spawn_egg": "蠹虫刷怪蛋", + "item.minecraft.skeleton_horse_spawn_egg": "骷髅马刷怪蛋", + "item.minecraft.skeleton_spawn_egg": "骷髅刷怪蛋", + "item.minecraft.skull_banner_pattern": "旗帜图案", + "item.minecraft.skull_banner_pattern.desc": "头颅盾徽", + "item.minecraft.skull_banner_pattern.new": "头颅盾徽旗帜图案", + "item.minecraft.skull_pottery_shard": "头颅纹样陶片", + "item.minecraft.skull_pottery_sherd": "头颅纹样陶片", + "item.minecraft.slime_ball": "黏液球", + "item.minecraft.slime_spawn_egg": "史莱姆刷怪蛋", + "item.minecraft.smithing_template": "锻造模板", + "item.minecraft.smithing_template.applies_to": "可应用于:", + "item.minecraft.smithing_template.armor_trim.additions_slot_description": "放入锭或晶体", + "item.minecraft.smithing_template.armor_trim.applies_to": "盔甲", + "item.minecraft.smithing_template.armor_trim.base_slot_description": "放入盔甲", + "item.minecraft.smithing_template.armor_trim.ingredients": "锭或晶体", + "item.minecraft.smithing_template.ingredients": "所需原材料:", + "item.minecraft.smithing_template.netherite_upgrade.additions_slot_description": "放入下界合金锭", + "item.minecraft.smithing_template.netherite_upgrade.applies_to": "钻石装备", + "item.minecraft.smithing_template.netherite_upgrade.base_slot_description": "放入钻石盔甲、武器或工具", + "item.minecraft.smithing_template.netherite_upgrade.ingredients": "下界合金锭", + "item.minecraft.smithing_template.upgrade": "已有升级:", + "item.minecraft.sniffer_spawn_egg": "嗅探兽刷怪蛋", + "item.minecraft.snort_pottery_shard": "嗅探纹样陶片", + "item.minecraft.snort_pottery_sherd": "嗅探纹样陶片", + "item.minecraft.snout_armor_trim_smithing_template": "锻造模板", + "item.minecraft.snout_armor_trim_smithing_template.new": "猪鼻盔甲纹饰", + "item.minecraft.snow_golem_spawn_egg": "雪傀儡刷怪蛋", + "item.minecraft.snowball": "雪球", + "item.minecraft.snowy_village_map": "雪原村庄地图", + "item.minecraft.spectral_arrow": "光灵箭", + "item.minecraft.spider_eye": "蜘蛛眼", + "item.minecraft.spider_spawn_egg": "蜘蛛刷怪蛋", + "item.minecraft.spire_armor_trim_smithing_template": "锻造模板", + "item.minecraft.spire_armor_trim_smithing_template.new": "尖塔盔甲纹饰", + "item.minecraft.splash_potion": "喷溅药水", + "item.minecraft.splash_potion.effect.awkward": "喷溅型粗制的药水", + "item.minecraft.splash_potion.effect.empty": "不可合成的喷溅型药水", + "item.minecraft.splash_potion.effect.fire_resistance": "喷溅型抗火药水", + "item.minecraft.splash_potion.effect.freezing": "喷溅型冰冻药水", + "item.minecraft.splash_potion.effect.harming": "喷溅型伤害药水", + "item.minecraft.splash_potion.effect.healing": "喷溅型治疗药水", + "item.minecraft.splash_potion.effect.infested": "喷溅型虫蚀药水", + "item.minecraft.splash_potion.effect.invisibility": "喷溅型隐身药水", + "item.minecraft.splash_potion.effect.leaping": "喷溅型跳跃药水", + "item.minecraft.splash_potion.effect.levitation": "喷溅型飘浮药水", + "item.minecraft.splash_potion.effect.luck": "喷溅型幸运药水", + "item.minecraft.splash_potion.effect.mundane": "喷溅型平凡的药水", + "item.minecraft.splash_potion.effect.night_vision": "喷溅型夜视药水", + "item.minecraft.splash_potion.effect.oozing": "喷溅型渗浆药水", + "item.minecraft.splash_potion.effect.poison": "喷溅型剧毒药水", + "item.minecraft.splash_potion.effect.regeneration": "喷溅型再生药水", + "item.minecraft.splash_potion.effect.slow_falling": "喷溅型缓降药水", + "item.minecraft.splash_potion.effect.slowness": "喷溅型迟缓药水", + "item.minecraft.splash_potion.effect.strength": "喷溅型力量药水", + "item.minecraft.splash_potion.effect.swiftness": "喷溅型迅捷药水", + "item.minecraft.splash_potion.effect.thick": "喷溅型浓稠的药水", + "item.minecraft.splash_potion.effect.turtle_master": "喷溅型神龟药水", + "item.minecraft.splash_potion.effect.water": "喷溅型水瓶", + "item.minecraft.splash_potion.effect.water_breathing": "喷溅型水肺药水", + "item.minecraft.splash_potion.effect.weakness": "喷溅型虚弱药水", + "item.minecraft.splash_potion.effect.weaving": "喷溅型盘丝药水", + "item.minecraft.splash_potion.effect.wind_charged": "喷溅型蓄风药水", + "item.minecraft.spruce_boat": "云杉木船", + "item.minecraft.spruce_chest_boat": "云杉木运输船", + "item.minecraft.spyglass": "望远镜", + "item.minecraft.squid_spawn_egg": "鱿鱼刷怪蛋", + "item.minecraft.stick": "木棍", + "item.minecraft.stone_axe": "石斧", + "item.minecraft.stone_hoe": "石锄", + "item.minecraft.stone_pickaxe": "石镐", + "item.minecraft.stone_shovel": "石锹", + "item.minecraft.stone_spear": "石矛", + "item.minecraft.stone_sword": "石剑", + "item.minecraft.straw_bed": "麦秆床", + "item.minecraft.stray_spawn_egg": "流浪者刷怪蛋", + "item.minecraft.strider_spawn_egg": "炽足兽刷怪蛋", + "item.minecraft.string": "线", + "item.minecraft.sugar": "糖", + "item.minecraft.sulfur_cube_bucket": "硫方怪桶", + "item.minecraft.sulfur_cube_spawn_egg": "硫方怪刷怪蛋", + "item.minecraft.suspicious_stew": "谜之炖菜", + "item.minecraft.swamp_explorer_map": "沼泽探险家地图", + "item.minecraft.swamp_hut_map": "沼泽小屋地图", + "item.minecraft.sweet_berries": "甜浆果", + "item.minecraft.tadpole_bucket": "蝌蚪桶", + "item.minecraft.tadpole_spawn_egg": "蝌蚪刷怪蛋", + "item.minecraft.taiga_village_map": "针叶林村庄地图", + "item.minecraft.tide_armor_trim_smithing_template": "锻造模板", + "item.minecraft.tide_armor_trim_smithing_template.new": "潮汐盔甲纹饰", + "item.minecraft.tipped_arrow": "药箭", + "item.minecraft.tipped_arrow.effect.awkward": "药箭", + "item.minecraft.tipped_arrow.effect.empty": "不可合成的药箭", + "item.minecraft.tipped_arrow.effect.fire_resistance": "抗火之箭", + "item.minecraft.tipped_arrow.effect.freezing": "冰冻之箭", + "item.minecraft.tipped_arrow.effect.harming": "伤害之箭", + "item.minecraft.tipped_arrow.effect.healing": "治疗之箭", + "item.minecraft.tipped_arrow.effect.infested": "虫蚀之箭", + "item.minecraft.tipped_arrow.effect.invisibility": "隐身之箭", + "item.minecraft.tipped_arrow.effect.leaping": "跳跃之箭", + "item.minecraft.tipped_arrow.effect.levitation": "飘浮之箭", + "item.minecraft.tipped_arrow.effect.luck": "幸运之箭", + "item.minecraft.tipped_arrow.effect.mundane": "药箭", + "item.minecraft.tipped_arrow.effect.night_vision": "夜视之箭", + "item.minecraft.tipped_arrow.effect.oozing": "渗浆之箭", + "item.minecraft.tipped_arrow.effect.poison": "剧毒之箭", + "item.minecraft.tipped_arrow.effect.regeneration": "再生之箭", + "item.minecraft.tipped_arrow.effect.slow_falling": "缓降之箭", + "item.minecraft.tipped_arrow.effect.slowness": "迟缓之箭", + "item.minecraft.tipped_arrow.effect.strength": "力量之箭", + "item.minecraft.tipped_arrow.effect.swiftness": "迅捷之箭", + "item.minecraft.tipped_arrow.effect.thick": "药箭", + "item.minecraft.tipped_arrow.effect.turtle_master": "神龟之箭", + "item.minecraft.tipped_arrow.effect.water": "喷溅之箭", + "item.minecraft.tipped_arrow.effect.water_breathing": "水肺之箭", + "item.minecraft.tipped_arrow.effect.weakness": "虚弱之箭", + "item.minecraft.tipped_arrow.effect.weaving": "盘丝之箭", + "item.minecraft.tipped_arrow.effect.wind_charged": "蓄风之箭", + "item.minecraft.tnt_minecart": "TNT矿车", + "item.minecraft.torchflower_seeds": "火把花种子", + "item.minecraft.totem_of_undying": "不死图腾", + "item.minecraft.trader_llama_spawn_egg": "行商羊驼刷怪蛋", + "item.minecraft.trial_explorer_map": "试炼探险家地图", + "item.minecraft.trial_key": "试炼钥匙", + "item.minecraft.trident": "三叉戟", + "item.minecraft.tropical_fish": "热带鱼", + "item.minecraft.tropical_fish_bucket": "热带鱼桶", + "item.minecraft.tropical_fish_spawn_egg": "热带鱼刷怪蛋", + "item.minecraft.turtle_helmet": "海龟壳", + "item.minecraft.turtle_scute": "海龟鳞甲", + "item.minecraft.turtle_spawn_egg": "海龟刷怪蛋", + "item.minecraft.vex_armor_trim_smithing_template": "锻造模板", + "item.minecraft.vex_armor_trim_smithing_template.new": "恼鬼盔甲纹饰", + "item.minecraft.vex_spawn_egg": "恼鬼刷怪蛋", + "item.minecraft.villager_spawn_egg": "村民刷怪蛋", + "item.minecraft.vindicator_spawn_egg": "卫道士刷怪蛋", + "item.minecraft.wandering_trader_spawn_egg": "流浪商人刷怪蛋", + "item.minecraft.ward_armor_trim_smithing_template": "锻造模板", + "item.minecraft.ward_armor_trim_smithing_template.new": "监守盔甲纹饰", + "item.minecraft.warden_spawn_egg": "监守者刷怪蛋", + "item.minecraft.warm_ocean_ruins_map": "暖水海底废墟地图", + "item.minecraft.warped_fungus_on_a_stick": "诡异菌钓竿", + "item.minecraft.water_bucket": "水桶", + "item.minecraft.wayfinder_armor_trim_smithing_template": "锻造模板", + "item.minecraft.wayfinder_armor_trim_smithing_template.new": "向导盔甲纹饰", + "item.minecraft.wheat": "小麦", + "item.minecraft.wheat_seeds": "小麦种子", + "item.minecraft.white_bundle": "白色收纳袋", + "item.minecraft.white_cushion": "白色坐垫", + "item.minecraft.white_dye": "白色染料", + "item.minecraft.white_harness": "白色挽具", + "item.minecraft.wild_armor_trim_smithing_template": "锻造模板", + "item.minecraft.wild_armor_trim_smithing_template.new": "荒野盔甲纹饰", + "item.minecraft.wind_charge": "风弹", + "item.minecraft.witch_spawn_egg": "女巫刷怪蛋", + "item.minecraft.wither_skeleton_spawn_egg": "凋灵骷髅刷怪蛋", + "item.minecraft.wither_spawn_egg": "凋灵刷怪蛋", + "item.minecraft.wolf_armor": "狼铠", + "item.minecraft.wolf_spawn_egg": "狼刷怪蛋", + "item.minecraft.wooden_axe": "木斧", + "item.minecraft.wooden_hoe": "木锄", + "item.minecraft.wooden_pickaxe": "木镐", + "item.minecraft.wooden_shovel": "木锹", + "item.minecraft.wooden_spear": "木矛", + "item.minecraft.wooden_sword": "木剑", + "item.minecraft.woodland_explorer_map": "林地探险家地图", + "item.minecraft.woodland_mansion_map": "林地府邸地图", + "item.minecraft.writable_book": "书与笔", + "item.minecraft.written_book": "成书", + "item.minecraft.yellow_bundle": "黄色收纳袋", + "item.minecraft.yellow_cushion": "黄色坐垫", + "item.minecraft.yellow_dye": "黄色染料", + "item.minecraft.yellow_harness": "黄色挽具", + "item.minecraft.zoglin_spawn_egg": "僵尸疣猪兽刷怪蛋", + "item.minecraft.zombie_horse_spawn_egg": "僵尸马刷怪蛋", + "item.minecraft.zombie_nautilus_spawn_egg": "僵尸鹦鹉螺刷怪蛋", + "item.minecraft.zombie_spawn_egg": "僵尸刷怪蛋", + "item.minecraft.zombie_villager_spawn_egg": "僵尸村民刷怪蛋", + "item.minecraft.zombified_piglin_spawn_egg": "僵尸猪灵刷怪蛋", + "item.modifiers.any": "装备时:", + "item.modifiers.armor": "穿戴时:", + "item.modifiers.body": "装备时:", + "item.modifiers.chest": "穿在身上时:", + "item.modifiers.feet": "穿在脚上时:", + "item.modifiers.hand": "手持时:", + "item.modifiers.head": "戴在头上时:", + "item.modifiers.legs": "穿在腿上时:", + "item.modifiers.mainhand": "在主手时:", + "item.modifiers.offhand": "在副手时:", + "item.modifiers.saddle": "装备鞍时:", + "item.nbt_tags": "NBT:%s个标签", + "item.op_block_warning.line1": "警告:", + "item.op_block_warning.line2": "使用该物品可能会导致命令执行。", + "item.op_block_warning.line3": "除非知道确切内容,否则请勿使用!", + "item.spawn_egg.peaceful": "已在和平难度下禁用", + "item.unbreakable": "无法破坏", + "itemGroup.buildingBlocks": "建筑方块", + "itemGroup.coloredBlocks": "染色方块", + "itemGroup.combat": "战斗用品", + "itemGroup.consumables": "消耗品", + "itemGroup.crafting": "合成用品", + "itemGroup.foodAndDrink": "食物与饮品", + "itemGroup.functional": "功能方块", + "itemGroup.hotbar": "已保存的快捷栏", + "itemGroup.ingredients": "原材料", + "itemGroup.inventory": "生存模式物品栏", + "itemGroup.natural": "自然方块", + "itemGroup.op": "管理员用品", + "itemGroup.redstone": "红石方块", + "itemGroup.search": "搜索物品", + "itemGroup.spawnEggs": "刷怪蛋", + "itemGroup.tools": "工具与实用物品", + "item_modifier.unknown": "未知的物品修饰器“%s”", + "jigsaw_block.final_state": "转变为:", + "jigsaw_block.generate": "生成", + "jigsaw_block.joint.aligned": "固定", + "jigsaw_block.joint.rollable": "可旋转", + "jigsaw_block.joint_label": "拼接类型:", + "jigsaw_block.keep_jigsaws": "保留拼图", + "jigsaw_block.levels": "层数:%s", + "jigsaw_block.name": "名称:", + "jigsaw_block.placement_priority": "放置优先级:", + "jigsaw_block.placement_priority.tooltip": "当此拼图方块连接到部件时,此处即为在更大的结构中各部件处理连接的顺序。\n\n部件按照优先级由高到低处理,若优先级相同,则按照插入顺序处理。", + "jigsaw_block.pool": "目标池:", + "jigsaw_block.selection_priority": "选择优先级:", + "jigsaw_block.selection_priority.tooltip": "当父级片段处理连接时,此处即为各拼图方块尝试连接目标部件的顺序。\n\n拼图方块将会按照优先级由高到低处理,同级则随机处理。", + "jigsaw_block.target": "目标名称:", + "jukebox_song.minecraft.11": "C418 - 11", + "jukebox_song.minecraft.13": "C418 - 13", + "jukebox_song.minecraft.5": "Samuel Åberg - 5", + "jukebox_song.minecraft.blocks": "C418 - blocks", + "jukebox_song.minecraft.bounce": "fingerspit - Bounce", + "jukebox_song.minecraft.cat": "C418 - cat", + "jukebox_song.minecraft.chirp": "C418 - chirp", + "jukebox_song.minecraft.creator": "Lena Raine - Creator", + "jukebox_song.minecraft.creator_music_box": "Lena Raine - Creator(八音盒)", + "jukebox_song.minecraft.far": "C418 - far", + "jukebox_song.minecraft.lava_chicken": "Hyper Potions - Lava Chicken", + "jukebox_song.minecraft.mall": "C418 - mall", + "jukebox_song.minecraft.mellohi": "C418 - mellohi", + "jukebox_song.minecraft.otherside": "Lena Raine - otherside", + "jukebox_song.minecraft.pigstep": "Lena Raine - Pigstep", + "jukebox_song.minecraft.precipice": "Aaron Cherof - Precipice", + "jukebox_song.minecraft.relic": "Aaron Cherof - Relic", + "jukebox_song.minecraft.stal": "C418 - stal", + "jukebox_song.minecraft.strad": "C418 - strad", + "jukebox_song.minecraft.tears": "Amos Roddy - Tears", + "jukebox_song.minecraft.wait": "C418 - wait", + "jukebox_song.minecraft.ward": "C418 - ward", + "key.advancements": "进度", + "key.attack": "攻击/摧毁", + "key.back": "向后移动", + "key.categories.creative": "创造模式", + "key.categories.gameplay": "游戏内容", + "key.categories.inventory": "物品栏", + "key.categories.misc": "杂项", + "key.categories.movement": "移动", + "key.categories.multiplayer": "多人游戏", + "key.categories.spectator": "旁观", + "key.categories.ui": "游戏界面", + "key.category.minecraft.creative": "创造模式", + "key.category.minecraft.debug": "调试", + "key.category.minecraft.gameplay": "游戏内容", + "key.category.minecraft.inventory": "物品栏", + "key.category.minecraft.misc": "杂项", + "key.category.minecraft.movement": "移动", + "key.category.minecraft.multiplayer": "多人游戏", + "key.category.minecraft.spectator": "旁观", + "key.chat": "打开聊天栏", + "key.command": "输入命令", + "key.debug.clearChat": "清除聊天消息", + "key.debug.copyLocation": "复制位置", + "key.debug.copyRecreateCommand": "复制数据", + "key.debug.crash": "调试崩溃", + "key.debug.debugOptions": "调试选项", + "key.debug.dumpDynamicTextures": "转储动态纹理", + "key.debug.dumpVersion": "转储版本信息", + "key.debug.focusPause": "切换失去焦点时暂停", + "key.debug.fpsCharts": "FPS图表", + "key.debug.improvedTransparency": "切换改进透明显示", + "key.debug.lightmapTexture": "光照贴图纹理", + "key.debug.modifier": "调试修饰键", + "key.debug.networkCharts": "网络图表", + "key.debug.overlay": "切换叠加层", + "key.debug.profiling": "开始/停止分析", + "key.debug.profilingChart": "分析图表", + "key.debug.reloadChunk": "重新加载区块", + "key.debug.reloadResourcePacks": "重新加载资源包", + "key.debug.showAdvancedTooltips": "显示高级提示框", + "key.debug.showChunkBorders": "显示区块边界", + "key.debug.showHitboxes": "显示判定箱", + "key.debug.spectate": "循环切换旁观模式", + "key.debug.switchGameMode": "游戏模式切换器", + "key.drop": "丢弃所选物品", + "key.forward": "向前移动", + "key.friends": "好友", + "key.fullscreen": "切换全屏显示", + "key.hotbar.1": "快捷栏1", + "key.hotbar.2": "快捷栏2", + "key.hotbar.3": "快捷栏3", + "key.hotbar.4": "快捷栏4", + "key.hotbar.5": "快捷栏5", + "key.hotbar.6": "快捷栏6", + "key.hotbar.7": "快捷栏7", + "key.hotbar.8": "快捷栏8", + "key.hotbar.9": "快捷栏9", + "key.inventory": "开启/关闭物品栏", + "key.jump": "跳跃", + "key.keyboard.ac.back": "AC 后退", + "key.keyboard.ac.bookmarks": "AC 收藏夹", + "key.keyboard.ac.close": "AC 关闭", + "key.keyboard.ac.exit": "AC 退出", + "key.keyboard.ac.forward": "AC 前进", + "key.keyboard.ac.home": "AC 主页", + "key.keyboard.ac.new": "AC 新建", + "key.keyboard.ac.open": "AC 打开", + "key.keyboard.ac.print": "AC 打印", + "key.keyboard.ac.properties": "AC 属性", + "key.keyboard.ac.refresh": "AC 刷新", + "key.keyboard.ac.save": "AC 保存", + "key.keyboard.ac.search": "AC 搜索", + "key.keyboard.ac.stop": "AC 停止", + "key.keyboard.again": "Again", + "key.keyboard.alternate.erase": "Alternate Erase", + "key.keyboard.apostrophe": "'", + "key.keyboard.application": "应用程序", + "key.keyboard.backslash": "\\", + "key.keyboard.backspace": "Backspace", + "key.keyboard.call": "接听", + "key.keyboard.cancel": "取消", + "key.keyboard.caps.lock": "Caps Lock", + "key.keyboard.channel.down": "上一频道", + "key.keyboard.channel.up": "下一频道", + "key.keyboard.clear": "清除", + "key.keyboard.clear.again": "再次清除", + "key.keyboard.comma": ",", + "key.keyboard.copy": "Copy", + "key.keyboard.crsel": "CrSel", + "key.keyboard.currency.subunit": "货币辅单位", + "key.keyboard.currency.unit": "货币单位", + "key.keyboard.cut": "Cut", + "key.keyboard.decimal.separator": "小数分隔符", + "key.keyboard.delete": "Delete", + "key.keyboard.down": "下方向键", + "key.keyboard.end": "End", + "key.keyboard.end.call": "挂断", + "key.keyboard.enter": "Enter", + "key.keyboard.enter2": "Enter 2", + "key.keyboard.equal": "=", + "key.keyboard.escape": "Esc", + "key.keyboard.execute": "执行", + "key.keyboard.exsel": "ExSel", + "key.keyboard.f1": "F1", + "key.keyboard.f10": "F10", + "key.keyboard.f11": "F11", + "key.keyboard.f12": "F12", + "key.keyboard.f13": "F13", + "key.keyboard.f14": "F14", + "key.keyboard.f15": "F15", + "key.keyboard.f16": "F16", + "key.keyboard.f17": "F17", + "key.keyboard.f18": "F18", + "key.keyboard.f19": "F19", + "key.keyboard.f2": "F2", + "key.keyboard.f20": "F20", + "key.keyboard.f21": "F21", + "key.keyboard.f22": "F22", + "key.keyboard.f23": "F23", + "key.keyboard.f24": "F24", + "key.keyboard.f25": "F25", + "key.keyboard.f3": "F3", + "key.keyboard.f4": "F4", + "key.keyboard.f5": "F5", + "key.keyboard.f6": "F6", + "key.keyboard.f7": "F7", + "key.keyboard.f8": "F8", + "key.keyboard.f9": "F9", + "key.keyboard.find": "Find", + "key.keyboard.grave.accent": "`", + "key.keyboard.help": "Help", + "key.keyboard.home": "Home", + "key.keyboard.insert": "Insert", + "key.keyboard.international1": "国际键 1", + "key.keyboard.international2": "国际键 2", + "key.keyboard.international3": "国际键 3", + "key.keyboard.international4": "国际键 4", + "key.keyboard.international5": "国际键 5", + "key.keyboard.international6": "国际键 6", + "key.keyboard.international7": "国际键 7", + "key.keyboard.international8": "国际键 8", + "key.keyboard.international9": "国际键 9", + "key.keyboard.keypad.0": "小键盘 0", + "key.keyboard.keypad.00": "小键盘 00", + "key.keyboard.keypad.000": "小键盘 000", + "key.keyboard.keypad.1": "小键盘 1", + "key.keyboard.keypad.2": "小键盘 2", + "key.keyboard.keypad.3": "小键盘 3", + "key.keyboard.keypad.4": "小键盘 4", + "key.keyboard.keypad.5": "小键盘 5", + "key.keyboard.keypad.6": "小键盘 6", + "key.keyboard.keypad.7": "小键盘 7", + "key.keyboard.keypad.8": "小键盘 8", + "key.keyboard.keypad.9": "小键盘 9", + "key.keyboard.keypad.a": "小键盘 A", + "key.keyboard.keypad.add": "小键盘 +", + "key.keyboard.keypad.ampersand": "小键盘 &", + "key.keyboard.keypad.at": "小键盘 @", + "key.keyboard.keypad.b": "小键盘 B", + "key.keyboard.keypad.backspace": "小键盘 Backspace", + "key.keyboard.keypad.binary": "小键盘 二进制", + "key.keyboard.keypad.c": "小键盘 C", + "key.keyboard.keypad.clear": "小键盘 清除", + "key.keyboard.keypad.clear.entry": "小键盘 清除输入", + "key.keyboard.keypad.colon": "小键盘 :", + "key.keyboard.keypad.comma": "小键盘 ,", + "key.keyboard.keypad.d": "小键盘 D", + "key.keyboard.keypad.decimal": "小键盘 .", + "key.keyboard.keypad.divide": "小键盘 /", + "key.keyboard.keypad.double.ampersand": "小键盘 &&", + "key.keyboard.keypad.double.vertical.bar": "小键盘 ||", + "key.keyboard.keypad.e": "小键盘 E", + "key.keyboard.keypad.enter": "小键盘 Enter", + "key.keyboard.keypad.equal": "小键盘 =", + "key.keyboard.keypad.equals.as400": "小键盘 =(AS400)", + "key.keyboard.keypad.exclamation": "小键盘 !", + "key.keyboard.keypad.f": "小键盘 F", + "key.keyboard.keypad.greater": "小键盘 >", + "key.keyboard.keypad.hash": "小键盘 #", + "key.keyboard.keypad.hexadecimal": "小键盘 十六进制", + "key.keyboard.keypad.left.brace": "小键盘 {", + "key.keyboard.keypad.left.parenthesis": "小键盘 (", + "key.keyboard.keypad.less": "小键盘 <", + "key.keyboard.keypad.memory.add": "小键盘 M+", + "key.keyboard.keypad.memory.clear": "小键盘 MC", + "key.keyboard.keypad.memory.divide": "小键盘 M/", + "key.keyboard.keypad.memory.multiply": "小键盘 M*", + "key.keyboard.keypad.memory.recall": "小键盘 MR", + "key.keyboard.keypad.memory.store": "小键盘 MS", + "key.keyboard.keypad.memory.subtract": "小键盘 M-", + "key.keyboard.keypad.multiply": "小键盘 *", + "key.keyboard.keypad.octal": "小键盘 八进制", + "key.keyboard.keypad.percent": "小键盘 %", + "key.keyboard.keypad.period": "小键盘 .", + "key.keyboard.keypad.plus.minus": "小键盘 ±", + "key.keyboard.keypad.power": "小键盘 ^", + "key.keyboard.keypad.right.brace": "小键盘 }", + "key.keyboard.keypad.right.parenthesis": "小键盘 )", + "key.keyboard.keypad.space": "小键盘 空格", + "key.keyboard.keypad.subtract": "小键盘 -", + "key.keyboard.keypad.tab": "小键盘 Tab", + "key.keyboard.keypad.vertical.bar": "小键盘 |", + "key.keyboard.keypad.xor": "小键盘 XOR", + "key.keyboard.lang1": "语言键 1", + "key.keyboard.lang2": "语言键 2", + "key.keyboard.lang3": "语言键 3", + "key.keyboard.lang4": "语言键 4", + "key.keyboard.lang5": "语言键 5", + "key.keyboard.lang6": "语言键 6", + "key.keyboard.lang7": "语言键 7", + "key.keyboard.lang8": "语言键 8", + "key.keyboard.lang9": "语言键 9", + "key.keyboard.left": "左方向键", + "key.keyboard.left.alt": "左Alt", + "key.keyboard.left.bracket": "[", + "key.keyboard.left.command": "左Command", + "key.keyboard.left.control": "左Ctrl", + "key.keyboard.left.meta": "左Meta", + "key.keyboard.left.option": "左Option", + "key.keyboard.left.shift": "左Shift", + "key.keyboard.left.win": "左Win", + "key.keyboard.left.windows": "左Windows", + "key.keyboard.media.eject": "弹出", + "key.keyboard.media.fast.forward": "快进", + "key.keyboard.media.next.track": "下一曲", + "key.keyboard.media.pause": "媒体暂停", + "key.keyboard.media.play": "媒体播放", + "key.keyboard.media.play.pause": "Play/Pause", + "key.keyboard.media.previous.track": "上一曲", + "key.keyboard.media.record": "录制", + "key.keyboard.media.rewind": "快退", + "key.keyboard.media.select": "媒体选择", + "key.keyboard.media.stop": "媒体停止", + "key.keyboard.menu": "菜单", + "key.keyboard.minus": "-", + "key.keyboard.mode": "模式", + "key.keyboard.mute": "Mute", + "key.keyboard.new": "New", + "key.keyboard.num.lock": "Num Lock", + "key.keyboard.oper": "Oper", + "key.keyboard.out": "Out", + "key.keyboard.page.down": "Page Down", + "key.keyboard.page.up": "Page Up", + "key.keyboard.paste": "Paste", + "key.keyboard.pause": "Pause", + "key.keyboard.period": ".", + "key.keyboard.power": "电源", + "key.keyboard.print.screen": "Print Screen", + "key.keyboard.prior": "Prior", + "key.keyboard.right": "右方向键", + "key.keyboard.right.alt": "右Alt", + "key.keyboard.right.bracket": "]", + "key.keyboard.right.command": "右Command", + "key.keyboard.right.control": "右Ctrl", + "key.keyboard.right.meta": "右Meta", + "key.keyboard.right.option": "右Option", + "key.keyboard.right.shift": "右Shift", + "key.keyboard.right.win": "右Win", + "key.keyboard.right.windows": "右Windows", + "key.keyboard.save": "Save", + "key.keyboard.scroll.lock": "Scroll Lock", + "key.keyboard.select": "选择", + "key.keyboard.semicolon": ";", + "key.keyboard.separator": "分隔符", + "key.keyboard.slash": "/", + "key.keyboard.sleep": "睡眠", + "key.keyboard.soft.left": "左软键", + "key.keyboard.soft.right": "右软键", + "key.keyboard.space": "空格", + "key.keyboard.stop": "Stop", + "key.keyboard.sys.req": "SysRq", + "key.keyboard.tab": "Tab", + "key.keyboard.thousands.separator": "千位分隔符", + "key.keyboard.undo": "Undo", + "key.keyboard.unknown": "未指定", + "key.keyboard.up": "上方向键", + "key.keyboard.volume.down": "音量减", + "key.keyboard.volume.up": "音量加", + "key.keyboard.wake": "唤醒", + "key.keyboard.world.1": "World 1", + "key.keyboard.world.2": "World 2", + "key.left": "向左移动", + "key.loadToolbarActivator": "加载快捷栏", + "key.mouse": "鼠标按键%1$s", + "key.mouse.left": "左键", + "key.mouse.middle": "中键", + "key.mouse.right": "右键", + "key.otherPlayers": "其他玩家", + "key.pickItem": "选取方块", + "key.playerlist": "显示玩家列表", + "key.quickActions": "快捷操作", + "key.right": "向右移动", + "key.saveToolbarActivator": "保存快捷栏", + "key.screenshot": "截图", + "key.smoothCamera": "切换电影视角", + "key.sneak": "潜行", + "key.socialInteractions": "社交屏幕", + "key.spectatorHotbar": "呼出快捷栏", + "key.spectatorOutlines": "高亮玩家", + "key.sprint": "疾跑", + "key.swapOffhand": "与副手交换物品", + "key.toggleGui": "切换界面", + "key.togglePerspective": "切换视角", + "key.toggleSpectatorShaderEffects": "切换旁观者着色器效果", + "key.use": "使用物品/放置方块", + "known_server_link.announcements": "公告", + "known_server_link.community": "社区", + "known_server_link.community_guidelines": "社区方针", + "known_server_link.feedback": "反馈", + "known_server_link.forums": "论坛", + "known_server_link.news": "新闻", + "known_server_link.report_bug": "报告服务器漏洞", + "known_server_link.status": "状态", + "known_server_link.support": "支持", + "known_server_link.website": "网站", + "lanServer.otherPlayers": "对其他玩家的设置", + "lanServer.port": "端口号", + "lanServer.port.invalid": "端口无效。\n请将编辑框留空,或输入一个介于1024和65535之间的数字。", + "lanServer.port.invalid.new": "端口无效。\n请将编辑框留空,或输入一个介于%s和%s之间的数字。", + "lanServer.port.unavailable": "端口不可用。\n请将编辑框留空,或输入一个介于1024和65535之间的新数字。", + "lanServer.port.unavailable.new": "端口不可用。\n请将编辑框留空,或输入一个介于%s和%s之间的新数字。", + "lanServer.scanning": "正在你的本地网络中寻找游戏", + "lanServer.start": "创建局域网世界", + "lanServer.title": "局域网世界", + "language.code": "zho-Hans_CN", + "language.name": "简体中文", + "language.region": "中国大陆", + "lectern.take_book": "取书", + "loading.progress": "%s%%", + "manageServer.add.title": "添加服务器", + "manageServer.edit.title": "编辑服务器信息", + "manageServer.enterIp": "服务器地址", + "manageServer.enterName": "服务器名称", + "manageServer.resourcePack": "服务器资源包", + "manageServer.resourcePack.disabled": "禁用", + "manageServer.resourcePack.enabled": "启用", + "manageServer.resourcePack.prompt": "询问", + "mco.account.privacy.info": "阅读更多关于Mojang和其他隐私政策的信息", + "mco.account.privacy.info.button": "阅读关于《通用数据保护条例》的内容", + "mco.account.privacy.information": "Mojang实施许多方法来帮助保护儿童及其隐私权,包括履行《儿童在线隐私权保护法案》(COPPA)与《通用数据保护条例》(GDPR)。\n\n在取得Realms账户访问权之前,你可能需要获得父母的同意。", + "mco.account.privacyinfo": "Mojang实施许多方法来促进儿童及其隐私权的保护,包括履行《儿童在线隐私权保护法案》(COPPA)与《通用数据保护条例》(GDPR)。\n\n在访问你的Realms账户之前,你可能需要征得父母的同意。\n\n如果你拥有的是旧版的Minecraft账户(登录时使用用户名),你需要将其迁移到Mojang账户以访问Realms。", + "mco.account.update": "更新账户", + "mco.activity.noactivity": "过去%s天无动态", + "mco.activity.title": "玩家动态", + "mco.backup.button.download": "下载最新版本", + "mco.backup.button.reset": "重置世界", + "mco.backup.button.restore": "还原", + "mco.backup.button.upload": "上传世界", + "mco.backup.changes.tooltip": "更改", + "mco.backup.entry": "备份(%s)", + "mco.backup.entry.description": "描述", + "mco.backup.entry.enabledPack": "已启用的资源包", + "mco.backup.entry.gameDifficulty": "游戏难度", + "mco.backup.entry.gameMode": "游戏模式", + "mco.backup.entry.gameServerVersion": "游戏服务器版本", + "mco.backup.entry.name": "名称", + "mco.backup.entry.seed": "种子", + "mco.backup.entry.templateName": "模板名称", + "mco.backup.entry.undefined": "未定义的更改", + "mco.backup.entry.uploaded": "已上传", + "mco.backup.entry.worldType": "世界类型", + "mco.backup.entry.worldType.adventureMap": "冒险地图", + "mco.backup.entry.worldType.experience": "体验", + "mco.backup.entry.worldType.inspiration": "示例", + "mco.backup.entry.worldType.minigame": "小游戏", + "mco.backup.entry.worldType.normal": "普通", + "mco.backup.entry.worldType.unknown": "未知", + "mco.backup.generate.world": "生成世界", + "mco.backup.info.title": "自上次备份以来发生的更改", + "mco.backup.narration": "备份自%s", + "mco.backup.nobackups": "这个Realm目前没有任何备份。", + "mco.backup.restoring": "正在还原你的Realm", + "mco.backup.unknown": "未知", + "mco.brokenworld.download": "下载", + "mco.brokenworld.downloaded": "已下载", + "mco.brokenworld.message.line1": "请重置或选择另一个世界。", + "mco.brokenworld.message.line2": "你可以选择将世界下载至单人模式中。", + "mco.brokenworld.minigame.title": "此小游戏已不受支持", + "mco.brokenworld.nonowner.error": "请等待Realm所有者重置世界", + "mco.brokenworld.nonowner.title": "世界已过期", + "mco.brokenworld.play": "开始游戏", + "mco.brokenworld.reset": "重置", + "mco.brokenworld.title": "当前的世界已不受支持", + "mco.client.incompatible.msg.line1": "你的客户端与Realms不兼容。", + "mco.client.incompatible.msg.line2": "请使用最新版本的Minecraft。", + "mco.client.incompatible.msg.line3": "Realms不与快照版本兼容。", + "mco.client.incompatible.title": "客户端不兼容!", + "mco.client.outdated.stable.version": "你的客户端版本(%s)与Realms不兼容。\n\n请使用最新版本的Minecraft。", + "mco.client.unsupported.snapshot.version": "你的客户端版本(%s)与Realms不兼容。\n\nRealms在此快照版本不可用。", + "mco.compatibility.downgrade": "降级", + "mco.compatibility.downgrade.description": "这个世界上次是在%s版本中打开的,你正在使用%s版本。降低世界的游戏版本可能会导致存档损坏,我们无法保证它可以正确加载和运行。\n\n此世界的备份将会保存在“世界备份”下。如有需要,请恢复原来的世界。", + "mco.compatibility.incompatible.popup.title": "版本不兼容", + "mco.compatibility.incompatible.releaseType.popup.message": "尝试加入的世界与所在版本不兼容。", + "mco.compatibility.incompatible.series.popup.message": "这个世界上次是在%s版本中打开的,你正在使用%s版本。\n\n这些系列互不兼容。此版本需要创建新的世界。", + "mco.compatibility.unverifiable.message": "无法验证上次打开这个世界的版本。如果升级或降级这个世界,游戏会在“世界备份”下自动创建备份。", + "mco.compatibility.unverifiable.title": "无法验证兼容性", + "mco.compatibility.upgrade": "升级", + "mco.compatibility.upgrade.description": "这个世界上次是在%s版本中打开的,你正在使用%s版本。\n\n此世界的备份将会保存在“世界备份”下。如有需要,请恢复原来的世界。", + "mco.compatibility.upgrade.friend.description": "这个世界上次是在%s版本中打开的,你正在使用%s版本。\n\n此世界的备份将会保存在“世界备份”下。\n\n如有需要,Realm的所有者可以恢复原来的世界。", + "mco.compatibility.upgrade.title": "你真的想升级此世界吗?", + "mco.configure.current.minigame": "当前", + "mco.configure.world.activityfeed.disabled": "玩家推送暂不可用", + "mco.configure.world.backup": "世界备份", + "mco.configure.world.buttons.activity": "玩家动态", + "mco.configure.world.buttons.close": "暂时关闭Realm", + "mco.configure.world.buttons.delete": "删除", + "mco.configure.world.buttons.done": "完成", + "mco.configure.world.buttons.edit": "设置", + "mco.configure.world.buttons.invite": "邀请玩家", + "mco.configure.world.buttons.invite_codes": "邀请码", + "mco.configure.world.buttons.moreoptions": "更多选项", + "mco.configure.world.buttons.newworld": "新的世界", + "mco.configure.world.buttons.open": "重新打开Realm", + "mco.configure.world.buttons.options": "世界选项", + "mco.configure.world.buttons.players": "玩家", + "mco.configure.world.buttons.region_preference": "选择地区…", + "mco.configure.world.buttons.resetworld": "重置世界", + "mco.configure.world.buttons.save": "保存", + "mco.configure.world.buttons.settings": "设置", + "mco.configure.world.buttons.subscription": "订阅", + "mco.configure.world.buttons.switchminigame": "切换小游戏", + "mco.configure.world.close.question.line1": "你可以暂时关闭Realm,从而防止有人在你调整设置时进入游戏。准备就绪后即可重新打开。\n\n此操作不会取消你的Realms订阅。", + "mco.configure.world.close.question.line2": "你确定要继续吗?", + "mco.configure.world.close.question.title": "要在不中断的情况下更改吗?", + "mco.configure.world.closing": "暂时关闭Realm中…", + "mco.configure.world.commandBlocks": "命令方块", + "mco.configure.world.delete.button": "删除Realm", + "mco.configure.world.delete.question.line1": "你的Realm将被永久删除", + "mco.configure.world.delete.question.line2": "你确定要继续吗?", + "mco.configure.world.description": "Realm描述", + "mco.configure.world.edit.slot.name": "世界名称", + "mco.configure.world.edit.subscreen.adventuremap": "由于你当前的世界是冒险世界,有些选项将会被禁用", + "mco.configure.world.edit.subscreen.experience": "由于你当前的世界是体验世界,有些选项将会被禁用", + "mco.configure.world.edit.subscreen.inspiration": "由于你当前的世界是示例世界,有些选项将会被禁用", + "mco.configure.world.forceGameMode": "强制游戏模式", + "mco.configure.world.invite.narration": "你有%s个新邀请", + "mco.configure.world.invite.profile.name": "名称", + "mco.configure.world.invite_codes.copy": "复制", + "mco.configure.world.invite_codes.create": "创建邀请码", + "mco.configure.world.invite_codes.delete": "删除", + "mco.configure.world.invite_codes.edit": "编辑", + "mco.configure.world.invite_codes.edit.active": "有效", + "mco.configure.world.invite_codes.edit.expiration_date": "有效期", + "mco.configure.world.invite_codes.edit.expires": "有效期:%s", + "mco.configure.world.invite_codes.edit.title": "编辑邀请码", + "mco.configure.world.invite_codes.expiration.12_hours": "12小时", + "mco.configure.world.invite_codes.expiration.1_day": "1天", + "mco.configure.world.invite_codes.expiration.1_hour": "1小时", + "mco.configure.world.invite_codes.expiration.30_minutes": "30分钟", + "mco.configure.world.invite_codes.expiration.6_hours": "6小时", + "mco.configure.world.invite_codes.expiration.7_days": "7天", + "mco.configure.world.invite_codes.expiration.never": "始终有效", + "mco.configure.world.invite_codes.loading": "正在加载邀请码", + "mco.configure.world.invite_codes.subtitle": "你可以创建最多%s个邀请码并与他人分享,让他们加入你的Realm", + "mco.configure.world.invite_codes.title": "邀请码(%s/%s)", + "mco.configure.world.invited": "已邀请", + "mco.configure.world.invited.number": "已邀请(%s)", + "mco.configure.world.invites.normal.tooltip": "普通玩家", + "mco.configure.world.invites.ops.tooltip": "管理员", + "mco.configure.world.invites.remove.tooltip": "移除", + "mco.configure.world.leave.question.line1": "离开该Realm后,除非再次收到邀请,否则其不会再显示", + "mco.configure.world.leave.question.line2": "你确定要继续吗?", + "mco.configure.world.loading": "加载Realm中", + "mco.configure.world.location": "位置", + "mco.configure.world.minigame": "当前:%s", + "mco.configure.world.name": "Realm名称", + "mco.configure.world.name.validation.whitespace": "不能以空格开头或结尾。两端的空格会被移除。", + "mco.configure.world.opening": "开启Realm中…", + "mco.configure.world.players.error": "不存在该指定名称的玩家", + "mco.configure.world.players.invite.duplicate": "此Realm中已邀请过使用该名称的玩家", + "mco.configure.world.players.inviting": "邀请玩家中…", + "mco.configure.world.players.title": "玩家", + "mco.configure.world.pvp": "玩家对战", + "mco.configure.world.region_preference": "地区偏好", + "mco.configure.world.region_preference.title": "地区偏好选择", + "mco.configure.world.renderDistance": "渲染距离限制", + "mco.configure.world.renderDistance.tooltip": "Realm成员可见的最大距离。较高数值可能会影响性能。", + "mco.configure.world.reset.question.line1": "你的世界将重新生成,你将失去当前的世界", + "mco.configure.world.reset.question.line2": "你确定要继续吗?", + "mco.configure.world.resourcepack.question": "这个Realm需要一个自定义的资源包\n\n你想要自动下载并安装它吗?", + "mco.configure.world.resourcepack.question.line1": "这个Realm需要一个自定义的资源包", + "mco.configure.world.resourcepack.question.line2": "你想要自动下载并安装它吗?", + "mco.configure.world.restore.download.question.line1": "世界将会被下载并添加至你的单人游戏世界中。", + "mco.configure.world.restore.download.question.line2": "你确定要继续吗?", + "mco.configure.world.restore.question.line1": "你的Realm世界将被还原至“%s”(%s)", + "mco.configure.world.restore.question.line2": "你确定要继续吗?", + "mco.configure.world.settings.expired": "你无法编辑已过期Realm的设置", + "mco.configure.world.settings.title": "设置", + "mco.configure.world.simulationDistance": "模拟距离", + "mco.configure.world.simulationDistance.tooltip": "玩家周围游戏加载并应用更改的范围。", + "mco.configure.world.slot": "世界%s", + "mco.configure.world.slot.empty": "空", + "mco.configure.world.slot.switch.question.line1": "你的Realm将会被切换至另一个世界", + "mco.configure.world.slot.switch.question.line2": "你确定要继续吗?", + "mco.configure.world.slot.tooltip": "切换到世界", + "mco.configure.world.slot.tooltip.active": "加入", + "mco.configure.world.slot.tooltip.minigame": "切换至小游戏", + "mco.configure.world.spawnAnimals": "生成动物", + "mco.configure.world.spawnMonsters": "生成怪物", + "mco.configure.world.spawnNPCs": "生成NPC", + "mco.configure.world.spawnProtection": "重生点保护", + "mco.configure.world.spawn_toggle.message": "关闭此选项后,所有现有的该类型实体将会被移除", + "mco.configure.world.spawn_toggle.message.npc": "关闭此选项后,所有现有的该类型实体将会被移除,例如村民", + "mco.configure.world.spawn_toggle.title": "警告!", + "mco.configure.world.status": "状态", + "mco.configure.world.subscription.day": "天", + "mco.configure.world.subscription.days": "天", + "mco.configure.world.subscription.expired": "已过期", + "mco.configure.world.subscription.extend": "延长订阅时间", + "mco.configure.world.subscription.less_than_a_day": "不足一天", + "mco.configure.world.subscription.month": "月", + "mco.configure.world.subscription.months": "月", + "mco.configure.world.subscription.recurring.daysleft": "自动续期剩余时间:", + "mco.configure.world.subscription.recurring.info": "对你的Realms订阅所做的更改(如续期和取消自动续订)将在下个结算日生效。", + "mco.configure.world.subscription.remaining.days": "%1$s天", + "mco.configure.world.subscription.remaining.months": "%1$s个月", + "mco.configure.world.subscription.remaining.months.days": "%1$s个月%2$s天", + "mco.configure.world.subscription.start": "开始日期", + "mco.configure.world.subscription.tab": "订阅", + "mco.configure.world.subscription.timeleft": "剩余时间", + "mco.configure.world.subscription.title": "订阅信息", + "mco.configure.world.subscription.unknown": "未知", + "mco.configure.world.switch.slot": "创建世界", + "mco.configure.world.switch.slot.subtitle": "这个世界是空的。请选择创建世界的方式", + "mco.configure.world.title": "配置Realm:", + "mco.configure.world.uninvite.player": "你确定要取消邀请“%s”吗?", + "mco.configure.world.uninvite.question": "你确定要取消邀请吗", + "mco.configure.worlds.title": "世界", + "mco.connect.authorizing": "登录中…", + "mco.connect.connecting": "正在连接至Realm…", + "mco.connect.failed": "无法连接至Realm", + "mco.connect.region": "服务器地区:%s", + "mco.connect.success": "完成", + "mco.create.world": "创建", + "mco.create.world.error": "你必须输入一个名字!", + "mco.create.world.failed": "创建世界失败!", + "mco.create.world.reset.title": "正在创建世界…", + "mco.create.world.skip": "跳过", + "mco.create.world.subtitle": "可选,选择你想放进新Realm的世界", + "mco.create.world.wait": "正在创建Realm…", + "mco.download.cancelled": "下载已取消", + "mco.download.confirmation.line1": "你将下载的世界大小大于%s", + "mco.download.confirmation.line2": "你无法再次将这个世界上传至Realm", + "mco.download.confirmation.oversized": "你将下载的世界大小大于%s\n\n你无法再次将这个世界上传至Realm", + "mco.download.done": "下载完成", + "mco.download.downloading": "正在下载", + "mco.download.extracting": "提取中", + "mco.download.failed": "下载失败", + "mco.download.percent": "%s%%", + "mco.download.preparing": "正在准备下载", + "mco.download.resourcePack.fail": "资源包下载失败!", + "mco.download.speed": "(%s/s)", + "mco.download.speed.narration": "%s每秒", + "mco.download.title": "下载最新的世界", + "mco.error.invalid.session.message": "请尝试重启Minecraft", + "mco.error.invalid.session.title": "无效会话", + "mco.errorMessage.6001": "客户端已过期!", + "mco.errorMessage.6002": "未同意服务条款", + "mco.errorMessage.6003": "超过下载限制", + "mco.errorMessage.6004": "超过上传限制", + "mco.errorMessage.6005": "世界已锁定", + "mco.errorMessage.6006": "世界已过期", + "mco.errorMessage.6007": "玩家所在的Realm数量过多", + "mco.errorMessage.6008": "无效的Realm名称", + "mco.errorMessage.6009": "无效的Realm描述", + "mco.errorMessage.connectionFailure": "出错了。请稍后再试。", + "mco.errorMessage.generic": "出现错误:", + "mco.errorMessage.initialize.failed": "初始化Realm失败", + "mco.errorMessage.noDetails": "未提供错误详情", + "mco.errorMessage.realmsService": "出现错误(%s):", + "mco.errorMessage.realmsService.configurationError": "编辑世界选项时出现意外错误", + "mco.errorMessage.realmsService.connectivity": "无法连接至Realm:%s", + "mco.errorMessage.realmsService.realmsError": "Realms(%s):", + "mco.errorMessage.realmsService.unknownCompatibility": "无法检查兼容版本。响应为:%s", + "mco.errorMessage.retry": "重试操作", + "mco.errorMessage.serviceBusy": "Realms目前处于繁忙状态。\n请在几分钟后再次尝试连接至你的Realm。", + "mco.gui.button": "按钮", + "mco.gui.ok": "确定", + "mco.info": "信息!", + "mco.invited.player.narration": "已邀请玩家%s", + "mco.invites.button.accept": "接受", + "mco.invites.button.reject": "拒绝", + "mco.invites.nopending": "没有未决邀请!", + "mco.invites.pending": "新邀请!", + "mco.invites.title": "未决邀请", + "mco.minigame.world.changeButton": "选择另一个小游戏", + "mco.minigame.world.info.line1": "这会暂时将你的世界替换成一个小游戏!", + "mco.minigame.world.info.line2": "之后你可以毫无损失地返回到原来的世界。", + "mco.minigame.world.noSelection": "请选择", + "mco.minigame.world.restore": "结束小游戏中…", + "mco.minigame.world.restore.question.line1": "小游戏将结束而你的Realm将被恢复。", + "mco.minigame.world.restore.question.line2": "你确定要继续吗?", + "mco.minigame.world.selected": "所选的小游戏:", + "mco.minigame.world.slot.screen.title": "切换世界中…", + "mco.minigame.world.startButton": "切换", + "mco.minigame.world.starting.screen.title": "开始小游戏中…", + "mco.minigame.world.stopButton": "结束小游戏", + "mco.minigame.world.switch.new": "选择另一个小游戏?", + "mco.minigame.world.switch.title": "切换小游戏", + "mco.minigame.world.title": "将Realm切换成小游戏", + "mco.news": "Realms新闻", + "mco.notification.dismiss": "关闭", + "mco.notification.transferSubscription.buttonText": "立即转移", + "mco.notification.transferSubscription.message": "Java版Realms订阅即将转移到Microsoft Store。别让你的订阅过期了!\n现在转移即可免费获得30天的Realms服务。\n请在minecraft.net的“档案”页面转移你的订阅。", + "mco.notification.visitUrl.buttonText.default": "打开链接", + "mco.notification.visitUrl.message.default": "请访问下方的链接", + "mco.onlinePlayers": "在线玩家", + "mco.pdp.friends.description": "你邀请的好友可以随时免费进入你的Realm,即使你不在线。最多可与10名玩家同时在线!", + "mco.pdp.friends.title": "更好的共同游戏方式", + "mco.pdp.minigames.description": "探索不断更新的小游戏、冒险地图和更多内容。", + "mco.pdp.minigames.title": "小游戏 - 免费创作者内容", + "mco.pdp.private.backups": "• 自动备份", + "mco.pdp.private.easyToManage": "• 易于设置,易于管理", + "mco.pdp.private.secure": "• 私密且安全", + "mco.pdp.private.title": "个人服务器", + "mco.pdp.private.worldSlots": "• 三个世界栏位", + "mco.pdp.trial.description": "30天免费试用!", + "mco.pdp.trial.title": "不断线的专属服务器", + "mco.pdp.tryForFree": "免费尝试", + "mco.play.button.realm.closed": "Realm已关闭", + "mco.question": "问题", + "mco.reset.world.adventure": "冒险", + "mco.reset.world.experience": "体验", + "mco.reset.world.generate": "新的世界", + "mco.reset.world.inspiration": "示例", + "mco.reset.world.resetting.screen.title": "重置世界中…", + "mco.reset.world.seed": "世界种子(可选)", + "mco.reset.world.template": "世界模板", + "mco.reset.world.title": "重置世界", + "mco.reset.world.upload": "上传世界", + "mco.reset.world.warning": "这将替换掉你目前的Realm世界", + "mco.selectServer.buy": "购买Realm服务器!", + "mco.selectServer.close": "关闭", + "mco.selectServer.closed": "已失效的Realm", + "mco.selectServer.closeserver": "关闭Realm", + "mco.selectServer.configure": "配置Realm", + "mco.selectServer.configureRealm": "配置Realm", + "mco.selectServer.create": "创建Realm", + "mco.selectServer.create.subtitle": "选择想放进你新Realm的世界", + "mco.selectServer.expired": "已过期的Realm", + "mco.selectServer.expiredList": "你的订阅已过期", + "mco.selectServer.expiredRenew": "续期", + "mco.selectServer.expiredSubscribe": "订阅", + "mco.selectServer.expiredTrial": "你的试用期已结束", + "mco.selectServer.expires.day": "将在一天内过期", + "mco.selectServer.expires.days": "将在%s天后过期", + "mco.selectServer.expires.soon": "即将过期", + "mco.selectServer.freeTrial": "免费试用", + "mco.selectServer.joinCode": "加入代码", + "mco.selectServer.joinCode.hint": "输入加入代码", + "mco.selectServer.joinCode.invalid": "输入的代码无效。", + "mco.selectServer.joinCode.select": "加入Realm", + "mco.selectServer.joinCode.title": "输入加入代码", + "mco.selectServer.joinCode.validating": "验证代码中", + "mco.selectServer.joinRealm": "加入Realm", + "mco.selectServer.leave": "离开Realm", + "mco.selectServer.loading": "加载Realm列表中", + "mco.selectServer.mapOnlySupportedForVersion": "此地图不受%s支持", + "mco.selectServer.minigame": "小游戏:", + "mco.selectServer.minigameName": "小游戏:%s", + "mco.selectServer.minigameNotSupportedInVersion": "无法在%s中进行这个小游戏", + "mco.selectServer.noRealms": "你似乎还没有Realm。添加Realm与朋友们一同畅玩。", + "mco.selectServer.note": "注意:", + "mco.selectServer.open": "已开启的Realm", + "mco.selectServer.openserver": "开启Realm", + "mco.selectServer.play": "开始游戏", + "mco.selectServer.popup": "Realms是一种安全、简单地享受在线Minecraft世界的方式,可支持多达十位好友同时在线。它也支持多种小游戏与海量自定义世界!只有Realm的所有者需要付款。", + "mco.selectServer.purchase": "添加Realm", + "mco.selectServer.trial": "开始试用!", + "mco.selectServer.uninitialized": "点击以创建新的Realm!", + "mco.snapshot.createSnapshotPopup.text": "你即将创建免费的快照版Realm,它会与你的付费Realm订阅绑定。新的快照版Realm在付费订阅生效期间可以随时访问。原本的付费Realm不会受到影响。", + "mco.snapshot.createSnapshotPopup.title": "是否创建快照版Realm?", + "mco.snapshot.creating": "正在创建快照版Realm…", + "mco.snapshot.description": "与%s绑定", + "mco.snapshot.friendsRealm.downgrade": "你需要使用版本%s加入此Realm", + "mco.snapshot.friendsRealm.upgrade": "在你使用此快照进行游戏之前,%s需要升级其Realm", + "mco.snapshot.paired": "此快照版Realm与%s绑定", + "mco.snapshot.parent.tooltip": "使用最新正式版Minecraft以进入此Realm", + "mco.snapshot.start": "开启免费的快照版Realm", + "mco.snapshot.subscription.info": "这是与你的Realm订阅“%s”绑定的快照版Realm。它会在与其绑定的Realm可用时保持可用。", + "mco.snapshot.tooltip": "使用快照版Realm抢先了解即将推出的Minecraft版本,其中可能包括新功能和其他更改。\n\n你可以在游戏的正式版中找到普通的Realm。", + "mco.snapshotRealmsPopup.message": "Realms从23w41a开始可在快照版本中使用。每份Realms订阅中包含一份和你普通Java版Realm相互独立的免费快照版Realm!", + "mco.snapshotRealmsPopup.title": "Realms现可在快照版本中使用", + "mco.snapshotRealmsPopup.urlText": "了解更多", + "mco.template.button.publisher": "发布者", + "mco.template.button.select": "选择", + "mco.template.button.trailer": "预告片", + "mco.template.default.name": "世界模板", + "mco.template.info.tooltip": "发布者的网站", + "mco.template.name": "模板", + "mco.template.select.failure": "我们无法接收这个分类的内容。\n请检查你的网络连接,或稍后再试。", + "mco.template.select.narrate.authors": "作者:%s", + "mco.template.select.narrate.version": "版本:%s", + "mco.template.select.none": "抱歉,看来这个分类目前是空的。\n请之后再回来检查是否有新内容,如果你是一名创作者的话,\n%s。", + "mco.template.select.none.linkTitle": "也可以自己提交一些内容", + "mco.template.title": "世界模板", + "mco.template.title.minigame": "Realm小游戏", + "mco.template.trailer.tooltip": "地图预告", + "mco.terms.buttons.agree": "同意", + "mco.terms.buttons.disagree": "不同意", + "mco.terms.sentence.1": "我已阅读并接受Minecraft Realms的", + "mco.terms.sentence.2": "服务条款", + "mco.terms.title": "Realms服务条款", + "mco.time.daysAgo": "%1$s天前", + "mco.time.hoursAgo": "%1$s小时前", + "mco.time.minutesAgo": "%1$s分钟前", + "mco.time.now": "刚刚", + "mco.time.secondsAgo": "%1$s秒前", + "mco.trial.message.line1": "你想获得属于自己的Realm吗?", + "mco.trial.message.line2": "点击此处了解更多信息!", + "mco.upload.button.name": "上传", + "mco.upload.cancelled": "上传已取消", + "mco.upload.close.failure": "无法关闭你的Realm。请稍后再试。", + "mco.upload.done": "上传已完成", + "mco.upload.entry.cheats": "%1$s, %2$s", + "mco.upload.entry.commands": "%1$s, %2$s", + "mco.upload.entry.id": "%1$s(%2$s)", + "mco.upload.failed": "上传失败!(%s)", + "mco.upload.failed.too_big.description": "选中的世界过大。最大允许大小为%s。", + "mco.upload.failed.too_big.title": "世界过大", + "mco.upload.hardcore": "不能上传极限模式世界!", + "mco.upload.percent": "%s%%", + "mco.upload.preparing": "正在准备你的世界数据", + "mco.upload.select.world.none": "找不到单人游戏世界!", + "mco.upload.select.world.subtitle": "请选择一个单人游戏世界来上传", + "mco.upload.select.world.title": "上传世界", + "mco.upload.size.failure.line1": "“%s”太大了!", + "mco.upload.size.failure.line2": "你想上传%s的地图,超过了%s的限制。", + "mco.upload.uploading": "正在上传“%s”", + "mco.upload.verifying": "正在验证你的世界", + "mco.version": "版本:%s", + "mco.warning": "警告!", + "mco.worldSlot.minigame": "小游戏", + "menu.custom_options": "自定义选项…", + "menu.custom_options.title": "自定义选项", + "menu.custom_options.tooltip": "注:自定义选项由第三方服务器或内容提供。\n请小心操作!", + "menu.custom_screen_info.button_narration": "这是一个自定义屏幕。了解更多信息。", + "menu.custom_screen_info.contents": "此屏幕的内容由第三方服务器或地图控制,不由Mojang Studios或Microsoft拥有、运营和监督。\n\n请小心操作!点击链接时请务必谨慎,确保不要泄露你的个人信息,包括账号密码等。\n\n如果此屏幕阻止你继续,可以点击下方按钮与当前服务器断开连接。", + "menu.custom_screen_info.disconnect": "已拒绝自定义屏幕", + "menu.custom_screen_info.title": "关于自定义屏幕的说明", + "menu.custom_screen_info.tooltip": "这是一个自定义屏幕。点击此处了解更多信息。", + "menu.disconnect": "断开连接", + "menu.disconnect.draft_report.tooltip": "你有一份未发送的玩家举报草稿。", + "menu.feedback": "反馈…", + "menu.feedback.title": "反馈", + "menu.game": "游戏菜单", + "menu.modded": "(已修改)", + "menu.multiplayer": "多人游戏", + "menu.multiplayerOptions.applyChanges": "应用更改", + "menu.multiplayerOptions.button": "对局域网开放", + "menu.multiplayerOptions.lan": "局域网", + "menu.multiplayerOptions.network": "多人游戏", + "menu.multiplayerOptions.network.header": "网络", + "menu.multiplayerOptions.network.lan": "局域网", + "menu.multiplayerOptions.network.lan.tooltip": "处于同一网络上的玩家可加入。", + "menu.multiplayerOptions.network.off": "关", + "menu.multiplayerOptions.network.off.tooltip": "无人可以加入。", + "menu.multiplayerOptions.network.online": "在线", + "menu.multiplayerOptions.network.online.tooltip": "在线好友可以加入。", + "menu.multiplayerOptions.otherPlayers.header": "对其他玩家的设置", + "menu.multiplayerOptions.publish.started.lan": "此世界现已对局域网开放。端口号为%s", + "menu.multiplayerOptions.publish.started.online": "此世界现已对在线多人游戏开放。", + "menu.multiplayerOptions.publish.stopped": "此世界不再对多人游戏开放", + "menu.multiplayerOptions.title": "局域网选项", + "menu.online": "Minecraft Realms", + "menu.options": "选项…", + "menu.other_players": "其他玩家", + "menu.other_players.no_players": "当前其他玩家无法加入此世界", + "menu.paused": "游戏暂停", + "menu.playdemo": "开始试玩世界", + "menu.playerReporting": "举报玩家", + "menu.playerReporting.no_players": "此世界中没有玩家可以举报", + "menu.preparingSpawn": "准备生成区域中:%s%%", + "menu.quick_actions": "快捷操作…", + "menu.quick_actions.title": "快捷操作", + "menu.quit": "退出游戏", + "menu.reportBugs": "报告漏洞", + "menu.resetdemo": "重置试玩世界", + "menu.returnToGame": "回到游戏", + "menu.returnToMenu": "保存并退回到标题屏幕", + "menu.savingChunks": "保存区块中", + "menu.savingLevel": "保存世界中", + "menu.sendFeedback": "提供反馈", + "menu.server_links": "服务器链接…", + "menu.server_links.title": "服务器链接", + "menu.shareToLan": "对局域网开放", + "menu.singleplayer": "单人游戏", + "menu.working": "处理中…", + "merchant.deprecated": "村民每天最多补货两次。", + "merchant.level.1": "新手", + "merchant.level.2": "学徒", + "merchant.level.3": "老手", + "merchant.level.4": "专家", + "merchant.level.5": "大师", + "merchant.title": "%s - %s", + "merchant.trades": "交易", + "mirror.front_back": "↑ ↓", + "mirror.left_right": "← →", + "mirror.none": "|", + "mount.onboard": "按下%1$s来脱离", + "mount.sit": "按下%1$s来起身", + "multiplayer.applyingPack": "正在应用资源包", + "multiplayer.codeOfConduct.check": "不再通知此行为准则", + "multiplayer.codeOfConduct.title": "服务器行为准则", + "multiplayer.confirm_command.parse_errors": "你尝试执行的命令无法识别或无效。\n确定要执行吗?\n命令:%s", + "multiplayer.confirm_command.permissions_required": "你尝试执行的命令需要较高的权限。\n这可能会对游戏产生负面影响。\n确定要执行吗?\n命令:%s", + "multiplayer.confirm_command.run_command": "执行命令", + "multiplayer.confirm_command.signature_required": "你尝试执行的命令将使用你的名称发送聊天消息。\n该命令只能在聊天屏幕执行。\n命令:%s", + "multiplayer.confirm_command.suggest_command": "复制到聊天屏幕", + "multiplayer.confirm_command.title": "确认执行命令", + "multiplayer.disconnect.authservers_down": "身份验证服务器目前处于宕机状态。请稍后再试。抱歉!", + "multiplayer.disconnect.bad_chat_index": "检测到来自此服务器的聊天消息部分缺失或已被重新排序", + "multiplayer.disconnect.banned": "你已被此服务器封禁", + "multiplayer.disconnect.banned.expiration": "\n你的封禁将于%s解除", + "multiplayer.disconnect.banned.reason": "你已被此服务器封禁。\n原因:%s", + "multiplayer.disconnect.banned.reason.default": "已被管理员封禁。", + "multiplayer.disconnect.banned_ip.expiration": "\n你的封禁将于%s解除", + "multiplayer.disconnect.banned_ip.reason": "你的IP已被此服务器封禁。\n原因:%s", + "multiplayer.disconnect.chat_validation_failed": "聊天消息验证失败", + "multiplayer.disconnect.code_of_conduct": "服务器要求接受行为准则", + "multiplayer.disconnect.configuration_error": "配置时出现意外错误", + "multiplayer.disconnect.duplicate_login": "你已在异地登录", + "multiplayer.disconnect.expired_public_key": "玩家档案公钥过期。请确保系统时间同步,然后尝试重启游戏。", + "multiplayer.disconnect.flying": "此服务器未启用飞行", + "multiplayer.disconnect.generic": "连接中断", + "multiplayer.disconnect.idling": "你的未操作时间过长!", + "multiplayer.disconnect.illegal_characters": "非法聊天字符", + "multiplayer.disconnect.incompatible": "客户端不兼容!请使用%s", + "multiplayer.disconnect.invalid_entity_attacked": "试图攻击无效实体", + "multiplayer.disconnect.invalid_packet": "此服务器发送了一个无效的数据包", + "multiplayer.disconnect.invalid_player_data": "无效的玩家数据", + "multiplayer.disconnect.invalid_player_movement": "收到了包含非法玩家移动的数据包", + "multiplayer.disconnect.invalid_public_key_signature": "无效的玩家档案公钥签名。\n请尝试重启游戏。", + "multiplayer.disconnect.invalid_public_key_signature.new": "无效的玩家档案公钥签名。\n请尝试重启游戏。", + "multiplayer.disconnect.invalid_vehicle_movement": "收到了包含非法载具移动的数据包", + "multiplayer.disconnect.ip_banned": "你的IP地址已被此服务器封禁", + "multiplayer.disconnect.kicked": "被管理员踢出游戏", + "multiplayer.disconnect.missing_tags": "从该服务器接收到了不完整标签集。\n请联系服务器管理员。", + "multiplayer.disconnect.name_taken": "此名称已被占用", + "multiplayer.disconnect.not_whitelisted": "你不在此服务器的白名单中!", + "multiplayer.disconnect.out_of_order_chat": "接收到了乱序的聊天数据包。你的系统时间是否被更改过?", + "multiplayer.disconnect.outdated_client": "客户端不兼容!请使用%s", + "multiplayer.disconnect.outdated_server": "客户端不兼容!请使用%s", + "multiplayer.disconnect.rejected": "服务器已拒绝连接", + "multiplayer.disconnect.server_full": "服务器已满!", + "multiplayer.disconnect.server_shutdown": "服务器已关闭", + "multiplayer.disconnect.slow_login": "登录超时", + "multiplayer.disconnect.too_many_pending_chats": "未经确认的聊天消息过多", + "multiplayer.disconnect.transfers_disabled": "此服务器不接受转移", + "multiplayer.disconnect.unexpected_query_response": "从客户端收到意外的自定义数据", + "multiplayer.disconnect.unsigned_chat": "接收到了缺少签名或含有无效签名的聊天数据包。", + "multiplayer.disconnect.unverified_username": "验证用户名失败!", + "multiplayer.downloadingStats": "正在获取统计信息…", + "multiplayer.downloadingTerrain": "加载地形中…", + "multiplayer.lan.server_found": "发现新的服务器:%s", + "multiplayer.message_not_delivered": "无法发送聊天消息,请检查服务器日志:%s", + "multiplayer.other_players.not_available": "“其他玩家”仅在多人游戏世界中可用", + "multiplayer.player.joined": "%s加入了游戏", + "multiplayer.player.joined.renamed": "%s(之前被称为%s)加入了游戏", + "multiplayer.player.left": "%s退出了游戏", + "multiplayer.player.list.hp": "%s生命值", + "multiplayer.player.list.narration": "在线玩家:%s", + "multiplayer.requiredTexturePrompt.disconnect": "此服务器需要自定义资源包", + "multiplayer.requiredTexturePrompt.line1": "这个服务器需要使用自定义的资源包。", + "multiplayer.requiredTexturePrompt.line2": "拒绝使用该自定义资源包将会断开你与该服务器的连接。", + "multiplayer.socialInteractions.not_available": "社交屏幕仅在多人游戏世界中可用", + "multiplayer.status.and_more": "…及其他%s名玩家…", + "multiplayer.status.anonymous_player": "匿名玩家", + "multiplayer.status.cancelled": "已取消", + "multiplayer.status.cannot_connect": "无法连接至服务器", + "multiplayer.status.cannot_resolve": "无法解析主机名", + "multiplayer.status.finished": "已完成", + "multiplayer.status.incompatible": "版本不兼容!", + "multiplayer.status.motd.narration": "服务器信息:%s", + "multiplayer.status.no_connection": "(无连接)", + "multiplayer.status.old": "旧版", + "multiplayer.status.online": "在线", + "multiplayer.status.ping": "%s毫秒", + "multiplayer.status.ping.narration": "延迟为%s毫秒", + "multiplayer.status.pinging": "检测中…", + "multiplayer.status.player_count": "%s/%s", + "multiplayer.status.player_count.narration": "%s名玩家在线,共%s名玩家", + "multiplayer.status.quitting": "正在退出", + "multiplayer.status.request_handled": "状态请求已处理", + "multiplayer.status.unknown": "???", + "multiplayer.status.unrequested": "收到了未请求的状态", + "multiplayer.status.version.narration": "服务器版本:%s", + "multiplayer.stopSleeping": "起床", + "multiplayer.texturePrompt.failure.line1": "无法应用服务器资源包", + "multiplayer.texturePrompt.failure.line2": "所有依赖自定义资源包的功能都有可能不按预期工作", + "multiplayer.texturePrompt.line1": "这个服务器推荐使用自定义的资源包。", + "multiplayer.texturePrompt.line2": "你想要自动下载和安装它吗?", + "multiplayer.texturePrompt.serverPrompt": "%s\n\n来自服务器的消息:\n%s", + "multiplayer.title": "多人游戏", + "multiplayer.unsecureserver.toast": "该服务器上发送的消息可能会被修改,可能无法反映原始消息", + "multiplayer.unsecureserver.toast.title": "无法验证聊天消息", + "multiplayerWarning.check": "不再显示此屏幕", + "multiplayerWarning.header": "警告:在线游戏由第三方提供", + "multiplayerWarning.message": "警告:在线游戏由第三方服务器提供,不由Mojang Studios或Microsoft拥有、运营和监督。在线游戏的过程中,你可能会收到不受规制的聊天消息或者其他用户生成内容,而这些内容可能不适合所有人。", + "music.game.a_familiar_room": "Aaron Cherof - A Familiar Room", + "music.game.an_ordinary_day": "谷冈久美 - An Ordinary Day", + "music.game.ancestry": "Lena Raine - Ancestry", + "music.game.below_and_above": "Amos Roddy - Below and Above", + "music.game.broken_clocks": "Amos Roddy - Broken Clocks", + "music.game.bromeliad": "Aaron Cherof - Bromeliad", + "music.game.clark": "C418 - Clark", + "music.game.comforting_memories": "谷冈久美 - Comforting Memories", + "music.game.creative.aria_math": "C418 - Aria Math", + "music.game.creative.biome_fest": "C418 - Biome Fest", + "music.game.creative.blind_spots": "C418 - Blind Spots", + "music.game.creative.dreiton": "C418 - Dreiton", + "music.game.creative.haunt_muskie": "C418 - Haunt Muskie", + "music.game.creative.taswell": "C418 - Taswell", + "music.game.crescent_dunes": "Aaron Cherof - Crescent Dunes", + "music.game.danny": "C418 - Danny", + "music.game.deeper": "Lena Raine - Deeper", + "music.game.dry_hands": "C418 - Dry Hands", + "music.game.ebb": "fingerspit - Ebb", + "music.game.echo_in_the_wind": "Aaron Cherof - Echo in the Wind", + "music.game.eld_unknown": "Lena Raine - Eld Unknown", + "music.game.end.alpha": "C418 - Alpha", + "music.game.end.boss": "C418 - Boss", + "music.game.end.the_end": "C418 - The End", + "music.game.endless": "Lena Raine - Endless", + "music.game.featherfall": "Aaron Cherof - Featherfall", + "music.game.fireflies": "Amos Roddy - Fireflies", + "music.game.floating_dream": "谷冈久美 - Floating Dream", + "music.game.haggstrom": "C418 - Haggstrom", + "music.game.home": "fingerspit - Home", + "music.game.infinite_amethyst": "Lena Raine - Infinite Amethyst", + "music.game.key": "C418 - Key", + "music.game.komorebi": "谷冈久美 - komorebi", + "music.game.left_to_bloom": "Lena Raine - Left to Bloom", + "music.game.lilypad": "Amos Roddy - Lilypad", + "music.game.living_mice": "C418 - Living Mice", + "music.game.memories": "fingerspit - Memories", + "music.game.mice_on_venus": "C418 - Mice on Venus", + "music.game.minecraft": "C418 - Minecraft", + "music.game.nether.ballad_of_the_cats": "C418 - Ballad of the Cats", + "music.game.nether.concrete_halls": "C418 - Concrete Halls", + "music.game.nether.crimson_forest.chrysopoeia": "Lena Raine - Chrysopoeia", + "music.game.nether.dead_voxel": "C418 - Dead Voxel", + "music.game.nether.nether_wastes.rubedo": "Lena Raine - Rubedo", + "music.game.nether.soulsand_valley.so_below": "Lena Raine - So Below", + "music.game.nether.warmth": "C418 - Warmth", + "music.game.nightly": "fingerspit - Nightly", + "music.game.one_more_day": "Lena Raine - One More Day", + "music.game.os_piano": "Amos Roddy - O's Piano", + "music.game.oxygene": "C418 - Oxygène", + "music.game.pokopoko": "谷冈久美 - pokopoko", + "music.game.puzzlebox": "Aaron Cherof - Puzzlebox", + "music.game.shores": "fingerspit - Shores", + "music.game.stand_tall": "Lena Raine - Stand Tall", + "music.game.subwoofer_lullaby": "C418 - Subwoofer Lullaby", + "music.game.swamp.aerie": "Lena Raine - Aerie", + "music.game.swamp.firebugs": "Lena Raine - Firebugs", + "music.game.swamp.labyrinthine": "Lena Raine - Labyrinthine", + "music.game.sweden": "C418 - Sweden", + "music.game.watcher": "Aaron Cherof - Watcher", + "music.game.water.axolotl": "C418 - Axolotl", + "music.game.water.dragon_fish": "C418 - Dragon Fish", + "music.game.water.shuniji": "C418 - Shuniji", + "music.game.wending": "Lena Raine - Wending", + "music.game.wet_hands": "C418 - Wet Hands", + "music.game.yakusoku": "谷冈久美 - yakusoku", + "music.menu.beginning_2": "C418 - Beginning 2", + "music.menu.floating_trees": "C418 - Floating Trees", + "music.menu.moog_city_2": "C418 - Moog City 2", + "music.menu.mutation": "C418 - Mutation", + "narration.button": "按钮:%s", + "narration.button.usage.focused": "按下Enter键激活", + "narration.button.usage.hovered": "单击鼠标左键激活", + "narration.checkbox": "复选框:%s", + "narration.checkbox.usage.focused": "按下Enter键切换", + "narration.checkbox.usage.focused.check": "按下Enter键勾选", + "narration.checkbox.usage.focused.uncheck": "按下Enter键取消勾选", + "narration.checkbox.usage.hovered": "单击鼠标左键切换", + "narration.checkbox.usage.hovered.check": "单击鼠标左键勾选", + "narration.checkbox.usage.hovered.uncheck": "单击鼠标左键取消勾选", + "narration.component_list.usage": "按下Tab键移动到下一屏幕控件", + "narration.cycle_button.usage.focused": "按下Enter键切换到%s", + "narration.cycle_button.usage.hovered": "单击鼠标左键切换到%s", + "narration.edit_box": "编辑框:%s", + "narration.item": "物品:%s", + "narration.link.usage.focused": "按下Enter键打开链接", + "narration.link.usage.hovered": "单击鼠标左键打开链接", + "narration.recipe": "%s的配方", + "narration.recipe.usage": "单击鼠标左键选中", + "narration.recipe.usage.more": "单击鼠标右键显示更多配方", + "narration.selection.usage": "请按上下方向键移动到另一项", + "narration.slider.usage.focused": "按下键盘左键或键盘右键更改数值", + "narration.slider.usage.focused.keyboard_cannot_change_value": "按下Enter键开始更改滑块值", + "narration.slider.usage.hovered": "拖动滑块更改数值", + "narration.suggestion": "已选中%2$s项建议中的第%1$s项:%3$s", + "narration.suggestion.tooltip": "已选中%2$s项建议中的第%1$s项:%3$s(%4$s)", + "narration.suggestion.usage.cycle.fixed": "按下Tab键切换到下一个建议", + "narration.suggestion.usage.cycle.hidable": "按下Tab键切换到下一个建议,或按下Escape键忽略建议", + "narration.suggestion.usage.fill.fixed": "按下Tab键使用此建议", + "narration.suggestion.usage.fill.hidable": "按下Tab键使用此建议,或按下Escape键忽略建议", + "narration.tab_navigation.mac.usage": "按下Cmd键和Tab键切换标签页", + "narration.tab_navigation.usage": "按下Ctrl键和Tab键切换标签页", + "narrator.button.accessibility": "辅助功能", + "narrator.button.difficulty_lock": "难度锁定", + "narrator.button.difficulty_lock.locked": "已锁定", + "narrator.button.difficulty_lock.unlocked": "未锁定", + "narrator.button.language": "语言", + "narrator.controls.bound": "%s已绑定至%s", + "narrator.controls.reset": "重置%s按钮", + "narrator.controls.unbound": "%s未绑定", + "narrator.joining": "加入中", + "narrator.loading": "加载中:%s", + "narrator.loading.done": "完成", + "narrator.position.list": "已选中列表的第%s行,共%s行", + "narrator.position.object_list": "已选中一列控件中的第%s项,共%s项", + "narrator.position.screen": "第%s个屏幕控件,共%s个", + "narrator.position.tab": "已选中第%s个标签页,共%s个", + "narrator.ready_to_play": "准备就绪", + "narrator.screen.title": "标题屏幕", + "narrator.screen.usage": "使用鼠标指针或Tab键选择屏幕控件", + "narrator.select": "已选择:%s", + "narrator.select.world": "已选择%s,上次进入游戏:%s,%s,%s,版本:%s", + "narrator.select.world_info": "已选择%s,上次进入游戏:%s,%s", + "narrator.toast.disabled": "复述功能已关闭", + "narrator.toast.enabled": "复述功能已开启", + "optimizeWorld.confirm.description": "这将尝试用最新的游戏格式储存所有数据来达到优化世界的效果。取决于世界的状况,这可能会花费不少时间。一旦完成,进行游戏时可能会更流畅,但你的世界将不再与旧版游戏兼容。你确定要继续吗?", + "optimizeWorld.confirm.proceed": "创建备份并优化", + "optimizeWorld.confirm.title": "优化世界", + "optimizeWorld.info.converted": "已更新的区块数:%s", + "optimizeWorld.info.skipped": "已忽略的区块数:%s", + "optimizeWorld.info.total": "总区块数:%s", + "optimizeWorld.progress.counter": "%s / %s", + "optimizeWorld.progress.percentage": "%s%%", + "optimizeWorld.stage.counting": "统计区块中…", + "optimizeWorld.stage.failed": "失败了!:(", + "optimizeWorld.stage.finished": "即将完成…", + "optimizeWorld.stage.finished.chunks": "即将完成区块的升级…", + "optimizeWorld.stage.finished.entities": "即将完成实体的升级…", + "optimizeWorld.stage.finished.poi": "即将完成兴趣点的升级…", + "optimizeWorld.stage.upgrading": "升级所有区块中…", + "optimizeWorld.stage.upgrading.chunks": "升级所有区块中…", + "optimizeWorld.stage.upgrading.entities": "升级所有实体中…", + "optimizeWorld.stage.upgrading.poi": "升级所有兴趣点中…", + "optimizeWorld.title": "正在优化世界“%s”", + "options.accessibility": "辅助功能设置…", + "options.accessibility.high_contrast": "高对比度", + "options.accessibility.high_contrast.error.tooltip": "高对比度资源包不可用。", + "options.accessibility.high_contrast.tooltip": "提高UI控件的对比度。", + "options.accessibility.high_contrast_block_outline": "高对比度方块轮廓", + "options.accessibility.high_contrast_block_outline.tooltip": "提高选中方块的轮廓对比度。", + "options.accessibility.link": "辅助功能指南", + "options.accessibility.menu_background_blurriness": "菜单背景模糊程度", + "options.accessibility.menu_background_blurriness.tooltip": "更改菜单背景的模糊程度。", + "options.accessibility.narrator_hotkey": "复述功能快捷键", + "options.accessibility.narrator_hotkey.mac.tooltip": "允许使用“Cmd + B”开关复述功能。", + "options.accessibility.narrator_hotkey.tooltip": "允许使用“Ctrl + B”开关复述功能。", + "options.accessibility.panorama_speed": "全景图滚动速度", + "options.accessibility.text_background": "文本背景", + "options.accessibility.text_background.chat": "聊天", + "options.accessibility.text_background.everywhere": "全局", + "options.accessibility.text_background_opacity": "文本背景不透明度", + "options.accessibility.title": "辅助功能设置", + "options.accessibility.tooltip": "辅助功能设置", + "options.allowCursorChanges": "允许光标变化", + "options.allowCursorChanges.tooltip": "允许鼠标光标悬停在特定UI控件上时改变形状。", + "options.allowFriendRequests": "允许请求", + "options.allowFriendRequests.tooltip": "允许其他玩家向你发送好友请求。", + "options.allowServerListing": "允许列入服务器玩家列表", + "options.allowServerListing.tooltip": "服务器可能会公开列出目前在线的玩家。\n若关闭此选项,你的名字将不会显示在此列表中。", + "options.ao": "平滑光照", + "options.ao.max": "最大", + "options.ao.min": "最小", + "options.ao.off": "关", + "options.attack.crosshair": "十字准星", + "options.attack.hotbar": "快捷栏", + "options.attackIndicator": "攻击指示器", + "options.audioDevice": "设备", + "options.audioDevice.default": "系统默认", + "options.autoJump": "自动跳跃", + "options.autoSuggestCommands": "命令提示", + "options.autosaveIndicator": "自动保存指示器", + "options.biomeBlendRadius": "生物群系过渡距离", + "options.biomeBlendRadius.1": "关(最快)", + "options.biomeBlendRadius.11": "11×11(极高)", + "options.biomeBlendRadius.13": "13×13(显著)", + "options.biomeBlendRadius.15": "15×15(最高)", + "options.biomeBlendRadius.3": "3×3(快)", + "options.biomeBlendRadius.5": "5×5(普通)", + "options.biomeBlendRadius.7": "7×7(高)", + "options.biomeBlendRadius.9": "9×9(很高)", + "options.blocks": "%s个方块", + "options.chat": "聊天设置…", + "options.chat.color": "颜色", + "options.chat.delay": "聊天延迟:%s秒", + "options.chat.delay_none": "聊天延迟:无", + "options.chat.drafts": "保存未发送的聊天消息", + "options.chat.drafts.tooltip": "未发送的消息将被保存,可以在下次打开聊天时发送。", + "options.chat.height.focused": "聚焦高度", + "options.chat.height.unfocused": "淡化高度", + "options.chat.line_spacing": "行距", + "options.chat.links": "网页链接", + "options.chat.links.prompt": "链接提示", + "options.chat.opacity": "聊天文本不透明度", + "options.chat.scale": "聊天文本大小", + "options.chat.title": "聊天设置", + "options.chat.visibility": "聊天", + "options.chat.visibility.full": "显示", + "options.chat.visibility.hidden": "隐藏", + "options.chat.visibility.system": "仅限命令", + "options.chat.width": "宽度", + "options.chunkFade": "区块淡入时长", + "options.chunkFade.none": "区块淡入:无", + "options.chunkFade.seconds": "区块淡入:%s秒", + "options.chunkFade.tooltip": "区块首次渲染时需要多少秒淡入。", + "options.chunks": "%s个区块", + "options.clouds.fancy": "高品质", + "options.clouds.fast": "流畅", + "options.controls": "按键控制…", + "options.credits_and_attribution": "鸣谢与著作权说明…", + "options.ctrlClickEmulatesRightClick": "右键单击模拟", + "options.ctrlClickEmulatesRightClick.tooltip": "启用后,Control + 左键单击将被视为右键单击。仅适用于 macOS。", + "options.cutoutLeaves": "可透视树叶", + "options.cutoutLeaves.tooltip": "允许透过树叶间隙观察。禁用此功能可以提高性能。", + "options.damageTiltStrength": "受伤抖动效果", + "options.damageTiltStrength.tooltip": "受到伤害导致的游戏视角晃动程度。", + "options.darkMojangStudiosBackgroundColor": "黑白徽标", + "options.darkMojangStudiosBackgroundColor.tooltip": "将Mojang Studios加载屏幕的背景颜色更改为黑色。", + "options.darknessEffectScale": "黑暗脉动效果", + "options.darknessEffectScale.tooltip": "控制监守者或幽匿尖啸体给予的黑暗脉动效果程度。", + "options.debugGuiScale.tooltip": "为调试叠加层单独设置与游戏其他界面不同的界面尺寸。", + "options.debugGuiScale.unchanged": "不变", + "options.difficulty": "难度", + "options.difficulty.easy": "简单", + "options.difficulty.easy.info": "敌对生物会生成,但伤害较低。生命值在饥饿值耗尽后最终会降至5颗心。", + "options.difficulty.hard": "困难", + "options.difficulty.hard.info": "敌对生物会生成,且伤害较高。饥饿值耗尽后最终会饿死。", + "options.difficulty.hardcore": "极限", + "options.difficulty.normal": "普通", + "options.difficulty.normal.info": "敌对生物会生成,且伤害适中。生命值在饥饿值耗尽后最终会降至半颗心。", + "options.difficulty.online": "服务器难度", + "options.difficulty.peaceful": "和平", + "options.difficulty.peaceful.info": "不会生成绝大多数敌对生物。饥饿值不会消耗,且生命值会自然恢复。", + "options.directionalAudio": "定向音频", + "options.directionalAudio.off.tooltip": "经典立体声。", + "options.directionalAudio.on.tooltip": "通过基于HRTF算法的定向环绕音频来改善立体音效的模拟。需使用与HRTF兼容的音频硬件,推荐在使用时佩戴耳机。", + "options.discrete_mouse_scroll": "离散滚动", + "options.entityDistanceScaling": "实体渲染距离", + "options.entityShadows": "实体阴影", + "options.exclusiveFullscreen": "独占全屏", + "options.exclusiveFullscreen.off.tooltip": "在此全屏模式下,所有输入法都应当能正常使用,但自适应同步(Nvidia G-Sync、AMD FreeSync)将无法使用。", + "options.exclusiveFullscreen.on.tooltip": "在此全屏模式下,部分输入法可能无法使用,但自适应同步(Nvidia G-Sync、AMD FreeSync)应当能正常使用。", + "options.exclusiveFullscreen.warningTooltip": "该选项可能会导致某些输入法无法正常使用。", + "options.font": "字体设置…", + "options.font.title": "字体设置", + "options.forceUnicodeFont": "强制使用Unicode字体", + "options.fov": "视场角", + "options.fov.max": "广角", + "options.fov.min": "中", + "options.fovEffectScale": "视场角效果", + "options.fovEffectScale.tooltip": "控制游戏机制效果改变视场角的程度。", + "options.framerate": "%s fps", + "options.framerateLimit": "最大帧率", + "options.framerateLimit.max": "无限制", + "options.friendsList": "好友列表", + "options.friendsList.confirm.message": "使用“好友列表”发送和接收好友请求。\n\n使用你的%s管理在线安全设置。", + "options.friendsList.confirm.message.link": "Microsoft账户", + "options.friendsList.confirm.title": "好友列表", + "options.friendsList.confirm.turnOff": "关闭", + "options.friendsList.confirm.turnOn": "开启", + "options.fullscreen": "全屏", + "options.fullscreen.current": "当前分辨率", + "options.fullscreen.entry": "%s×%s@%sHz(%s位色深)", + "options.fullscreen.exclusive.mode": "独占全屏模式", + "options.fullscreen.exclusive.mode.tooltip": "此设置仅在“独占全屏”启用时生效。", + "options.fullscreen.resolution": "全屏分辨率", + "options.fullscreen.unavailable": "设置不可用", + "options.gamma": "亮度", + "options.gamma.default": "默认", + "options.gamma.max": "明亮", + "options.gamma.min": "昏暗", + "options.generic_value": "%s:%s", + "options.glintSpeed": "附魔光效闪烁速度", + "options.glintSpeed.tooltip": "控制附魔物品光效的闪动速度。", + "options.glintStrength": "附魔光效闪烁强度", + "options.glintStrength.tooltip": "控制附魔物品光效的透明程度。", + "options.graphics": "图像品质", + "options.graphics.custom": "自定义", + "options.graphics.fabulous": "极佳!", + "options.graphics.fabulous.tooltip": "%s画质使用屏幕着色器绘制天气、云以及半透明方块和水后面的粒子。\n这也许会在便携设备和4K显示屏上造成严重的性能负担。", + "options.graphics.fancy": "高品质", + "options.graphics.fancy.tooltip": "高品质画质会为大多数设备平衡性能和画面效果。\n天气、云和粒子可能不会在半透明方块或水的后面显示。", + "options.graphics.fast": "流畅", + "options.graphics.fast.tooltip": "流畅画质将减少雨雪的可见数量。\n树叶等方块的透明效果将被禁用。", + "options.graphics.preset": "预设", + "options.graphics.preset.tooltip": "根据需要为“品质与性能”设置合适的默认值。", + "options.graphics.warning.accept": "在不受支持的情况下继续", + "options.graphics.warning.cancel": "算了", + "options.graphics.warning.message": "检测到你的图形设备不支持%s画质选项。\n\n你可以忽略此提示并继续,但如果你依然选择使用%s画质,你的设备将不会受到支持。", + "options.graphics.warning.renderer": "检测到渲染器:[%s]", + "options.graphics.warning.title": "图形设备不受支持", + "options.graphics.warning.vendor": "检测到厂商:[%s]", + "options.graphics.warning.version": "检测到OpenGL版本:[%s]", + "options.graphicsApi": "图形API", + "options.graphicsApi.default": "默认", + "options.graphicsApi.opengl": "优先使用OpenGL", + "options.graphicsApi.restart": "必须重启游戏才能更改图形API。", + "options.graphicsApi.tooltip": "选择渲染使用的图形API。除非有特殊需求需要更改,否则请维持此设置为默认不变!", + "options.graphicsApi.tooltip.vulkan": "实验性设置。在部分系统上使用可能会降低性能或导致不稳定。", + "options.graphicsApi.vulkan": "优先使用Vulkan(实验性)", + "options.graphicsApi.vulkanNonExperimental": "优先使用Vulkan", + "options.guiScale": "界面尺寸", + "options.guiScale.auto": "自动", + "options.hidden": "隐藏", + "options.hideLightningFlashes": "隐藏天空闪烁效果", + "options.hideLightningFlashes.tooltip": "防止闪电束或其他环境效果使天空闪烁。闪烁源本身仍将可见。", + "options.hideMatchedNames": "隐藏匹配的名称", + "options.hideMatchedNames.tooltip": "第三方服务器也许会发送非标准格式的聊天消息。\n此设置打开后,被隐藏的玩家将会被根据发言者的名字匹配。", + "options.hideSplashTexts": "隐藏闪烁标语", + "options.hideSplashTexts.tooltip": "隐藏主菜单上的黄色闪烁标语。", + "options.improvedTransparency": "改进透明显示", + "options.improvedTransparency.oit.tooltip": "实验性方案,使用顺序无关透明算法,以避免透视多层半透明物体时常见的图形问题。\n此设置会影响性能。", + "options.improvedTransparency.oitWithImprovedFog.tooltip": "实验性方案,使用顺序无关透明算法,以避免透视多层半透明物体时常见的图形问题。此选项还会将天空背景和云与基于渲染距离的雾进行混合,使地形与天空之间的边界不可见。\n此选项会影响性能。", + "options.improvedTransparency.tooltip": "使用屏幕着色器在半透明方块和水后面绘制天气、云和粒子的实验性方案。\n此设置会影响GPU性能。", + "options.inGameNotification": "游戏内通知", + "options.inGameNotification.tooltip": "在游戏内显示好友通知。", + "options.inactivityFpsLimit": "降低帧率", + "options.inactivityFpsLimit.afk": "无操作时", + "options.inactivityFpsLimit.afk.tooltip": "若超过1分钟无操作,帧率将被限制至30。9分钟后进一步降至10。", + "options.inactivityFpsLimit.minimized": "最小化时", + "options.inactivityFpsLimit.minimized.tooltip": "仅在游戏窗口最小化时限制帧率。", + "options.invertMouse": "鼠标反转", + "options.invertMouseX": "反转鼠标X轴", + "options.invertMouseY": "反转鼠标Y轴", + "options.japaneseGlyphVariants": "日本字形变体", + "options.japaneseGlyphVariants.tooltip": "在默认字体中为CJK字符使用日本字形。", + "options.key.hold": "按住", + "options.key.toggle": "切换", + "options.language": "语言…", + "options.language.empty_or_missing_translation": "%s的翻译文件为空或缺失。请重启游戏及启动器并重试。", + "options.language.load_translations_failed": "翻译加载失败", + "options.language.narration": "更改语言", + "options.language.title": "语言", + "options.language.tooltip": "更改语言", + "options.languageAccuracyWarning": "(语言翻译不一定100%%准确)", + "options.languageWarning": "语言翻译不一定100%%准确。", + "options.macFullscreenMenuVisibility": "菜单/程序坞可见性", + "options.macFullscreenMenuVisibility.tooltip": "在非独占全屏模式下,是否可以将鼠标移动到屏幕边缘来显示macOS菜单栏和程序坞。", + "options.mainHand": "主手", + "options.mainHand.left": "左手", + "options.mainHand.right": "右手", + "options.maxAnisotropy": "各向异性过滤", + "options.maxAnisotropy.tooltip": "每个级别都能显著改善纹理在显示时的平滑效果,但会影响性能,并大幅增加显存占用。需要将纹理过滤设置为各向异性。", + "options.mipmapLevels": "Mipmap级别", + "options.modelPart.cape": "披风", + "options.modelPart.hat": "帽子", + "options.modelPart.jacket": "外套", + "options.modelPart.left_pants_leg": "左裤腿", + "options.modelPart.left_sleeve": "左袖", + "options.modelPart.right_pants_leg": "右裤腿", + "options.modelPart.right_sleeve": "右袖", + "options.mouseWheelSensitivity": "滚轮灵敏度", + "options.mouse_settings": "鼠标设置…", + "options.mouse_settings.title": "鼠标设置", + "options.multiplayer.title": "对局域网开放", + "options.multiplier": "%s×", + "options.musicToast": "音乐弹窗", + "options.musicToast.never": "不显示", + "options.musicToast.never.tooltip": "不显示音乐弹窗。", + "options.musicToast.pauseMenu": "暂停菜单", + "options.musicToast.pauseMenu.tooltip": "歌曲正在播放时,在游戏暂停菜单中持续显示音乐弹窗。", + "options.musicToast.pauseMenuAndToast": "暂停菜单和弹窗", + "options.musicToast.pauseMenuAndToast.tooltip": "当有歌曲开始播放时显示弹窗。歌曲正在播放时,会在游戏暂停菜单中持续显示同一弹窗。", + "options.music_frequency": "音乐播放频率", + "options.music_frequency.constant": "持续", + "options.music_frequency.default": "默认", + "options.music_frequency.frequent": "频繁", + "options.music_frequency.tooltip": "调整游戏世界中音乐播放的频率。", + "options.narrator": "复述功能", + "options.narrator.all": "复述所有", + "options.narrator.chat": "复述聊天消息", + "options.narrator.notavailable": "不可用", + "options.narrator.off": "关", + "options.narrator.system": "复述系统消息", + "options.needsRestart": "需要重启游戏才能生效。", + "options.notifications.display_time": "通知显示时长", + "options.notifications.display_time.tooltip": "影响所有通知在屏幕上停留的时长。", + "options.off": "关", + "options.off.composed": "%s:关", + "options.on": "开", + "options.on.composed": "%s:开", + "options.online": "在线选项…", + "options.online.friends.header": "好友列表", + "options.online.realms.header": "Realms", + "options.online.servers.header": "服务器", + "options.online.title": "在线选项", + "options.online.xboxSettings": "Xbox设置…", + "options.onlyShowSecureChat": "仅显示安全的聊天", + "options.onlyShowSecureChat.tooltip": "对于来自其他玩家的消息,仅当其经过验证,确认为由本人发送且未被修改时,才会显示。", + "options.operatorItemsTab": "管理员用品标签页", + "options.particles": "粒子效果", + "options.particles.all": "全部", + "options.particles.decreased": "少量", + "options.particles.minimal": "最少", + "options.percent_add_value": "%s:+%s%%", + "options.percent_value": "%s:%s%%", + "options.pixel_value": "%s:%spx", + "options.prioritizeChunkUpdates": "区块构建器", + "options.prioritizeChunkUpdates.byPlayer": "半阻塞", + "options.prioritizeChunkUpdates.byPlayer.tooltip": "区块中的某些行为会导致区块的重新编译。例如对方块进行放置或破坏。", + "options.prioritizeChunkUpdates.nearby": "全阻塞", + "options.prioritizeChunkUpdates.nearby.tooltip": "附近的区块总会被立刻编译。这可能影响放置或破坏方块时的游戏性能。", + "options.prioritizeChunkUpdates.none": "线程化", + "options.prioritizeChunkUpdates.none.tooltip": "附近的区块会在并行的线程中编译。这可能导致破坏方块时短暂出现图像空洞。", + "options.quitShortcuts": "退出游戏快捷键", + "options.quitShortcuts.tooltip": "能否使用键盘快捷键关闭游戏。禁用时,无法通过Windows和Linux上的Alt + F4以及macOS上的Command + Q退出游戏。", + "options.rawMouseInput": "原始输入", + "options.realmsNotifications": "Realms新闻与邀请", + "options.realmsNotifications.button": "新闻与邀请", + "options.realmsNotifications.tooltip": "在标题屏幕上获取Realms新闻和邀请,并在Realms按钮上显示相应图标。", + "options.reducedDebugInfo": "简化调试信息", + "options.renderClouds": "云", + "options.renderCloudsDistance": "云层距离", + "options.renderDistance": "渲染距离", + "options.resourcepack": "资源包…", + "options.restartRequired": "部分更改需要重启游戏才能生效。", + "options.rotateWithMinecart": "同步矿车转动", + "options.rotateWithMinecart.tooltip": "是否在矿车转动时同步转动玩家视角。仅在开启“矿车改进”实验性设置的世界中可用。", + "options.screenEffectScale": "屏幕扭曲效果", + "options.screenEffectScale.tooltip": "反胃状态效果和下界传送门所使用的屏幕扭曲效果的强度。\n值较低时,反胃的扭曲效果会被一层绿色的视觉效果替代。", + "options.sensitivity": "鼠标灵敏度", + "options.sensitivity.max": "超高速!!!", + "options.sensitivity.min": "*哈欠*", + "options.sharePresence": "在线状态可见性", + "options.sharePresence.all": "全部展示", + "options.sharePresence.all.description": "你将显示为在线且活跃", + "options.sharePresence.all.name": "完全可见", + "options.sharePresence.all.tooltip": "向好友显示你的游戏活动。", + "options.sharePresence.limited": "仅展示部分", + "options.sharePresence.limited.description": "你将显示为在线", + "options.sharePresence.limited.name": "部分可见", + "options.sharePresence.limited.tooltip": "向好友显示在线状态,但不显示你的游戏活动。", + "options.sharePresence.none": "隐藏", + "options.sharePresence.none.description": "你将显示为离线", + "options.sharePresence.none.name": "不可见", + "options.sharePresence.none.tooltip": "始终向好友显示离线状态。", + "options.showNowPlayingToast": "显示音乐弹窗", + "options.showNowPlayingToast.tooltip": "当有歌曲开始播放时显示弹窗。歌曲正在播放时,会在游戏暂停菜单中持续显示同一弹窗。", + "options.showSubtitles": "隐藏式字幕", + "options.showSubtitles.tooltip": "为游戏中播放的声音启用字幕。", + "options.simulationDistance": "模拟距离", + "options.skinCustomisation": "自定义皮肤…", + "options.skinCustomisation.title": "自定义皮肤", + "options.sounds": "音乐和声音…", + "options.sounds.title": "音乐和声音选项", + "options.sprintWindow": "疾跑间隔", + "options.sprintWindow.tooltip": "双击前进键激活疾跑的最大时间间隔,以刻为单位。", + "options.telemetry": "遥测数据…", + "options.telemetry.button": "数据收集", + "options.telemetry.button.tooltip": "“%s”仅包含必要数据。\n“%s”包含可选数据和必要数据。", + "options.telemetry.disabled": "遥测已禁用。", + "options.telemetry.state.all": "全部", + "options.telemetry.state.minimal": "最少", + "options.telemetry.state.none": "无", + "options.textureFiltering": "纹理过滤", + "options.textureFiltering.anisotropic": "各向异性", + "options.textureFiltering.anisotropic.tooltip": "基于硬件的过滤方法,但会影响性能,并大幅增加显存占用。可能不支持所有硬件。", + "options.textureFiltering.none": "无", + "options.textureFiltering.none.tooltip": "纹理显示时不经任何过滤。从特定角度观察方块可能会模糊。", + "options.textureFiltering.rgss": "RGSS", + "options.textureFiltering.rgss.tooltip": "(旋转网格超采样)\n基于着色器的过滤方法,可在适度性能影响下提高纹理品质。", + "options.title": "选项", + "options.touchscreen": "触屏模式", + "options.value": "%s", + "options.video": "视频设置…", + "options.video.display.header": "显示", + "options.video.interface.header": "界面", + "options.video.preferences.header": "偏好设置", + "options.video.quality.header": "品质与性能", + "options.videoTitle": "视频设置", + "options.viewBobbing": "视角摇晃", + "options.vignette": "显示晕影", + "options.vignette.tooltip": "游戏屏幕周围的细微纹理,用于减弱屏幕边缘亮度和警示世界边界。", + "options.visible": "显示", + "options.vsync": "垂直同步", + "options.weatherRadius": "天气效果半径", + "options.weatherRadius.tooltip": "可见雨雪效果区域的半径。对性能影响很小。", + "options.worldOptions.allow_commands.disabled.demo.tooltip": "无法在试玩世界中开启“允许命令”。", + "options.worldOptions.allow_commands.disabled.tooltip": "无法在极限模式世界中开启“允许命令”。", + "options.worldOptions.allow_commands.tooltip": "允许在此世界中使用命令。", + "options.worldOptions.button": "世界选项…", + "options.worldOptions.difficulty.disabled.hardcore.tooltip": "无法在极限模式世界中更改难度。", + "options.worldOptions.difficulty.disabled.locked.tooltip": "难度已锁定。", + "options.worldOptions.difficulty.disabled.operator.tooltip": "更改难度需要管理员权限。", + "options.worldOptions.game_mode": "默认游戏模式", + "options.worldOptions.game_mode.disabled.operator.tooltip": "更改游戏模式需要管理员权限。", + "options.worldOptions.game_mode.disabled.tooltip": "无法在极限模式世界中更改游戏模式。", + "options.worldOptions.game_mode.tooltip": "更改世界的默认游戏模式。", + "options.worldOptions.general.title": "通用", + "options.worldOptions.guest.command_access": "命令使用权限", + "options.worldOptions.guest.command_access.disabled.commands.tooltip": "无法在禁用命令时更改加入世界的玩家的命令使用权限。", + "options.worldOptions.guest.command_access.disabled.scope.tooltip": "无法在“局域网”设置为“关”时更改加入世界的玩家的命令使用权限。", + "options.worldOptions.guest.command_access.tooltip": "控制加入世界的玩家能否使用命令。", + "options.worldOptions.guest.force_game_mode": "强制游戏模式", + "options.worldOptions.guest.force_game_mode.off.commands.tooltip": "其他玩家可以通过命令设置自己的游戏模式。", + "options.worldOptions.guest.force_game_mode.off.hardcore.tooltip": "无法在极限模式世界中更改此设置。", + "options.worldOptions.guest.force_game_mode.off.scope.tooltip": "无法在“局域网”设置为“关”时更改此设置。", + "options.worldOptions.guest.force_game_mode.off.tooltip": "其他玩家将保留其当前游戏模式,且不受世界默认游戏模式的影响。", + "options.worldOptions.guest.force_game_mode.on.tooltip": "其他玩家将被强制设为世界的默认游戏模式。", + "options.worldOptions.multiplayer.title": "多人游戏", + "options.worldOptions.personal_game_mode": "个人游戏模式", + "options.worldOptions.personal_game_mode.tooltip": "更改你在此世界中的游戏模式。", + "options.worldOptions.title": "世界选项", + "outOfMemory.message": "Minecraft已耗尽内存。\n\n原因可能是游戏存在漏洞,或分配给Java虚拟机的内存不足。\n\n为避免世界损坏,当前游戏已退出。我们已尝试腾出内存,让你可以回到主菜单并继续游戏,但这不一定奏效。\n\n如果你再次看见此消息,请重启游戏。", + "outOfMemory.title": "内存溢出!", + "pack.available.title": "可用", + "pack.copyFailure": "复制包失败", + "pack.dropConfirm": "你确定要将这些包添加进Minecraft中吗?", + "pack.dropInfo": "将文件拖放到这个窗口内来添加包", + "pack.dropRejected.message": "以下项目不是有效的包,未能执行复制操作:\n %s", + "pack.dropRejected.title": "所添加的内容不是包", + "pack.folderInfo": "(请将包放在这里)", + "pack.incompatible": "不兼容", + "pack.incompatible.confirm.new": "这个包是为更新的Minecraft版本所打造的,在此版本可能不会正常工作。", + "pack.incompatible.confirm.old": "这个包是为更旧的Minecraft版本所打造的,在此版本可能不会正常工作。", + "pack.incompatible.confirm.title": "你确定要加载此包吗?", + "pack.incompatible.confirm.unknown": "这个包已损坏,或是为未知的Minecraft版本所打造的,在此版本可能不会正常工作。", + "pack.incompatible.new": "(适用于新版本的Minecraft)", + "pack.incompatible.old": "(适用于旧版本的Minecraft)", + "pack.incompatible.unknown": "(已损坏或不兼容)", + "pack.nameAndSource": "%s(%s)", + "pack.openFolder": "打开包文件夹", + "pack.selected.title": "已选", + "pack.source.builtin": "内置", + "pack.source.feature": "功能", + "pack.source.local": "本地", + "pack.source.server": "服务器", + "pack.source.world": "世界", + "painting.dimensions": "%s×%s", + "painting.minecraft.alban.author": "Kristoffer Zetterstrand", + "painting.minecraft.alban.title": "Albanian", + "painting.minecraft.aztec.author": "Kristoffer Zetterstrand", + "painting.minecraft.aztec.title": "de_aztec", + "painting.minecraft.aztec2.author": "Kristoffer Zetterstrand", + "painting.minecraft.aztec2.title": "de_aztec", + "painting.minecraft.backyard.author": "Kristoffer Zetterstrand", + "painting.minecraft.backyard.title": "Backyard", + "painting.minecraft.baroque.author": "Sarah Boeving", + "painting.minecraft.baroque.title": "Baroque", + "painting.minecraft.bomb.author": "Kristoffer Zetterstrand", + "painting.minecraft.bomb.title": "Target Successfully Bombed", + "painting.minecraft.bouquet.author": "Kristoffer Zetterstrand", + "painting.minecraft.bouquet.title": "Bouquet", + "painting.minecraft.burning_skull.author": "Kristoffer Zetterstrand", + "painting.minecraft.burning_skull.title": "Skull On Fire", + "painting.minecraft.bust.author": "Kristoffer Zetterstrand", + "painting.minecraft.bust.title": "Bust", + "painting.minecraft.cavebird.author": "Kristoffer Zetterstrand", + "painting.minecraft.cavebird.title": "Cavebird", + "painting.minecraft.changing.author": "Kristoffer Zetterstrand", + "painting.minecraft.changing.title": "Changing", + "painting.minecraft.cotan.author": "Kristoffer Zetterstrand", + "painting.minecraft.cotan.title": "Cotán", + "painting.minecraft.courbet.author": "Kristoffer Zetterstrand", + "painting.minecraft.courbet.title": "Bonjour Monsieur Courbet", + "painting.minecraft.creebet.author": "Kristoffer Zetterstrand", + "painting.minecraft.creebet.title": "Creebet", + "painting.minecraft.dennis.author": "Sarah Boeving", + "painting.minecraft.dennis.title": "Dennis", + "painting.minecraft.donkey_kong.author": "Kristoffer Zetterstrand", + "painting.minecraft.donkey_kong.title": "Kong", + "painting.minecraft.earth.author": "Mojang", + "painting.minecraft.earth.title": "Earth", + "painting.minecraft.endboss.author": "Kristoffer Zetterstrand", + "painting.minecraft.endboss.title": "Endboss", + "painting.minecraft.fern.author": "Kristoffer Zetterstrand", + "painting.minecraft.fern.title": "Fern", + "painting.minecraft.fighters.author": "Kristoffer Zetterstrand", + "painting.minecraft.fighters.title": "Fighters", + "painting.minecraft.finding.author": "Kristoffer Zetterstrand", + "painting.minecraft.finding.title": "Finding", + "painting.minecraft.fire.author": "Mojang", + "painting.minecraft.fire.title": "Fire", + "painting.minecraft.graham.author": "Kristoffer Zetterstrand", + "painting.minecraft.graham.title": "Graham", + "painting.minecraft.humble.author": "Sarah Boeving", + "painting.minecraft.humble.title": "Humble", + "painting.minecraft.kebab.author": "Kristoffer Zetterstrand", + "painting.minecraft.kebab.title": "Kebab med tre pepperoni", + "painting.minecraft.lowmist.author": "Kristoffer Zetterstrand", + "painting.minecraft.lowmist.title": "Lowmist", + "painting.minecraft.match.author": "Kristoffer Zetterstrand", + "painting.minecraft.match.title": "Match", + "painting.minecraft.meditative.author": "Sarah Boeving", + "painting.minecraft.meditative.title": "Meditative", + "painting.minecraft.orb.author": "Kristoffer Zetterstrand", + "painting.minecraft.orb.title": "Orb", + "painting.minecraft.owlemons.author": "Kristoffer Zetterstrand", + "painting.minecraft.owlemons.title": "Owlemons", + "painting.minecraft.passage.author": "Kristoffer Zetterstrand", + "painting.minecraft.passage.title": "Passage", + "painting.minecraft.pigscene.author": "Kristoffer Zetterstrand", + "painting.minecraft.pigscene.title": "Pigscene", + "painting.minecraft.plant.author": "Kristoffer Zetterstrand", + "painting.minecraft.plant.title": "Paradisträd", + "painting.minecraft.pointer.author": "Kristoffer Zetterstrand", + "painting.minecraft.pointer.title": "Pointer", + "painting.minecraft.pond.author": "Kristoffer Zetterstrand", + "painting.minecraft.pond.title": "Pond", + "painting.minecraft.pool.author": "Kristoffer Zetterstrand", + "painting.minecraft.pool.title": "The Pool", + "painting.minecraft.prairie_ride.author": "Sarah Boeving", + "painting.minecraft.prairie_ride.title": "Prairie Ride", + "painting.minecraft.sea.author": "Kristoffer Zetterstrand", + "painting.minecraft.sea.title": "Seaside", + "painting.minecraft.skeleton.author": "Kristoffer Zetterstrand", + "painting.minecraft.skeleton.title": "Mortal Coil", + "painting.minecraft.skull_and_roses.author": "Kristoffer Zetterstrand", + "painting.minecraft.skull_and_roses.title": "Skull and Roses", + "painting.minecraft.stage.author": "Kristoffer Zetterstrand", + "painting.minecraft.stage.title": "The Stage Is Set", + "painting.minecraft.sunflowers.author": "Kristoffer Zetterstrand", + "painting.minecraft.sunflowers.title": "Sunflowers", + "painting.minecraft.sunset.author": "Kristoffer Zetterstrand", + "painting.minecraft.sunset.title": "sunset_dense", + "painting.minecraft.tides.author": "Kristoffer Zetterstrand", + "painting.minecraft.tides.title": "Tides", + "painting.minecraft.unpacked.author": "Sarah Boeving", + "painting.minecraft.unpacked.title": "Unpacked", + "painting.minecraft.void.author": "Kristoffer Zetterstrand", + "painting.minecraft.void.title": "The void", + "painting.minecraft.wanderer.author": "Kristoffer Zetterstrand", + "painting.minecraft.wanderer.title": "Wanderer", + "painting.minecraft.wasteland.author": "Kristoffer Zetterstrand", + "painting.minecraft.wasteland.title": "Wasteland", + "painting.minecraft.water.author": "Mojang", + "painting.minecraft.water.title": "Water", + "painting.minecraft.wind.author": "Mojang", + "painting.minecraft.wind.title": "Wind", + "painting.minecraft.wither.author": "Mojang", + "painting.minecraft.wither.title": "Wither", + "painting.random": "随机样式", + "parsing.bool.expected": "应为布尔型", + "parsing.bool.invalid": "无效的布尔型数据:应为“true”或“false”,实际为“%s”", + "parsing.double.expected": "应为双精度浮点型", + "parsing.double.invalid": "无效的双精度浮点型数据“%s”", + "parsing.expected": "缺少“%s”", + "parsing.float.expected": "应为浮点型", + "parsing.float.invalid": "无效的浮点型数据“%s”", + "parsing.int.expected": "应为整型", + "parsing.int.invalid": "无效的整型数据“%s”", + "parsing.long.expected": "应为长整型", + "parsing.long.invalid": "无效的长整型数据“%s”", + "parsing.quote.escape": "引号内的字符串包含无效的转义序列“\\%s”", + "parsing.quote.expected.end": "字符串引号不成对", + "parsing.quote.expected.start": "字符串开端应有引号", + "particle.invalidOptions": "无法解析粒子选项:%s", + "particle.notFound": "未知的粒子“%s”", + "permissions.requires.entity": "需要一个实体来执行此命令", + "permissions.requires.player": "需要一名玩家来执行此命令", + "potion.potency.1": "II", + "potion.potency.2": "III", + "potion.potency.3": "IV", + "potion.potency.4": "V", + "potion.potency.5": "VI", + "potion.whenDrank": "当生效后:", + "potion.withAmplifier": "%s %s", + "potion.withDuration": "%s(%s)", + "predicate.unknown": "未知的断言“%s”", + "quickplay.error.invalid_identifier": "无法找到具有指定标识符的世界", + "quickplay.error.realm_connect": "无法连接至此Realm", + "quickplay.error.realm_permission": "权限不足,无法连接至此Realm", + "quickplay.error.title": "无法快速进入游戏", + "realms.configuration.region.australia_east": "澳大利亚新南威尔士州", + "realms.configuration.region.australia_southeast": "澳大利亚维多利亚州", + "realms.configuration.region.brazil_south": "巴西", + "realms.configuration.region.canada_central": "加拿大", + "realms.configuration.region.central_india": "印度", + "realms.configuration.region.central_us": "美国艾奥瓦州", + "realms.configuration.region.east_asia": "香港", + "realms.configuration.region.east_us": "美国弗吉尼亚州", + "realms.configuration.region.east_us_2": "美国北卡罗来纳州", + "realms.configuration.region.france_central": "法国", + "realms.configuration.region.japan_east": "日本东部", + "realms.configuration.region.japan_west": "日本西部", + "realms.configuration.region.korea_central": "韩国", + "realms.configuration.region.mexico_central": "墨西哥", + "realms.configuration.region.north_central_us": "美国伊利诺伊州", + "realms.configuration.region.north_europe": "爱尔兰", + "realms.configuration.region.south_africa_north": "南非", + "realms.configuration.region.south_central_us": "美国得克萨斯州", + "realms.configuration.region.southeast_asia": "新加坡", + "realms.configuration.region.sweden_central": "瑞典", + "realms.configuration.region.uae_north": "阿联酋", + "realms.configuration.region.uk_south": "英国南部", + "realms.configuration.region.west_central_us": "美国犹他州", + "realms.configuration.region.west_europe": "荷兰", + "realms.configuration.region.west_us": "美国加利福尼亚州", + "realms.configuration.region.west_us_2": "美国华盛顿州", + "realms.configuration.region.west_us_3": "美国亚利桑那州", + "realms.configuration.region_preference.automatic_owner": "自动(取决于Realm所有者的延迟)", + "realms.configuration.region_preference.automatic_player": "自动(取决于首个加入会话的玩家)", + "realms.missing.snapshot.error.text": "快照当前不支持Realms", + "recipe.notFound": "未知的配方“%s”", + "recipe.toast.description": "请检查你的配方书", + "recipe.toast.title": "新配方已解锁!", + "record.nowPlaying": "正在播放:%s", + "recover_world.bug_tracker": "报告漏洞", + "recover_world.button": "尝试恢复", + "recover_world.done.failed": "无法从之前的状态恢复。", + "recover_world.done.success": "恢复成功!", + "recover_world.done.title": "恢复已完成", + "recover_world.issue.missing_file": "缺失文件", + "recover_world.issue.none": "没有问题", + "recover_world.message": "尝试读取世界文件夹“%s”时出现以下问题。\n可以尝试从先前的状态恢复此世界,也可以在漏洞追踪器上报告此问题。", + "recover_world.no_fallback": "没有可以恢复的状态", + "recover_world.restore": "尝试恢复", + "recover_world.restoring": "尝试恢复世界中…", + "recover_world.state_entry": "%s的状态:", + "recover_world.state_entry.unknown": "未知", + "recover_world.title": "加载世界失败", + "recover_world.warning": "加载世界摘要失败", + "resourcePack.broken_assets": "检测到损坏的资产", + "resourcePack.high_contrast.name": "高对比度", + "resourcePack.load_fail": "重载资源失败", + "resourcePack.programmer_art.name": "Programmer Art", + "resourcePack.runtime_failure": "检测到资源包错误", + "resourcePack.server.name": "世界指定资源包", + "resourcePack.title": "选择资源包", + "resourcePack.vanilla.description": "Minecraft的默认观感", + "resourcePack.vanilla.name": "默认", + "resourcepack.downloading": "正在下载资源包", + "resourcepack.progress": "下载文件中(%s MB)…", + "resourcepack.requesting": "正在发送请求…", + "restrictions_screen.button": "行为限制…", + "restrictions_screen.permission.receive_player_messages.allowed": "你可以收到来自玩家的消息", + "restrictions_screen.permission.receive_player_messages.denied": "你无法收到来自玩家的消息", + "restrictions_screen.permission.receive_player_messages.friends_only": "你只能接收来自好友的消息", + "restrictions_screen.permission.receive_system_messages.allowed": "你可以收到来自服务器的系统消息", + "restrictions_screen.permission.receive_system_messages.denied": "你无法收到来自服务器的系统消息", + "restrictions_screen.permission.send_commands.allowed": "你可以发送命令", + "restrictions_screen.permission.send_commands.denied": "你无法发送命令", + "restrictions_screen.permission.send_messages.allowed": "你可以发送聊天消息", + "restrictions_screen.permission.send_messages.denied": "你无法发送聊天消息", + "restrictions_screen.title": "行为限制", + "screenshot.failure": "无法保存截图:%s", + "screenshot.success": "已将截图保存为%s", + "selectServer.add": "添加服务器", + "selectServer.defaultName": "Minecraft服务器", + "selectServer.delete": "删除", + "selectServer.deleteButton": "删除", + "selectServer.deleteQuestion": "你确定要删除此服务器吗?", + "selectServer.deleteWarning": "“%s”将会永久消失!(真的很久!)", + "selectServer.direct": "直接连接", + "selectServer.edit": "编辑", + "selectServer.hiddenAddress": "(隐藏)", + "selectServer.refresh": "刷新", + "selectServer.select": "加入服务器", + "selectWorld.access_failure": "进入世界失败", + "selectWorld.allowCommands": "允许作弊", + "selectWorld.allowCommands.info": "例如/gamemode、/experience等命令", + "selectWorld.allowCommands.new": "允许命令", + "selectWorld.backupEraseCache": "清除缓存数据", + "selectWorld.backupJoinConfirmButton": "创建备份并加载", + "selectWorld.backupJoinSkipButton": "我知道我在做什么!", + "selectWorld.backupQuestion.customized": "自定义世界已不受支持", + "selectWorld.backupQuestion.downgrade": "不支持对存档版本进行降级", + "selectWorld.backupQuestion.experimental": "不支持使用实验性设置的世界", + "selectWorld.backupQuestion.file_fixing_required": "升级此世界前先创建备份?", + "selectWorld.backupQuestion.snapshot": "你真的想加载此世界吗?", + "selectWorld.backupWarning.customized": "抱歉,此Minecraft版本不支持自定义世界。我们可以继续加载这个世界并保持原状,但任何新生成的地形将不再被自定义。若有不便还请谅解。", + "selectWorld.backupWarning.downgrade": "这个世界上次是在%s版本中打开的,你正在使用%s版本。降低世界的游戏版本可能会导致存档损坏,我们无法保证它可以被加载和运行。如果你仍要继续,请备份该存档。", + "selectWorld.backupWarning.experimental": "这个世界使用的实验性设置可能会随时停止运作。我们无法保证这些设置将来能够加载或运作。务必谨慎!", + "selectWorld.backupWarning.file_fixing_required": "此世界需要升级才能打开。我们强烈建议在继续前创建备份,以防世界损坏。", + "selectWorld.backupWarning.snapshot": "此世界上次打开于%s版本,而你正在使用%s版本。请先创建备份以防世界损坏。", + "selectWorld.bonusItems": "奖励箱", + "selectWorld.cheats": "作弊", + "selectWorld.commands": "命令", + "selectWorld.conversion": "必须进行转换!", + "selectWorld.conversion.tooltip": "此世界必须在较旧版本(比如1.6.4)中打开,以便安全转换。", + "selectWorld.create": "创建新的世界", + "selectWorld.customizeType": "自定义", + "selectWorld.dataPacks": "数据包", + "selectWorld.data_read": "读取世界数据中…", + "selectWorld.delete": "删除", + "selectWorld.deleteButton": "删除", + "selectWorld.deleteQuestion": "你确定要删除这个世界吗?", + "selectWorld.deleteWarning": "“%s”将会永久消失!(真的很久!)", + "selectWorld.delete_failure": "删除世界失败", + "selectWorld.edit": "编辑", + "selectWorld.edit.backup": "进行备份", + "selectWorld.edit.backupCreated": "已备份:%s", + "selectWorld.edit.backupFailed": "备份失败", + "selectWorld.edit.backupFolder": "打开备份文件夹", + "selectWorld.edit.backupSize": "大小:%s MB", + "selectWorld.edit.export_worldgen_settings": "导出世界生成设置", + "selectWorld.edit.export_worldgen_settings.failure": "导出失败", + "selectWorld.edit.export_worldgen_settings.success": "导出成功", + "selectWorld.edit.openFolder": "打开世界文件夹", + "selectWorld.edit.optimize": "优化世界", + "selectWorld.edit.resetIcon": "重置图标", + "selectWorld.edit.save": "保存", + "selectWorld.edit.title": "编辑世界", + "selectWorld.enterName": "世界名称", + "selectWorld.enterSeed": "世界生成器的种子", + "selectWorld.experimental": "实验性", + "selectWorld.experimental.details": "详细信息", + "selectWorld.experimental.details.entry": "需要的实验性功能:%s", + "selectWorld.experimental.details.title": "实验性功能需求", + "selectWorld.experimental.message": "请注意!\n此配置需要使用仍在开发中的功能。你的世界可能会崩溃、损坏或不兼容未来的更新。", + "selectWorld.experimental.title": "实验性功能警告", + "selectWorld.experiments": "实验性内容", + "selectWorld.experiments.info": "实验性内容,即仍在开发的新功能。请小心,这些内容存在损坏风险。已开启的实验性内容在创建世界后将无法再关闭。", + "selectWorld.futureworld.error.text": "试图加载来自未来版本的世界时发生错误。这本就并非万无一失,很抱歉没能成功。", + "selectWorld.futureworld.error.title": "出错了!", + "selectWorld.gameMode": "游戏模式", + "selectWorld.gameMode.adventure": "冒险", + "selectWorld.gameMode.adventure.info": "与生存模式相同,但无法放置或破坏方块。", + "selectWorld.gameMode.adventure.line1": "与生存模式相同,但无法", + "selectWorld.gameMode.adventure.line2": "放置或破坏方块", + "selectWorld.gameMode.creative": "创造", + "selectWorld.gameMode.creative.info": "无拘无束地探索创造。可以飞行,材料取之不尽,且不受怪物伤害。", + "selectWorld.gameMode.creative.line1": "无限的资源、自由地飞翔", + "selectWorld.gameMode.creative.line2": "并且能够瞬间破坏方块", + "selectWorld.gameMode.hardcore": "极限", + "selectWorld.gameMode.hardcore.info": "难度锁定为“困难”的生存模式,且死亡后无法重生。", + "selectWorld.gameMode.hardcore.line1": "难度锁定在困难的生存模式", + "selectWorld.gameMode.hardcore.line2": "且只有一条生命", + "selectWorld.gameMode.spectator": "旁观", + "selectWorld.gameMode.spectator.info": "你可以旁观,但不能互动。", + "selectWorld.gameMode.spectator.line1": "你可以旁观,但不能互动", + "selectWorld.gameMode.survival": "生存", + "selectWorld.gameMode.survival.info": "探索未知的世界,尽情建造、收集、合成并与怪物战斗。", + "selectWorld.gameMode.survival.line1": "探索世界、收集资源、合成道具、", + "selectWorld.gameMode.survival.line2": "提高等级、补充体力和生命值", + "selectWorld.gameRules": "游戏规则", + "selectWorld.import_worldgen_settings": "导入设置", + "selectWorld.import_worldgen_settings.failure": "导入设置时出错", + "selectWorld.import_worldgen_settings.select_file": "选择设置文件(.json)", + "selectWorld.incompatible.description": "此世界无法在此版本中打开。\n最后一次打开此世界的版本是%s。", + "selectWorld.incompatible.info": "不兼容的版本:%s", + "selectWorld.incompatible.title": "版本不兼容", + "selectWorld.incompatible.tooltip": "此世界在不兼容的版本上创建,故无法打开。", + "selectWorld.incompatible_series": "创建于不兼容的版本", + "selectWorld.load_folder_access": "无法读取或访问游戏世界存档所在的文件夹!", + "selectWorld.loading_list": "加载世界列表中", + "selectWorld.locked": "被另一个正在运行的Minecraft实例锁定。", + "selectWorld.mapFeatures": "生成结构", + "selectWorld.mapFeatures.info": "村庄、沉船等", + "selectWorld.mapType": "世界类型", + "selectWorld.mapType.normal": "普通", + "selectWorld.moreWorldOptions": "更多世界选项…", + "selectWorld.newWorld": "新的世界", + "selectWorld.recreate": "重建", + "selectWorld.recreate.customized.text": "自定义世界在这个版本的Minecraft中已不受支持。我们可以尝试用同样的种子与选项重建它,但任何自定义的地形都会丢失。抱歉给你带来不便。", + "selectWorld.recreate.customized.title": "自定义世界已不受支持", + "selectWorld.recreate.error.text": "尝试重建世界时出错。", + "selectWorld.recreate.error.title": "出错了!", + "selectWorld.requiresFileFixingTooltip.edit": "由于世界格式的底层更改,此世界需要升级到最新版本才能编辑。", + "selectWorld.requiresFileFixingTooltip.play": "由于世界格式的底层更改,此世界需要升级到最新版本才能打开。", + "selectWorld.requiresFileFixingTooltip.recreate": "由于世界格式的底层更改,此世界需要升级到最新版本才能重建。", + "selectWorld.resource_load": "准备资源中…", + "selectWorld.resultFolder": "将会保存于:", + "selectWorld.search": "搜索世界", + "selectWorld.seedInfo": "留空以生成随机种子", + "selectWorld.select": "进入选中的世界", + "selectWorld.targetFolder": "存档文件夹:%s", + "selectWorld.title": "选择世界", + "selectWorld.tooltip.fromNewerVersion1": "世界是在更新的版本中被保存的,", + "selectWorld.tooltip.fromNewerVersion2": "加载这个世界可能会产生问题!", + "selectWorld.tooltip.snapshot1": "在这个快照中加载它之前,", + "selectWorld.tooltip.snapshot2": "不要忘了备份这个世界!", + "selectWorld.unable_to_load": "无法加载世界", + "selectWorld.upgrade_and_play": "升级并打开世界", + "selectWorld.version": "版本:", + "selectWorld.versionJoinButton": "仍然加载", + "selectWorld.versionQuestion": "你真的想加载此世界吗?", + "selectWorld.versionUnknown": "未知", + "selectWorld.versionWarning": "此世界上次是在%s版本中打开的,在此版本中加载可能会导致数据损坏!", + "selectWorld.waitingForBackup.message": "此过程可能需要一些时间,具体取决于世界大小。请勿关闭游戏或设备。", + "selectWorld.waitingForBackup.title": "创建备份中", + "selectWorld.warning.deprecated.question": "一些使用中的功能已被淘汰并会在将来失效。你确定要继续吗?", + "selectWorld.warning.deprecated.title": "警告!这些设置使用了已淘汰的功能", + "selectWorld.warning.experimental.question": "这些设置是实验性的,将来可能停止运作。你确定要继续吗?", + "selectWorld.warning.experimental.title": "警告!这些设置使用了实验性功能", + "selectWorld.warning.lowDiskSpace.description": "设备上的存储空间不足。\n运行游戏时磁盘空间不足可能会导致世界损坏。", + "selectWorld.warning.lowDiskSpace.title": "警告!磁盘空间不足!", + "selectWorld.world": "世界", + "selectWorld.world_gen_settings_access": "无法读取或访问世界生成设置文件!%s", + "selecteWorld.backupRequiredTooltip": "加载此世界前需要备份", + "sign.back_text": "背面文本:", + "sign.click_actions_disabled": "此告示牌的点击操作已被禁用", + "sign.edit": "编辑告示牌消息", + "sign.front_text": "正面文本:", + "sleep.not_possible": "已入睡玩家的数量不足以跳过夜晚", + "sleep.players_sleeping": "%s/%s名玩家已入睡", + "sleep.skipping_night": "今夜将在睡梦中度过", + "slot.only_single_allowed": "只允许有单个槽位:实际为“%s”", + "slot.unknown": "未知的槽位“%s”", + "snbt.parser.empty_key": "键不能为空", + "snbt.parser.expected_binary_numeral": "应为二进制数", + "snbt.parser.expected_decimal_numeral": "应为十进制数", + "snbt.parser.expected_float_type": "应为浮点数", + "snbt.parser.expected_hex_escape": "字符字面量长度应为%s", + "snbt.parser.expected_hex_numeral": "应为十六进制数", + "snbt.parser.expected_integer_type": "应为整数", + "snbt.parser.expected_non_negative_number": "应为非负数", + "snbt.parser.expected_number_or_boolean": "应为数字或布尔型", + "snbt.parser.expected_string_uuid": "应为表示有效UUID的字符串", + "snbt.parser.expected_unquoted_string": "应为有效的无引号字符串", + "snbt.parser.infinity_not_allowed": "不允许使用非有限数的数值", + "snbt.parser.invalid_array_element_type": "无效的数组元素类型", + "snbt.parser.invalid_character_name": "无效的Unicode字符名称", + "snbt.parser.invalid_codepoint": "无效的Unicode字符码位:%s", + "snbt.parser.invalid_string_contents": "无效的字符串内容", + "snbt.parser.invalid_unquoted_start": "无引号字符串不能以数字0-9、+或-开头", + "snbt.parser.leading_zero_not_allowed": "十进制数不能以0开头", + "snbt.parser.no_such_operation": "不存在的操作:%s", + "snbt.parser.number_parse_failure": "解析数字失败:%s", + "snbt.parser.undescore_not_allowed": "数字的开头和结尾不允许使用下划线字符", + "soundCategory.ambient": "环境", + "soundCategory.block": "方块", + "soundCategory.hostile": "敌对生物", + "soundCategory.master": "主音量", + "soundCategory.music": "音乐", + "soundCategory.neutral": "友好生物", + "soundCategory.player": "玩家", + "soundCategory.record": "唱片机/音符盒", + "soundCategory.ui": "用户界面", + "soundCategory.voice": "复述功能/语音", + "soundCategory.weather": "天气", + "spectator.cannot_teleport": "无法传送。另一侧尚未生成。", + "spectatorMenu.close": "关闭菜单", + "spectatorMenu.next_page": "下一页", + "spectatorMenu.previous_page": "上一页", + "spectatorMenu.root.prompt": "按下任意键选择命令,再次按下即可使用。", + "spectatorMenu.team_teleport": "传送到队伍成员", + "spectatorMenu.team_teleport.prompt": "选择一支队伍作为传送目标", + "spectatorMenu.teleport": "传送到玩家", + "spectatorMenu.teleport.prompt": "选择一名玩家作为传送目标", + "stat.generalButton": "通用", + "stat.itemsButton": "物品", + "stat.minecraft.animals_bred": "繁殖动物次数", + "stat.minecraft.aviate_one_cm": "鞘翅滑行距离", + "stat.minecraft.bell_ring": "鸣钟次数", + "stat.minecraft.boat_one_cm": "坐船移动距离", + "stat.minecraft.clean_armor": "清洗盔甲次数", + "stat.minecraft.clean_banner": "清洗旗帜次数", + "stat.minecraft.clean_shulker_box": "潜影盒清洗次数", + "stat.minecraft.climb_one_cm": "攀爬距离", + "stat.minecraft.crouch_one_cm": "潜行距离", + "stat.minecraft.damage_absorbed": "吸收伤害量", + "stat.minecraft.damage_blocked_by_shield": "盾牌格挡伤害量", + "stat.minecraft.damage_dealt": "造成伤害量", + "stat.minecraft.damage_dealt_absorbed": "造成伤害量(被吸收)", + "stat.minecraft.damage_dealt_resisted": "造成伤害量(被抵挡)", + "stat.minecraft.damage_resisted": "抵挡伤害量", + "stat.minecraft.damage_taken": "承受伤害量", + "stat.minecraft.deaths": "死亡次数", + "stat.minecraft.drop": "丢弃物品次数", + "stat.minecraft.eat_cake_slice": "食用蛋糕片数", + "stat.minecraft.enchant_item": "物品附魔次数", + "stat.minecraft.fall_one_cm": "摔落高度", + "stat.minecraft.fill_cauldron": "炼药锅装水次数", + "stat.minecraft.fish_caught": "捕鱼数", + "stat.minecraft.fly_one_cm": "飞行距离", + "stat.minecraft.happy_ghast_one_cm": "骑快乐恶魂移动距离", + "stat.minecraft.horse_one_cm": "骑马移动距离", + "stat.minecraft.inspect_dispenser": "搜查发射器次数", + "stat.minecraft.inspect_dropper": "搜查投掷器次数", + "stat.minecraft.inspect_hopper": "搜查漏斗次数", + "stat.minecraft.interact_with_anvil": "与铁砧交互次数", + "stat.minecraft.interact_with_beacon": "与信标交互次数", + "stat.minecraft.interact_with_blast_furnace": "与高炉交互次数", + "stat.minecraft.interact_with_brewingstand": "与酿造台交互次数", + "stat.minecraft.interact_with_campfire": "与营火交互次数", + "stat.minecraft.interact_with_cartography_table": "与制图台交互次数", + "stat.minecraft.interact_with_crafting_table": "与工作台交互次数", + "stat.minecraft.interact_with_furnace": "与熔炉交互次数", + "stat.minecraft.interact_with_grindstone": "与砂轮交互次数", + "stat.minecraft.interact_with_lectern": "与讲台交互次数", + "stat.minecraft.interact_with_loom": "与织布机交互次数", + "stat.minecraft.interact_with_smithing_table": "与锻造台交互次数", + "stat.minecraft.interact_with_smoker": "与烟熏炉交互次数", + "stat.minecraft.interact_with_stonecutter": "与切石机交互次数", + "stat.minecraft.jump": "跳跃次数", + "stat.minecraft.leave_game": "游戏退出次数", + "stat.minecraft.minecart_one_cm": "坐矿车移动距离", + "stat.minecraft.mob_kills": "生物击杀数", + "stat.minecraft.nautilus_one_cm": "骑鹦鹉螺移动距离", + "stat.minecraft.open_barrel": "木桶打开次数", + "stat.minecraft.open_chest": "箱子打开次数", + "stat.minecraft.open_enderchest": "末影箱打开次数", + "stat.minecraft.open_shulker_box": "潜影盒打开次数", + "stat.minecraft.pig_one_cm": "骑猪移动距离", + "stat.minecraft.play_noteblock": "音符盒播放次数", + "stat.minecraft.play_record": "播放唱片数", + "stat.minecraft.play_time": "游戏时长", + "stat.minecraft.player_kills": "玩家击杀数", + "stat.minecraft.pot_flower": "盆栽种植数", + "stat.minecraft.raid_trigger": "触发袭击次数", + "stat.minecraft.raid_win": "袭击胜利次数", + "stat.minecraft.sleep_in_bed": "床上入眠次数", + "stat.minecraft.sleep_in_straw_bed": "麦秆床上入眠次数", + "stat.minecraft.sneak_time": "潜行时间", + "stat.minecraft.sprint_one_cm": "疾跑距离", + "stat.minecraft.strider_one_cm": "骑炽足兽移动距离", + "stat.minecraft.swim_one_cm": "游泳距离", + "stat.minecraft.talked_to_villager": "村民交互次数", + "stat.minecraft.target_hit": "击中标靶次数", + "stat.minecraft.time_since_death": "自上次死亡", + "stat.minecraft.time_since_rest": "自上次入眠", + "stat.minecraft.total_world_time": "世界打开时间", + "stat.minecraft.traded_with_villager": "村民交易次数", + "stat.minecraft.trigger_trapped_chest": "陷阱箱触发次数", + "stat.minecraft.tune_noteblock": "音符盒调音次数", + "stat.minecraft.use_cauldron": "从炼药锅取水次数", + "stat.minecraft.walk_on_water_one_cm": "水面行走距离", + "stat.minecraft.walk_one_cm": "行走距离", + "stat.minecraft.walk_under_water_one_cm": "水下行走距离", + "stat.mobsButton": "生物", + "stat_type.minecraft.broken": "损坏次数", + "stat_type.minecraft.crafted": "合成次数", + "stat_type.minecraft.dropped": "丢弃个数", + "stat_type.minecraft.killed": "你杀死了%2$s%1$s次", + "stat_type.minecraft.killed.none": "你从来没有杀死过%s", + "stat_type.minecraft.killed_by": "%s杀死了你%s次", + "stat_type.minecraft.killed_by.none": "你从来没有被%s杀死过", + "stat_type.minecraft.mined": "开采次数", + "stat_type.minecraft.picked_up": "拾起个数", + "stat_type.minecraft.used": "使用次数", + "stats.none": "-", + "structure_block.button.detect_size": "探测", + "structure_block.button.load": "加载", + "structure_block.button.save": "保存", + "structure_block.custom_data": "自定义数据标签名", + "structure_block.detect_size": "探测结构大小和位置:", + "structure_block.hover.corner": "角落:%s", + "structure_block.hover.data": "数据:%s", + "structure_block.hover.load": "加载:%s", + "structure_block.hover.save": "保存:%s", + "structure_block.include_entities": "包括实体:", + "structure_block.integrity": "结构完整性及种子", + "structure_block.integrity.integrity": "结构完整性", + "structure_block.integrity.seed": "结构种子", + "structure_block.invalid_structure_name": "无效的结构名“%s”", + "structure_block.load_not_found": "不存在名为“%s”的结构", + "structure_block.load_prepare": "结构“%s”的加载位置已就绪", + "structure_block.load_success": "成功从“%s”中加载结构", + "structure_block.mode.corner": "角落模式", + "structure_block.mode.data": "数据模式", + "structure_block.mode.load": "加载模式", + "structure_block.mode.save": "保存模式", + "structure_block.mode_info.corner": "角落模式 — 位置和大小标记", + "structure_block.mode_info.data": "数据模式 — 游戏逻辑标记", + "structure_block.mode_info.load": "加载模式 — 从文件中加载", + "structure_block.mode_info.save": "保存模式 — 写入文件", + "structure_block.position": "相对位置", + "structure_block.position.x": "相对X坐标", + "structure_block.position.y": "相对Y坐标", + "structure_block.position.z": "相对Z坐标", + "structure_block.save_failure": "无法保存结构“%s”", + "structure_block.save_success": "成功将结构保存为“%s”", + "structure_block.show_air": "显示隐形方块:", + "structure_block.show_boundingbox": "显示边框:", + "structure_block.size": "结构大小", + "structure_block.size.x": "结构X轴大小", + "structure_block.size.y": "结构Y轴大小", + "structure_block.size.z": "结构Z轴大小", + "structure_block.size_failure": "无法检测结构大小。请放置与结构名称对应的角落结构方块", + "structure_block.size_success": "“%s”的大小已成功检测", + "structure_block.strict": "严格放置:", + "structure_block.structure_name": "结构名称", + "subtitles.ambient.cave": "怪异的噪声", + "subtitles.ambient.sound": "怪异的噪声", + "subtitles.block.amethyst_block.chime": "紫水晶:叮铃", + "subtitles.block.amethyst_block.resonate": "紫水晶:共振", + "subtitles.block.anvil.destroy": "铁砧:损坏", + "subtitles.block.anvil.land": "铁砧:着陆", + "subtitles.block.anvil.use": "铁砧:被使用", + "subtitles.block.barrel.close": "木桶:关闭", + "subtitles.block.barrel.open": "木桶:打开", + "subtitles.block.beacon.activate": "信标:激活", + "subtitles.block.beacon.ambient": "信标:嗡嗡作响", + "subtitles.block.beacon.deactivate": "信标:失效", + "subtitles.block.beacon.power_select": "信标效果:被选中", + "subtitles.block.beehive.drip": "蜂蜜:滴落", + "subtitles.block.beehive.enter": "蜜蜂:入巢", + "subtitles.block.beehive.exit": "蜜蜂:离巢", + "subtitles.block.beehive.shear": "剪刀:刮削", + "subtitles.block.beehive.work": "蜜蜂:工作", + "subtitles.block.bell.resonate": "钟:回响", + "subtitles.block.bell.use": "钟:响起", + "subtitles.block.big_dripleaf.tilt_down": "垂滴叶:折下", + "subtitles.block.big_dripleaf.tilt_up": "垂滴叶:升起", + "subtitles.block.blastfurnace.fire_crackle": "高炉:噼啪作响", + "subtitles.block.brewing_stand.brew": "酿造台:冒泡", + "subtitles.block.bubble_column.bubble_pop": "气泡:破裂", + "subtitles.block.bubble_column.upwards_ambient": "气泡:上浮", + "subtitles.block.bubble_column.upwards_inside": "气泡:上升", + "subtitles.block.bubble_column.whirlpool_ambient": "气泡:旋转", + "subtitles.block.bubble_column.whirlpool_inside": "气泡:骤降", + "subtitles.block.button.click": "按钮:咔哒", + "subtitles.block.cake.add_candle": "蛋糕:吧唧", + "subtitles.block.campfire.crackle": "营火:噼啪作响", + "subtitles.block.candle.crackle": "蜡烛:噼啪作响", + "subtitles.block.candle.extinguish": "蜡烛:熄灭", + "subtitles.block.chest.close": "箱子:关闭", + "subtitles.block.chest.locked": "箱子:锁哐当作响", + "subtitles.block.chest.open": "箱子:开启", + "subtitles.block.chorus_flower.death": "紫颂花:凋零", + "subtitles.block.chorus_flower.grow": "紫颂花:生长", + "subtitles.block.comparator.click": "比较器:模式变更", + "subtitles.block.composter.empty": "堆肥桶:清空", + "subtitles.block.composter.fill": "堆肥桶:被填充", + "subtitles.block.composter.ready": "堆肥桶:堆肥", + "subtitles.block.conduit.activate": "潮涌核心:激活", + "subtitles.block.conduit.ambient": "潮涌核心:涌动", + "subtitles.block.conduit.attack.target": "潮涌核心:攻击", + "subtitles.block.conduit.deactivate": "潮涌核心:失效", + "subtitles.block.copper_bulb.turn_off": "铜灯:熄灭", + "subtitles.block.copper_bulb.turn_on": "铜灯:亮起", + "subtitles.block.copper_chest.close": "箱子:关闭", + "subtitles.block.copper_chest.open": "箱子:开启", + "subtitles.block.copper_trapdoor.close": "活板门:关闭", + "subtitles.block.copper_trapdoor.open": "活板门:打开", + "subtitles.block.crafter.craft": "合成器:合成", + "subtitles.block.crafter.fail": "合成器:合成失败", + "subtitles.block.creaking_heart.hurt": "嘎枝之心:咕哝", + "subtitles.block.creaking_heart.idle": "怪异的噪声", + "subtitles.block.creaking_heart.spawn": "嘎枝之心:苏醒", + "subtitles.block.deadbush.idle": "干枯声", + "subtitles.block.decorated_pot.insert": "饰纹陶罐:装入", + "subtitles.block.decorated_pot.insert_fail": "饰纹陶罐:晃动", + "subtitles.block.decorated_pot.shatter": "饰纹陶罐:碎裂", + "subtitles.block.dispenser.dispense": "发射器:发射物品", + "subtitles.block.dispenser.fail": "发射器:发射失败", + "subtitles.block.door.toggle": "门:嘎吱作响", + "subtitles.block.dried_ghast.ambient": "干燥声", + "subtitles.block.dried_ghast.ambient_water": "失水恶魂:补水", + "subtitles.block.dried_ghast.place_in_water": "失水恶魂:浸泡", + "subtitles.block.dried_ghast.transition": "失水恶魂:恢复", + "subtitles.block.dry_grass.ambient": "风声", + "subtitles.block.enchantment_table.use": "附魔台:被使用", + "subtitles.block.end_portal.spawn": "末地传送门:开启", + "subtitles.block.end_portal_frame.fill": "末影之眼:嵌入", + "subtitles.block.eyeblossom.close": "眼眸花:闭合", + "subtitles.block.eyeblossom.idle": "眼眸花:沙沙作响", + "subtitles.block.eyeblossom.open": "眼眸花:张开", + "subtitles.block.fence_gate.toggle": "栅栏门:嘎吱作响", + "subtitles.block.fire.ambient": "火:噼啪作响", + "subtitles.block.fire.extinguish": "火:熄灭", + "subtitles.block.firefly_bush.idle": "萤火虫:嗡嗡", + "subtitles.block.frogspawn.hatch": "蝌蚪:孵化", + "subtitles.block.furnace.fire_crackle": "熔炉:噼啪作响", + "subtitles.block.generic.break": "方块:被破坏", + "subtitles.block.generic.fall": "某物:摔落到方块上", + "subtitles.block.generic.footsteps": "脚步声", + "subtitles.block.generic.hit": "方块:损坏中", + "subtitles.block.generic.place": "方块:被放置", + "subtitles.block.grindstone.use": "砂轮:被使用", + "subtitles.block.growing_plant.crop": "植物:被修剪", + "subtitles.block.hanging_sign.waxed_interact_fail": "告示牌:晃动", + "subtitles.block.honey_block.slide": "从蜂蜜块滑下", + "subtitles.block.iron_trapdoor.close": "活板门:关闭", + "subtitles.block.iron_trapdoor.open": "活板门:打开", + "subtitles.block.lava.ambient": "熔岩:迸裂", + "subtitles.block.lava.extinguish": "熔岩:嘶嘶声", + "subtitles.block.lever.click": "拉杆:拉动", + "subtitles.block.note_block.note": "音符盒:播放", + "subtitles.block.pale_hanging_moss.idle": "怪异的噪声", + "subtitles.block.piston.move": "活塞:移动", + "subtitles.block.pointed_dripstone.drip_lava": "熔岩:滴落", + "subtitles.block.pointed_dripstone.drip_lava_into_cauldron": "熔岩:滴入炼药锅", + "subtitles.block.pointed_dripstone.drip_water": "水:滴落", + "subtitles.block.pointed_dripstone.drip_water_into_cauldron": "水:滴入炼药锅", + "subtitles.block.pointed_dripstone.land": "钟乳石:塌落", + "subtitles.block.poplar_leaves.ambient": "树叶:沙沙作响", + "subtitles.block.portal.ambient": "传送门:呼啸", + "subtitles.block.portal.travel": "传送门:噪声消散", + "subtitles.block.portal.trigger": "传送门:噪声渐响", + "subtitles.block.potent_sulfur.geyser_eruption": "硫黄涌泉:喷涌", + "subtitles.block.potent_sulfur.noxious_gas": "有毒气体:冒出", + "subtitles.block.pressure_plate.click": "压力板:咔哒", + "subtitles.block.pumpkin.carve": "剪刀:雕刻", + "subtitles.block.redstone_torch.burnout": "红石火把:熄灭", + "subtitles.block.respawn_anchor.ambient": "重生锚:呼啸", + "subtitles.block.respawn_anchor.charge": "重生锚:充能", + "subtitles.block.respawn_anchor.deplete": "重生锚:耗能", + "subtitles.block.respawn_anchor.set_spawn": "重生锚:设置出生点", + "subtitles.block.sand.idle": "沙沙声", + "subtitles.block.sand.wind": "风声", + "subtitles.block.sculk.charge": "幽匿块:冒泡", + "subtitles.block.sculk.spread": "幽匿块:蔓延", + "subtitles.block.sculk_catalyst.bloom": "幽匿催发体:催发", + "subtitles.block.sculk_sensor.clicking": "幽匿感测体:颤动", + "subtitles.block.sculk_sensor.clicking_stop": "幽匿感测体:停息", + "subtitles.block.sculk_shrieker.shriek": "幽匿尖啸体:尖啸", + "subtitles.block.shelf.activate": "展示架:激活", + "subtitles.block.shelf.deactivate": "展示架:取消激活", + "subtitles.block.shelf.multi_swap": "物品:交换", + "subtitles.block.shelf.place_item": "物品:被放入", + "subtitles.block.shelf.single_swap": "物品:交换", + "subtitles.block.shelf.take_item": "物品:被取出", + "subtitles.block.shelf_mushroom.bounce": "某物:在层孔菇上弹跳", + "subtitles.block.shulker_box.close": "潜影盒:关闭", + "subtitles.block.shulker_box.open": "潜影盒:开启", + "subtitles.block.sign.waxed_interact_fail": "告示牌:晃动", + "subtitles.block.smithing_table.use": "锻造台:被使用", + "subtitles.block.smoker.smoke": "烟熏炉:烟熏", + "subtitles.block.sniffer_egg.crack": "嗅探兽蛋:裂开", + "subtitles.block.sniffer_egg.hatch": "嗅探兽蛋:孵化", + "subtitles.block.sniffer_egg.plop": "嗅探兽:下蛋", + "subtitles.block.sponge.absorb": "海绵:吸水", + "subtitles.block.straw_bed.break_leave": "麦秆床:损坏", + "subtitles.block.sweet_berry_bush.pick_berries": "浆果:弹出", + "subtitles.block.trapdoor.close": "活板门:关闭", + "subtitles.block.trapdoor.open": "活板门:打开", + "subtitles.block.trapdoor.toggle": "活板门:嘎吱作响", + "subtitles.block.trial_spawner.about_to_spawn_item": "不祥之物:准备", + "subtitles.block.trial_spawner.ambient": "试炼刷怪笼:噼啪作响", + "subtitles.block.trial_spawner.ambient_charged": "不祥试炼刷怪笼:噼啪作响", + "subtitles.block.trial_spawner.ambient_ominous": "不祥试炼刷怪笼:噼啪作响", + "subtitles.block.trial_spawner.charge_activate": "试炼刷怪笼:不祥涌动", + "subtitles.block.trial_spawner.close_shutter": "试炼刷怪笼:关闭", + "subtitles.block.trial_spawner.detect_player": "试炼刷怪笼:充能", + "subtitles.block.trial_spawner.eject_item": "试炼刷怪笼:喷出物品", + "subtitles.block.trial_spawner.ominous_activate": "试炼刷怪笼:不祥涌动", + "subtitles.block.trial_spawner.open_shutter": "试炼刷怪笼:开启", + "subtitles.block.trial_spawner.spawn_item": "不祥之物:掉落", + "subtitles.block.trial_spawner.spawn_item_begin": "不祥之物:出现", + "subtitles.block.trial_spawner.spawn_mob": "试炼刷怪笼:生成生物", + "subtitles.block.tripwire.attach": "绊线:连接", + "subtitles.block.tripwire.click": "绊线:咔哒", + "subtitles.block.tripwire.detach": "绊线:断开", + "subtitles.block.vault.activate": "宝库:燃起", + "subtitles.block.vault.ambient": "宝库:噼啪作响", + "subtitles.block.vault.close_shutter": "宝库:关闭", + "subtitles.block.vault.deactivate": "宝库:熄灭", + "subtitles.block.vault.eject_item": "宝库:喷出物品", + "subtitles.block.vault.insert_item": "宝库:解锁", + "subtitles.block.vault.insert_item_fail": "宝库:解锁失败", + "subtitles.block.vault.open_shutter": "宝库:打开", + "subtitles.block.vault.reject_rewarded_player": "宝库:拒绝已奖励玩家", + "subtitles.block.water.ambient": "水:流动", + "subtitles.block.wet_sponge.dries": "海绵:烘干", + "subtitles.chiseled_bookshelf.insert": "书:被放入", + "subtitles.chiseled_bookshelf.insert_enchanted": "附魔书:被放入", + "subtitles.chiseled_bookshelf.take": "书:被取出", + "subtitles.chiseled_bookshelf.take_enchanted": "附魔书:被取出", + "subtitles.enchant.thorns.hit": "荆棘:反刺", + "subtitles.entity.allay.ambient_with_item": "悦灵:搜寻", + "subtitles.entity.allay.ambient_without_item": "悦灵:渴求", + "subtitles.entity.allay.death": "悦灵:死亡", + "subtitles.entity.allay.hurt": "悦灵:受伤", + "subtitles.entity.allay.item_given": "悦灵:嬉笑", + "subtitles.entity.allay.item_taken": "悦灵:愉悦", + "subtitles.entity.allay.item_thrown": "悦灵:投掷", + "subtitles.entity.armadillo.ambient": "犰狳:呼噜", + "subtitles.entity.armadillo.brush": "鳞甲:被刷落", + "subtitles.entity.armadillo.death": "犰狳:死亡", + "subtitles.entity.armadillo.eat": "犰狳:进食", + "subtitles.entity.armadillo.hurt": "犰狳:受伤", + "subtitles.entity.armadillo.hurt_reduced": "犰狳:自卫", + "subtitles.entity.armadillo.land": "犰狳:着陆", + "subtitles.entity.armadillo.peek": "犰狳:窥视", + "subtitles.entity.armadillo.roll": "犰狳:蜷缩", + "subtitles.entity.armadillo.scute_drop": "犰狳:脱落鳞甲", + "subtitles.entity.armadillo.unroll_finish": "犰狳:伸展", + "subtitles.entity.armadillo.unroll_start": "犰狳:窥视", + "subtitles.entity.armor_stand.fall": "某物:着地", + "subtitles.entity.arrow.hit": "箭:击中", + "subtitles.entity.arrow.hit_player": "箭:击中玩家", + "subtitles.entity.arrow.shoot": "箭:被射出", + "subtitles.entity.axolotl.attack": "美西螈:攻击", + "subtitles.entity.axolotl.death": "美西螈:死亡", + "subtitles.entity.axolotl.hurt": "美西螈:受伤", + "subtitles.entity.axolotl.idle_air": "美西螈:啾啾", + "subtitles.entity.axolotl.idle_water": "美西螈:啾啾", + "subtitles.entity.axolotl.splash": "美西螈:溅起水花", + "subtitles.entity.axolotl.swim": "美西螈:游泳", + "subtitles.entity.baby_cat.ambient": "幼年猫:喵~", + "subtitles.entity.baby_cat.beg_for_food": "幼年猫:求食", + "subtitles.entity.baby_cat.death": "幼年猫:死亡", + "subtitles.entity.baby_cat.eat": "幼年猫:进食", + "subtitles.entity.baby_cat.hiss": "幼年猫:嘶嘶声", + "subtitles.entity.baby_cat.hurt": "幼年猫:受伤", + "subtitles.entity.baby_cat.purr": "幼年猫:呼噜声", + "subtitles.entity.baby_chicken.ambient": "幼年鸡:叽叽叫", + "subtitles.entity.baby_chicken.death": "幼年鸡:死亡", + "subtitles.entity.baby_chicken.hurts": "幼年鸡:受伤", + "subtitles.entity.baby_horse.ambient": "幼年马:嘶鸣", + "subtitles.entity.baby_horse.angry": "幼年马:嘶鸣", + "subtitles.entity.baby_horse.breathe": "幼年马:呼吸", + "subtitles.entity.baby_horse.death": "幼年马:死亡", + "subtitles.entity.baby_horse.eat": "幼年马:进食", + "subtitles.entity.baby_horse.hurt": "幼年马:受伤", + "subtitles.entity.baby_horse.land": "幼年马:着陆", + "subtitles.entity.baby_nautilus.ambient": "幼年鹦鹉螺:啾啾", + "subtitles.entity.baby_nautilus.ambient_land": "幼年鹦鹉螺:啾啾", + "subtitles.entity.baby_nautilus.death": "幼年鹦鹉螺:死亡", + "subtitles.entity.baby_nautilus.death_land": "幼年鹦鹉螺:死亡", + "subtitles.entity.baby_nautilus.eat": "幼年鹦鹉螺:进食", + "subtitles.entity.baby_nautilus.hurt": "幼年鹦鹉螺:受伤", + "subtitles.entity.baby_nautilus.hurt_land": "幼年鹦鹉螺:受伤", + "subtitles.entity.baby_nautilus.swim": "幼年鹦鹉螺:游泳", + "subtitles.entity.baby_pig.ambient": "幼年猪:哼叫", + "subtitles.entity.baby_pig.death": "幼年猪:死亡", + "subtitles.entity.baby_pig.eat": "幼年猪:进食", + "subtitles.entity.baby_pig.hurt": "幼年猪:受伤", + "subtitles.entity.baby_wolf.ambient": "幼年狼:嗷~", + "subtitles.entity.baby_wolf.death": "幼年狼:死亡", + "subtitles.entity.baby_wolf.growl": "幼年狼:嚎叫", + "subtitles.entity.baby_wolf.hurt": "幼年狼:受伤", + "subtitles.entity.baby_wolf.pant": "幼年狼:喘息", + "subtitles.entity.baby_wolf.whine": "幼年狼:呜咽", + "subtitles.entity.bat.ambient": "蝙蝠:尖声叫", + "subtitles.entity.bat.death": "蝙蝠:死亡", + "subtitles.entity.bat.hurt": "蝙蝠:受伤", + "subtitles.entity.bat.takeoff": "蝙蝠:起飞", + "subtitles.entity.bee.ambient": "蜜蜂:嗡嗡", + "subtitles.entity.bee.death": "蜜蜂:死亡", + "subtitles.entity.bee.hurt": "蜜蜂:受伤", + "subtitles.entity.bee.loop": "蜜蜂:嗡嗡", + "subtitles.entity.bee.loop_aggressive": "蜜蜂:愤怒地嗡嗡叫", + "subtitles.entity.bee.pollinate": "蜜蜂:高兴地嗡嗡叫", + "subtitles.entity.bee.sting": "蜜蜂:刺蜇", + "subtitles.entity.blaze.ambient": "烈焰人:呼吸", + "subtitles.entity.blaze.burn": "烈焰人:噼啪作响", + "subtitles.entity.blaze.death": "烈焰人:死亡", + "subtitles.entity.blaze.hurt": "烈焰人:受伤", + "subtitles.entity.blaze.shoot": "烈焰人:射击", + "subtitles.entity.boat.paddle_land": "划船", + "subtitles.entity.boat.paddle_water": "划船", + "subtitles.entity.bogged.ambient": "沼骸:咯咯声", + "subtitles.entity.bogged.death": "沼骸:死亡", + "subtitles.entity.bogged.hurt": "沼骸:受伤", + "subtitles.entity.breeze.charge": "旋风人:蓄力", + "subtitles.entity.breeze.death": "旋风人:死亡", + "subtitles.entity.breeze.deflect": "旋风人:反弹", + "subtitles.entity.breeze.hurt": "旋风人:受伤", + "subtitles.entity.breeze.idle_air": "旋风人:飞行", + "subtitles.entity.breeze.idle_ground": "旋风人:呼啸", + "subtitles.entity.breeze.inhale": "旋风人:吸气", + "subtitles.entity.breeze.jump": "旋风人:跳跃", + "subtitles.entity.breeze.land": "旋风人:着陆", + "subtitles.entity.breeze.shoot": "旋风人:射击", + "subtitles.entity.breeze.slide": "旋风人:滑行", + "subtitles.entity.breeze.whirl": "旋风人:旋转", + "subtitles.entity.breeze.wind_burst": "风弹:爆裂", + "subtitles.entity.camel.ambient": "骆驼:呼噜", + "subtitles.entity.camel.dash": "骆驼:冲刺", + "subtitles.entity.camel.dash_ready": "骆驼:恢复体力", + "subtitles.entity.camel.death": "骆驼:死亡", + "subtitles.entity.camel.eat": "骆驼:进食", + "subtitles.entity.camel.hurt": "骆驼:受伤", + "subtitles.entity.camel.saddle": "鞍:被装备", + "subtitles.entity.camel.sit": "骆驼:坐下", + "subtitles.entity.camel.stand": "骆驼:站起", + "subtitles.entity.camel.step": "骆驼:脚步声", + "subtitles.entity.camel.step_sand": "骆驼:踏沙", + "subtitles.entity.camel_husk.ambient": "骆驼尸壳:咕哝", + "subtitles.entity.camel_husk.dash": "骆驼尸壳:冲刺", + "subtitles.entity.camel_husk.dash_ready": "骆驼尸壳:恢复体力", + "subtitles.entity.camel_husk.death": "骆驼尸壳:死亡", + "subtitles.entity.camel_husk.eat": "骆驼尸壳:进食", + "subtitles.entity.camel_husk.hurt": "骆驼尸壳:受伤", + "subtitles.entity.camel_husk.saddle": "鞍:被装备", + "subtitles.entity.camel_husk.sit": "骆驼尸壳:坐下", + "subtitles.entity.camel_husk.stand": "骆驼尸壳:站起", + "subtitles.entity.cat.ambient": "猫:喵~", + "subtitles.entity.cat.beg_for_food": "猫:求食", + "subtitles.entity.cat.death": "猫:死亡", + "subtitles.entity.cat.eat": "猫:进食", + "subtitles.entity.cat.hiss": "猫:嘶嘶声", + "subtitles.entity.cat.hurt": "猫:受伤", + "subtitles.entity.cat.purr": "猫:呼噜声", + "subtitles.entity.chicken.ambient": "鸡:咯咯叫", + "subtitles.entity.chicken.death": "鸡:死亡", + "subtitles.entity.chicken.egg": "鸡:下蛋", + "subtitles.entity.chicken.hurt": "鸡:受伤", + "subtitles.entity.cod.death": "鳕鱼:死亡", + "subtitles.entity.cod.flop": "鳕鱼:扑腾", + "subtitles.entity.cod.hurt": "鳕鱼:受伤", + "subtitles.entity.copper_golem.death": "铜傀儡:死亡", + "subtitles.entity.copper_golem.hurt": "铜傀儡:受伤", + "subtitles.entity.copper_golem.item_drop": "铜傀儡:放置物品", + "subtitles.entity.copper_golem.item_no_drop": "铜傀儡:无法放置物品", + "subtitles.entity.copper_golem.no_item_get": "铜傀儡:取出物品", + "subtitles.entity.copper_golem.no_item_no_get": "铜傀儡:无法取出物品", + "subtitles.entity.copper_golem.spawn": "铜傀儡:出现", + "subtitles.entity.copper_golem.spin": "铜傀儡:旋转头部", + "subtitles.entity.copper_golem_become_statue": "铜傀儡:固定", + "subtitles.entity.copper_golem_oxidized.death": "铜傀儡:死亡", + "subtitles.entity.copper_golem_oxidized.hurt": "铜傀儡:受伤", + "subtitles.entity.copper_golem_oxidized.spin": "铜傀儡:旋转头部", + "subtitles.entity.copper_golem_weathered.death": "铜傀儡:死亡", + "subtitles.entity.copper_golem_weathered.hurt": "铜傀儡:受伤", + "subtitles.entity.copper_golem_weathered.spin": "铜傀儡:旋转头部", + "subtitles.entity.cow.ambient": "牛:哞~", + "subtitles.entity.cow.death": "牛:死亡", + "subtitles.entity.cow.hurt": "牛:受伤", + "subtitles.entity.cow.milk": "牛:被挤奶", + "subtitles.entity.creaking.activate": "嘎枝:注视", + "subtitles.entity.creaking.ambient": "嘎枝:嘎吱作响", + "subtitles.entity.creaking.attack": "嘎枝:攻击", + "subtitles.entity.creaking.deactivate": "嘎枝:静息", + "subtitles.entity.creaking.death": "嘎枝:碎裂", + "subtitles.entity.creaking.freeze": "嘎枝:驻足", + "subtitles.entity.creaking.spawn": "嘎枝:显现", + "subtitles.entity.creaking.sway": "嘎枝:被击打", + "subtitles.entity.creaking.twitch": "嘎枝:抽搐", + "subtitles.entity.creaking.unfreeze": "嘎枝:移动", + "subtitles.entity.creeper.death": "苦力怕:死亡", + "subtitles.entity.creeper.hurt": "苦力怕:受伤", + "subtitles.entity.creeper.primed": "苦力怕:嘶~", + "subtitles.entity.cushion.break": "坐垫:损坏", + "subtitles.entity.cushion.get_up": "离开坐垫", + "subtitles.entity.cushion.place": "坐垫:被放置", + "subtitles.entity.cushion.sit": "坐上坐垫", + "subtitles.entity.dolphin.ambient": "海豚:啾啾", + "subtitles.entity.dolphin.ambient_water": "海豚:吹口哨", + "subtitles.entity.dolphin.attack": "海豚:攻击", + "subtitles.entity.dolphin.death": "海豚:死亡", + "subtitles.entity.dolphin.eat": "海豚:进食", + "subtitles.entity.dolphin.hurt": "海豚:受伤", + "subtitles.entity.dolphin.jump": "海豚:跃起", + "subtitles.entity.dolphin.play": "海豚:嬉戏", + "subtitles.entity.dolphin.splash": "海豚:溅起水花", + "subtitles.entity.dolphin.swim": "海豚:游泳", + "subtitles.entity.donkey.ambient": "驴:嘶叫", + "subtitles.entity.donkey.angry": "驴:嘶鸣", + "subtitles.entity.donkey.chest": "驴:被装备箱子", + "subtitles.entity.donkey.death": "驴:死亡", + "subtitles.entity.donkey.eat": "驴:进食", + "subtitles.entity.donkey.hurt": "驴:受伤", + "subtitles.entity.donkey.jump": "驴:跳跃", + "subtitles.entity.drowned.ambient": "溺尸:呻吟", + "subtitles.entity.drowned.ambient_water": "溺尸:呻吟", + "subtitles.entity.drowned.death": "溺尸:死亡", + "subtitles.entity.drowned.hurt": "溺尸:受伤", + "subtitles.entity.drowned.shoot": "溺尸:投掷三叉戟", + "subtitles.entity.drowned.step": "溺尸:脚步声", + "subtitles.entity.drowned.swim": "溺尸:游泳", + "subtitles.entity.egg.throw": "鸡蛋:飞出", + "subtitles.entity.elder_guardian.ambient": "远古守卫者:低鸣", + "subtitles.entity.elder_guardian.ambient_land": "远古守卫者:弹跳", + "subtitles.entity.elder_guardian.curse": "远古守卫者:诅咒", + "subtitles.entity.elder_guardian.death": "远古守卫者:死亡", + "subtitles.entity.elder_guardian.flop": "远古守卫者:扑腾", + "subtitles.entity.elder_guardian.hurt": "远古守卫者:受伤", + "subtitles.entity.ender_dragon.ambient": "末影龙:咆哮", + "subtitles.entity.ender_dragon.death": "末影龙:死亡", + "subtitles.entity.ender_dragon.flap": "末影龙:拍打翅膀", + "subtitles.entity.ender_dragon.growl": "末影龙:吼叫", + "subtitles.entity.ender_dragon.hurt": "末影龙:受伤", + "subtitles.entity.ender_dragon.shoot": "末影龙:射击", + "subtitles.entity.ender_eye.death": "末影之眼:掉落", + "subtitles.entity.ender_eye.launch": "末影之眼:射出", + "subtitles.entity.ender_pearl.throw": "末影珍珠:飞出", + "subtitles.entity.enderman.ambient": "末影人:低鸣", + "subtitles.entity.enderman.death": "末影人:死亡", + "subtitles.entity.enderman.hurt": "末影人:受伤", + "subtitles.entity.enderman.scream": "末影人:尖叫", + "subtitles.entity.enderman.stare": "末影人:喊叫", + "subtitles.entity.enderman.teleport": "末影人:传送", + "subtitles.entity.endermite.ambient": "末影螨:窜动", + "subtitles.entity.endermite.death": "末影螨:死亡", + "subtitles.entity.endermite.hurt": "末影螨:受伤", + "subtitles.entity.evoker.ambient": "唤魔者:咕哝", + "subtitles.entity.evoker.cast_spell": "唤魔者:施法", + "subtitles.entity.evoker.celebrate": "唤魔者:欢呼", + "subtitles.entity.evoker.death": "唤魔者:死亡", + "subtitles.entity.evoker.hurt": "唤魔者:受伤", + "subtitles.entity.evoker.prepare_attack": "唤魔者:准备攻击", + "subtitles.entity.evoker.prepare_summon": "唤魔者:准备召唤", + "subtitles.entity.evoker.prepare_wololo": "唤魔者:准备施咒", + "subtitles.entity.evoker_fangs.attack": "尖牙:咬合", + "subtitles.entity.experience_orb.pickup": "获得经验", + "subtitles.entity.firework_rocket.blast": "烟花:爆裂", + "subtitles.entity.firework_rocket.launch": "烟花:发射", + "subtitles.entity.firework_rocket.twinkle": "烟花:闪烁", + "subtitles.entity.fish.swim": "溅起水花", + "subtitles.entity.fishing_bobber.retrieve": "浮漂:被收回", + "subtitles.entity.fishing_bobber.splash": "浮漂:溅起水花", + "subtitles.entity.fishing_bobber.throw": "浮漂:被甩出", + "subtitles.entity.fox.aggro": "狐狸:愤怒", + "subtitles.entity.fox.ambient": "狐狸:吱吱叫", + "subtitles.entity.fox.bite": "狐狸:撕咬", + "subtitles.entity.fox.death": "狐狸:死亡", + "subtitles.entity.fox.eat": "狐狸:进食", + "subtitles.entity.fox.hurt": "狐狸:受伤", + "subtitles.entity.fox.screech": "狐狸:尖声叫", + "subtitles.entity.fox.sleep": "狐狸:打鼾", + "subtitles.entity.fox.sniff": "狐狸:嗅探", + "subtitles.entity.fox.spit": "狐狸:吐出", + "subtitles.entity.fox.teleport": "狐狸:传送", + "subtitles.entity.frog.ambient": "青蛙:咕呱", + "subtitles.entity.frog.death": "青蛙:死亡", + "subtitles.entity.frog.eat": "青蛙:进食", + "subtitles.entity.frog.hurt": "青蛙:受伤", + "subtitles.entity.frog.lay_spawn": "青蛙:产卵", + "subtitles.entity.frog.long_jump": "青蛙:跳跃", + "subtitles.entity.frostbite.ambient": "冻尸:低吼", + "subtitles.entity.frostbite.converted_to_zombie": "冻尸:转化为僵尸", + "subtitles.entity.frostbite.death": "冻尸:死亡", + "subtitles.entity.frostbite.hurt": "冻尸:受伤", + "subtitles.entity.generic.big_fall": "某物:着地", + "subtitles.entity.generic.burn": "燃烧", + "subtitles.entity.generic.death": "死亡", + "subtitles.entity.generic.drink": "啜饮", + "subtitles.entity.generic.eat": "进食", + "subtitles.entity.generic.explode": "爆炸", + "subtitles.entity.generic.extinguish_fire": "火:熄灭", + "subtitles.entity.generic.hurt": "某物:受伤", + "subtitles.entity.generic.small_fall": "某物:摔倒", + "subtitles.entity.generic.splash": "溅起水花", + "subtitles.entity.generic.swim": "游泳", + "subtitles.entity.generic.wind_burst": "风弹:爆裂", + "subtitles.entity.ghast.ambient": "恶魂:哭泣", + "subtitles.entity.ghast.death": "恶魂:死亡", + "subtitles.entity.ghast.hurt": "恶魂:受伤", + "subtitles.entity.ghast.shoot": "恶魂:射击", + "subtitles.entity.ghastling.ambient": "小恶魂:咕咕", + "subtitles.entity.ghastling.death": "小恶魂:死亡", + "subtitles.entity.ghastling.hurt": "小恶魂:受伤", + "subtitles.entity.ghastling.spawn": "小恶魂:出现", + "subtitles.entity.glow_item_frame.add_item": "荧光物品展示框:被填充", + "subtitles.entity.glow_item_frame.break": "荧光物品展示框:损坏", + "subtitles.entity.glow_item_frame.place": "荧光物品展示框:被放置", + "subtitles.entity.glow_item_frame.remove_item": "荧光物品展示框:清空", + "subtitles.entity.glow_item_frame.rotate_item": "荧光物品展示框:转动", + "subtitles.entity.glow_squid.ambient": "发光鱿鱼:游泳", + "subtitles.entity.glow_squid.death": "发光鱿鱼:死亡", + "subtitles.entity.glow_squid.hurt": "发光鱿鱼:受伤", + "subtitles.entity.glow_squid.squirt": "发光鱿鱼:喷墨", + "subtitles.entity.goat.ambient": "山羊:咩~", + "subtitles.entity.goat.death": "山羊:死亡", + "subtitles.entity.goat.eat": "山羊:进食", + "subtitles.entity.goat.horn_break": "山羊:断角", + "subtitles.entity.goat.hurt": "山羊:受伤", + "subtitles.entity.goat.long_jump": "山羊:跳跃", + "subtitles.entity.goat.milk": "山羊:被挤奶", + "subtitles.entity.goat.prepare_ram": "山羊:跺脚", + "subtitles.entity.goat.ram_impact": "山羊:冲撞", + "subtitles.entity.goat.screaming.ambient": "山羊:喊叫", + "subtitles.entity.goat.step": "山羊:脚步声", + "subtitles.entity.guardian.ambient": "守卫者:低鸣", + "subtitles.entity.guardian.ambient_land": "守卫者:弹跳", + "subtitles.entity.guardian.attack": "守卫者:射击", + "subtitles.entity.guardian.death": "守卫者:死亡", + "subtitles.entity.guardian.flop": "守卫者:扑腾", + "subtitles.entity.guardian.hurt": "守卫者:受伤", + "subtitles.entity.happy_ghast.ambient": "快乐恶魂:哼唱", + "subtitles.entity.happy_ghast.death": "快乐恶魂:死亡", + "subtitles.entity.happy_ghast.equip": "挽具:被装备", + "subtitles.entity.happy_ghast.harness_goggles_down": "快乐恶魂:就绪", + "subtitles.entity.happy_ghast.harness_goggles_up": "快乐恶魂:停下", + "subtitles.entity.happy_ghast.hurt": "快乐恶魂:受伤", + "subtitles.entity.happy_ghast.riding": "风呼啸而过", + "subtitles.entity.happy_ghast.unequip": "挽具:被卸下", + "subtitles.entity.hoglin.ambient": "疣猪兽:咆哮", + "subtitles.entity.hoglin.angry": "疣猪兽:怒吼", + "subtitles.entity.hoglin.attack": "疣猪兽:攻击", + "subtitles.entity.hoglin.converted_to_zombified": "疣猪兽:转化为僵尸疣猪兽", + "subtitles.entity.hoglin.death": "疣猪兽:死亡", + "subtitles.entity.hoglin.hurt": "疣猪兽:受伤", + "subtitles.entity.hoglin.retreat": "疣猪兽:退缩", + "subtitles.entity.hoglin.step": "疣猪兽:脚步声", + "subtitles.entity.horse.ambient": "马:嘶鸣", + "subtitles.entity.horse.angry": "马:嘶鸣", + "subtitles.entity.horse.armor": "马铠:被装备", + "subtitles.entity.horse.breathe": "马:呼吸", + "subtitles.entity.horse.death": "马:死亡", + "subtitles.entity.horse.eat": "马:进食", + "subtitles.entity.horse.gallop": "马:奔腾", + "subtitles.entity.horse.hurt": "马:受伤", + "subtitles.entity.horse.jump": "马:跳跃", + "subtitles.entity.horse.saddle": "鞍:被装备", + "subtitles.entity.husk.ambient": "尸壳:低吼", + "subtitles.entity.husk.converted_to_zombie": "尸壳:转化为僵尸", + "subtitles.entity.husk.death": "尸壳:死亡", + "subtitles.entity.husk.hurt": "尸壳:受伤", + "subtitles.entity.ice_ball.break": "冰球:击中", + "subtitles.entity.ice_ball.throw": "冰球:飞出", + "subtitles.entity.illusioner.ambient": "幻术师:咕哝", + "subtitles.entity.illusioner.cast_spell": "幻术师:施法", + "subtitles.entity.illusioner.death": "幻术师:死亡", + "subtitles.entity.illusioner.hurt": "幻术师:受伤", + "subtitles.entity.illusioner.mirror_move": "幻术师:分影", + "subtitles.entity.illusioner.prepare_blindness": "幻术师:准备失明法术", + "subtitles.entity.illusioner.prepare_mirror": "幻术师:准备分影法术", + "subtitles.entity.iron_golem.attack": "铁傀儡:攻击", + "subtitles.entity.iron_golem.damage": "铁傀儡:受损", + "subtitles.entity.iron_golem.death": "铁傀儡:死亡", + "subtitles.entity.iron_golem.hurt": "铁傀儡:受伤", + "subtitles.entity.iron_golem.repair": "铁傀儡:被修复", + "subtitles.entity.item.break": "物品:损坏", + "subtitles.entity.item.pickup": "物品:被拾起", + "subtitles.entity.item_frame.add_item": "物品展示框:被填充", + "subtitles.entity.item_frame.break": "物品展示框:损坏", + "subtitles.entity.item_frame.place": "物品展示框:被放置", + "subtitles.entity.item_frame.remove_item": "物品展示框:清空", + "subtitles.entity.item_frame.rotate_item": "物品展示框:转动", + "subtitles.entity.leash_knot.break": "拴绳结:损坏", + "subtitles.entity.leash_knot.place": "拴绳结:被系上", + "subtitles.entity.lightning_bolt.impact": "电闪", + "subtitles.entity.lightning_bolt.thunder": "雷鸣", + "subtitles.entity.llama.ambient": "羊驼:吼叫", + "subtitles.entity.llama.angry": "羊驼:怒吼", + "subtitles.entity.llama.chest": "羊驼:被装备箱子", + "subtitles.entity.llama.death": "羊驼:死亡", + "subtitles.entity.llama.eat": "羊驼:进食", + "subtitles.entity.llama.hurt": "羊驼:受伤", + "subtitles.entity.llama.spit": "羊驼:喷射唾沫", + "subtitles.entity.llama.step": "羊驼:脚步声", + "subtitles.entity.llama.swag": "羊驼:被装饰", + "subtitles.entity.magma_cube.death": "岩浆怪:死亡", + "subtitles.entity.magma_cube.hurt": "岩浆怪:受伤", + "subtitles.entity.magma_cube.squish": "岩浆怪:挤压", + "subtitles.entity.minecart.inside": "矿车:哐啷作响", + "subtitles.entity.minecart.inside_underwater": "矿车:在水中哐啷作响", + "subtitles.entity.minecart.riding": "矿车:行进", + "subtitles.entity.mooshroom.convert": "哞菇:转化", + "subtitles.entity.mooshroom.eat": "哞菇:进食", + "subtitles.entity.mooshroom.milk": "哞菇:被挤奶", + "subtitles.entity.mooshroom.suspicious_milk": "哞菇:被挤出谜之炖菜", + "subtitles.entity.mule.ambient": "骡:鸣叫", + "subtitles.entity.mule.angry": "骡:嘶鸣", + "subtitles.entity.mule.chest": "骡:被装备箱子", + "subtitles.entity.mule.death": "骡:死亡", + "subtitles.entity.mule.eat": "骡:进食", + "subtitles.entity.mule.hurt": "骡:受伤", + "subtitles.entity.mule.jump": "骡:跳跃", + "subtitles.entity.nautilus.ambient": "鹦鹉螺:咔哒", + "subtitles.entity.nautilus.ambient_land": "鹦鹉螺:咔哒", + "subtitles.entity.nautilus.dash": "鹦鹉螺:喷进", + "subtitles.entity.nautilus.dash_land": "鹦鹉螺:喷进", + "subtitles.entity.nautilus.dash_ready": "鹦鹉螺:恢复体力", + "subtitles.entity.nautilus.dash_ready_land": "鹦鹉螺:恢复体力", + "subtitles.entity.nautilus.death": "鹦鹉螺:死亡", + "subtitles.entity.nautilus.death_land": "鹦鹉螺:死亡", + "subtitles.entity.nautilus.eat": "鹦鹉螺:进食", + "subtitles.entity.nautilus.hurt": "鹦鹉螺:受伤", + "subtitles.entity.nautilus.hurt_land": "鹦鹉螺:受伤", + "subtitles.entity.nautilus.riding": "鹦鹉螺:冒泡", + "subtitles.entity.nautilus.swim": "鹦鹉螺:游泳", + "subtitles.entity.painting.break": "画:损坏", + "subtitles.entity.painting.place": "画:被放置", + "subtitles.entity.panda.aggressive_ambient": "熊猫:发怒", + "subtitles.entity.panda.ambient": "熊猫:喘息", + "subtitles.entity.panda.bite": "熊猫:撕咬", + "subtitles.entity.panda.cant_breed": "熊猫:哀鸣", + "subtitles.entity.panda.death": "熊猫:死亡", + "subtitles.entity.panda.eat": "熊猫:进食", + "subtitles.entity.panda.hurt": "熊猫:受伤", + "subtitles.entity.panda.pre_sneeze": "熊猫:鼻痒", + "subtitles.entity.panda.sneeze": "熊猫:打喷嚏", + "subtitles.entity.panda.step": "熊猫:脚步声", + "subtitles.entity.panda.worried_ambient": "熊猫:呜咽", + "subtitles.entity.parched.ambient": "焦骸:咯咯声", + "subtitles.entity.parched.death": "焦骸:死亡", + "subtitles.entity.parched.hurt": "焦骸:受伤", + "subtitles.entity.parrot.ambient": "鹦鹉:说话", + "subtitles.entity.parrot.death": "鹦鹉:死亡", + "subtitles.entity.parrot.eats": "鹦鹉:进食", + "subtitles.entity.parrot.fly": "鹦鹉:扑翼", + "subtitles.entity.parrot.hurts": "鹦鹉:受伤", + "subtitles.entity.parrot.imitate.blaze": "鹦鹉:呼吸", + "subtitles.entity.parrot.imitate.bogged": "鹦鹉:咯咯声", + "subtitles.entity.parrot.imitate.breeze": "鹦鹉:呼啸", + "subtitles.entity.parrot.imitate.camel_husk": "鹦鹉:咕哝", + "subtitles.entity.parrot.imitate.creaking": "鹦鹉:嘎吱作响", + "subtitles.entity.parrot.imitate.creeper": "鹦鹉:嘶~", + "subtitles.entity.parrot.imitate.drowned": "鹦鹉:呻吟", + "subtitles.entity.parrot.imitate.elder_guardian": "鹦鹉:低鸣", + "subtitles.entity.parrot.imitate.ender_dragon": "鹦鹉:咆哮", + "subtitles.entity.parrot.imitate.endermite": "鹦鹉:窜动", + "subtitles.entity.parrot.imitate.evoker": "鹦鹉:咕哝", + "subtitles.entity.parrot.imitate.ghast": "鹦鹉:哭泣", + "subtitles.entity.parrot.imitate.guardian": "鹦鹉:低鸣", + "subtitles.entity.parrot.imitate.hoglin": "鹦鹉:咆哮", + "subtitles.entity.parrot.imitate.husk": "鹦鹉:低吼", + "subtitles.entity.parrot.imitate.illusioner": "鹦鹉:咕哝", + "subtitles.entity.parrot.imitate.magma_cube": "鹦鹉:挤压", + "subtitles.entity.parrot.imitate.parched": "鹦鹉:咯咯声", + "subtitles.entity.parrot.imitate.phantom": "鹦鹉:尖声叫", + "subtitles.entity.parrot.imitate.piglin": "鹦鹉:哼叫", + "subtitles.entity.parrot.imitate.piglin_brute": "鹦鹉:哼叫", + "subtitles.entity.parrot.imitate.pillager": "鹦鹉:咕哝", + "subtitles.entity.parrot.imitate.ravager": "鹦鹉:呼噜", + "subtitles.entity.parrot.imitate.shulker": "鹦鹉:窥视", + "subtitles.entity.parrot.imitate.silverfish": "鹦鹉:嘶嘶", + "subtitles.entity.parrot.imitate.skeleton": "鹦鹉:咯咯声", + "subtitles.entity.parrot.imitate.slime": "鹦鹉:挤压", + "subtitles.entity.parrot.imitate.spider": "鹦鹉:嘶嘶", + "subtitles.entity.parrot.imitate.stray": "鹦鹉:咯咯声", + "subtitles.entity.parrot.imitate.vex": "鹦鹉:恼人", + "subtitles.entity.parrot.imitate.vindicator": "鹦鹉:低语", + "subtitles.entity.parrot.imitate.warden": "鹦鹉:呻吟", + "subtitles.entity.parrot.imitate.witch": "鹦鹉:暗笑", + "subtitles.entity.parrot.imitate.wither": "鹦鹉:愤怒", + "subtitles.entity.parrot.imitate.wither_skeleton": "鹦鹉:咯咯声", + "subtitles.entity.parrot.imitate.zoglin": "鹦鹉:咆哮", + "subtitles.entity.parrot.imitate.zombie": "鹦鹉:低吼", + "subtitles.entity.parrot.imitate.zombie_horse": "鹦鹉:低吼", + "subtitles.entity.parrot.imitate.zombie_nautilus": "鹦鹉:咕噜", + "subtitles.entity.parrot.imitate.zombie_villager": "鹦鹉:低吼", + "subtitles.entity.phantom.ambient": "幻翼:尖声叫", + "subtitles.entity.phantom.bite": "幻翼:撕咬", + "subtitles.entity.phantom.death": "幻翼:死亡", + "subtitles.entity.phantom.flap": "幻翼:振翅", + "subtitles.entity.phantom.hurt": "幻翼:受伤", + "subtitles.entity.phantom.swoop": "幻翼:俯冲", + "subtitles.entity.pig.ambient": "猪:哼叫", + "subtitles.entity.pig.death": "猪:死亡", + "subtitles.entity.pig.eat": "猪:进食", + "subtitles.entity.pig.hurt": "猪:受伤", + "subtitles.entity.pig.saddle": "鞍:被装备", + "subtitles.entity.piglin.admiring_item": "猪灵:端详物品", + "subtitles.entity.piglin.ambient": "猪灵:哼叫", + "subtitles.entity.piglin.angry": "猪灵:愤怒地哼叫", + "subtitles.entity.piglin.celebrate": "猪灵:庆祝", + "subtitles.entity.piglin.converted_to_zombified": "猪灵:转化为僵尸猪灵", + "subtitles.entity.piglin.death": "猪灵:死亡", + "subtitles.entity.piglin.hurt": "猪灵:受伤", + "subtitles.entity.piglin.jealous": "猪灵:羡慕地哼叫", + "subtitles.entity.piglin.retreat": "猪灵:退缩", + "subtitles.entity.piglin.step": "猪灵:脚步声", + "subtitles.entity.piglin_brute.ambient": "猪灵蛮兵:哼叫", + "subtitles.entity.piglin_brute.angry": "猪灵蛮兵:愤怒地哼叫", + "subtitles.entity.piglin_brute.converted_to_zombified": "猪灵蛮兵:转化为僵尸猪灵", + "subtitles.entity.piglin_brute.death": "猪灵蛮兵:死亡", + "subtitles.entity.piglin_brute.hurt": "猪灵蛮兵:受伤", + "subtitles.entity.piglin_brute.step": "猪灵蛮兵:脚步声", + "subtitles.entity.pillager.ambient": "掠夺者:咕哝", + "subtitles.entity.pillager.celebrate": "掠夺者:欢呼", + "subtitles.entity.pillager.death": "掠夺者:死亡", + "subtitles.entity.pillager.hurt": "掠夺者:受伤", + "subtitles.entity.player.attack.crit": "暴击", + "subtitles.entity.player.attack.knockback": "击退攻击", + "subtitles.entity.player.attack.strong": "重击", + "subtitles.entity.player.attack.sweep": "横扫攻击", + "subtitles.entity.player.attack.weak": "轻击", + "subtitles.entity.player.burp": "玩家:打嗝", + "subtitles.entity.player.death": "玩家:死亡", + "subtitles.entity.player.freeze_hurt": "玩家:冻伤", + "subtitles.entity.player.hurt": "玩家:受伤", + "subtitles.entity.player.hurt_drown": "玩家:溺水", + "subtitles.entity.player.hurt_on_fire": "玩家:燃烧", + "subtitles.entity.player.levelup": "玩家:升级", + "subtitles.entity.player.teleport": "玩家:传送", + "subtitles.entity.polar_bear.ambient": "北极熊:低吼", + "subtitles.entity.polar_bear.ambient_baby": "幼年北极熊:哼哼", + "subtitles.entity.polar_bear.death": "北极熊:死亡", + "subtitles.entity.polar_bear.hurt": "北极熊:受伤", + "subtitles.entity.polar_bear.warning": "北极熊:咆哮", + "subtitles.entity.potion.splash": "玻璃瓶:碎裂", + "subtitles.entity.potion.throw": "玻璃瓶:被扔出", + "subtitles.entity.puffer_fish.blow_out": "河豚:收缩", + "subtitles.entity.puffer_fish.blow_up": "河豚:膨胀", + "subtitles.entity.puffer_fish.death": "河豚:死亡", + "subtitles.entity.puffer_fish.flop": "河豚:扑腾", + "subtitles.entity.puffer_fish.hurt": "河豚:受伤", + "subtitles.entity.puffer_fish.sting": "河豚:刺蜇", + "subtitles.entity.rabbit.ambient": "兔子:吱吱叫", + "subtitles.entity.rabbit.attack": "兔子:攻击", + "subtitles.entity.rabbit.death": "兔子:死亡", + "subtitles.entity.rabbit.hurt": "兔子:受伤", + "subtitles.entity.rabbit.jump": "兔子:跳动", + "subtitles.entity.ravager.ambient": "劫掠兽:呼噜", + "subtitles.entity.ravager.attack": "劫掠兽:撕咬", + "subtitles.entity.ravager.celebrate": "劫掠兽:欢呼", + "subtitles.entity.ravager.death": "劫掠兽:死亡", + "subtitles.entity.ravager.hurt": "劫掠兽:受伤", + "subtitles.entity.ravager.roar": "劫掠兽:咆哮", + "subtitles.entity.ravager.step": "劫掠兽:脚步声", + "subtitles.entity.ravager.stunned": "劫掠兽:眩晕", + "subtitles.entity.salmon.death": "鲑鱼:死亡", + "subtitles.entity.salmon.flop": "鲑鱼:扑腾", + "subtitles.entity.salmon.hurt": "鲑鱼:受伤", + "subtitles.entity.sheep.ambient": "绵羊:咩~", + "subtitles.entity.sheep.death": "绵羊:死亡", + "subtitles.entity.sheep.hurt": "绵羊:受伤", + "subtitles.entity.shulker.ambient": "潜影贝:窥视", + "subtitles.entity.shulker.close": "潜影贝:闭合", + "subtitles.entity.shulker.death": "潜影贝:死亡", + "subtitles.entity.shulker.hurt": "潜影贝:受伤", + "subtitles.entity.shulker.open": "潜影贝:打开", + "subtitles.entity.shulker.shoot": "潜影贝:射击", + "subtitles.entity.shulker.teleport": "潜影贝:传送", + "subtitles.entity.shulker_bullet.hit": "潜影弹:爆炸", + "subtitles.entity.shulker_bullet.hurt": "潜影弹:碎裂", + "subtitles.entity.silverfish.ambient": "蠹虫:嘶嘶", + "subtitles.entity.silverfish.death": "蠹虫:死亡", + "subtitles.entity.silverfish.hurt": "蠹虫:受伤", + "subtitles.entity.skeleton.ambient": "骷髅:咯咯声", + "subtitles.entity.skeleton.converted_to_stray": "骷髅:转化为流浪者", + "subtitles.entity.skeleton.death": "骷髅:死亡", + "subtitles.entity.skeleton.hurt": "骷髅:受伤", + "subtitles.entity.skeleton.shoot": "骷髅:射击", + "subtitles.entity.skeleton_horse.ambient": "骷髅马:嘶叫", + "subtitles.entity.skeleton_horse.death": "骷髅马:死亡", + "subtitles.entity.skeleton_horse.hurt": "骷髅马:受伤", + "subtitles.entity.skeleton_horse.jump_water": "骷髅马:跳跃", + "subtitles.entity.skeleton_horse.swim": "骷髅马:游泳", + "subtitles.entity.slime.attack": "史莱姆:攻击", + "subtitles.entity.slime.death": "史莱姆:死亡", + "subtitles.entity.slime.hurt": "史莱姆:受伤", + "subtitles.entity.slime.squish": "史莱姆:挤压", + "subtitles.entity.small_sulfur_cube.death": "小型硫方怪:死亡", + "subtitles.entity.small_sulfur_cube.eat": "小型硫方怪:增加质量", + "subtitles.entity.small_sulfur_cube.hurt": "小型硫方怪:受伤", + "subtitles.entity.small_sulfur_cube.jump": "小型硫方怪:弹起", + "subtitles.entity.sniffer.death": "嗅探兽:死亡", + "subtitles.entity.sniffer.digging": "嗅探兽:挖掘", + "subtitles.entity.sniffer.digging_stop": "嗅探兽:站起", + "subtitles.entity.sniffer.drop_seed": "嗅探兽:丢下种子", + "subtitles.entity.sniffer.eat": "嗅探兽:进食", + "subtitles.entity.sniffer.egg_crack": "嗅探兽蛋:裂开", + "subtitles.entity.sniffer.egg_hatch": "嗅探兽蛋:孵化", + "subtitles.entity.sniffer.happy": "嗅探兽:愉悦", + "subtitles.entity.sniffer.hurt": "嗅探兽:受伤", + "subtitles.entity.sniffer.idle": "嗅探兽:呼噜", + "subtitles.entity.sniffer.scenting": "嗅探兽:嗅闻", + "subtitles.entity.sniffer.searching": "嗅探兽:搜寻", + "subtitles.entity.sniffer.sniffing": "嗅探兽:嗅探", + "subtitles.entity.sniffer.step": "嗅探兽:脚步声", + "subtitles.entity.snow_golem.death": "雪傀儡:死亡", + "subtitles.entity.snow_golem.hurt": "雪傀儡:受伤", + "subtitles.entity.snowball.throw": "雪球:飞出", + "subtitles.entity.spider.ambient": "蜘蛛:嘶嘶", + "subtitles.entity.spider.death": "蜘蛛:死亡", + "subtitles.entity.spider.hurt": "蜘蛛:受伤", + "subtitles.entity.squid.ambient": "鱿鱼:游泳", + "subtitles.entity.squid.death": "鱿鱼:死亡", + "subtitles.entity.squid.hurt": "鱿鱼:受伤", + "subtitles.entity.squid.squirt": "鱿鱼:喷墨", + "subtitles.entity.stray.ambient": "流浪者:咯咯声", + "subtitles.entity.stray.death": "流浪者:死亡", + "subtitles.entity.stray.hurt": "流浪者:受伤", + "subtitles.entity.strider.death": "炽足兽:死亡", + "subtitles.entity.strider.eat": "炽足兽:进食", + "subtitles.entity.strider.happy": "炽足兽:颤鸣", + "subtitles.entity.strider.hurt": "炽足兽:受伤", + "subtitles.entity.strider.idle": "炽足兽:啾啾", + "subtitles.entity.strider.retreat": "炽足兽:退缩", + "subtitles.entity.sulfur_cube.absorb": "硫方怪:填充", + "subtitles.entity.sulfur_cube.bounce": "硫方怪:弹起", + "subtitles.entity.sulfur_cube.death": "硫方怪:死亡", + "subtitles.entity.sulfur_cube.eject": "方块:被移除", + "subtitles.entity.sulfur_cube.hit": "硫方怪:受击", + "subtitles.entity.sulfur_cube.hurt": "硫方怪:受伤", + "subtitles.entity.sulfur_cube.jump": "硫方怪:弹起", + "subtitles.entity.sulfur_cube.push": "硫方怪:被推动", + "subtitles.entity.sulfur_cube.squish": "硫方怪:弹起", + "subtitles.entity.tadpole.death": "蝌蚪:死亡", + "subtitles.entity.tadpole.flop": "蝌蚪:扑腾", + "subtitles.entity.tadpole.grow_up": "蝌蚪:成长", + "subtitles.entity.tadpole.hurt": "蝌蚪:受伤", + "subtitles.entity.tnt.primed": "TNT:嘶嘶作响", + "subtitles.entity.tropical_fish.death": "热带鱼:死亡", + "subtitles.entity.tropical_fish.flop": "热带鱼:扑腾", + "subtitles.entity.tropical_fish.hurt": "热带鱼:受伤", + "subtitles.entity.turtle.ambient_land": "海龟:啾啾", + "subtitles.entity.turtle.death": "海龟:死亡", + "subtitles.entity.turtle.death_baby": "幼年海龟:死亡", + "subtitles.entity.turtle.egg_break": "海龟蛋:破裂", + "subtitles.entity.turtle.egg_crack": "海龟蛋:裂开", + "subtitles.entity.turtle.egg_hatch": "海龟蛋:孵化", + "subtitles.entity.turtle.hurt": "海龟:受伤", + "subtitles.entity.turtle.hurt_baby": "幼年海龟:受伤", + "subtitles.entity.turtle.lay_egg": "海龟:产卵", + "subtitles.entity.turtle.shamble": "海龟:爬行", + "subtitles.entity.turtle.shamble_baby": "幼年海龟:爬行", + "subtitles.entity.turtle.swim": "海龟:游泳", + "subtitles.entity.vex.ambient": "恼鬼:恼人", + "subtitles.entity.vex.charge": "恼鬼:尖叫", + "subtitles.entity.vex.death": "恼鬼:死亡", + "subtitles.entity.vex.hurt": "恼鬼:受伤", + "subtitles.entity.villager.ambient": "村民:喃喃自语", + "subtitles.entity.villager.celebrate": "村民:欢呼", + "subtitles.entity.villager.death": "村民:死亡", + "subtitles.entity.villager.hurt": "村民:受伤", + "subtitles.entity.villager.no": "村民:拒绝", + "subtitles.entity.villager.trade": "村民:交易", + "subtitles.entity.villager.work_armorer": "盔甲匠:工作", + "subtitles.entity.villager.work_butcher": "屠夫:工作", + "subtitles.entity.villager.work_cartographer": "制图师:工作", + "subtitles.entity.villager.work_cleric": "牧师:工作", + "subtitles.entity.villager.work_farmer": "农民:工作", + "subtitles.entity.villager.work_fisherman": "渔夫:工作", + "subtitles.entity.villager.work_fletcher": "制箭师:工作", + "subtitles.entity.villager.work_leatherworker": "皮匠:工作", + "subtitles.entity.villager.work_librarian": "图书管理员:工作", + "subtitles.entity.villager.work_mason": "石匠:工作", + "subtitles.entity.villager.work_shepherd": "牧羊人:工作", + "subtitles.entity.villager.work_toolsmith": "工具匠:工作", + "subtitles.entity.villager.work_weaponsmith": "武器匠:工作", + "subtitles.entity.villager.yes": "村民:同意", + "subtitles.entity.vindicator.ambient": "卫道士:低语", + "subtitles.entity.vindicator.celebrate": "卫道士:欢呼", + "subtitles.entity.vindicator.death": "卫道士:死亡", + "subtitles.entity.vindicator.hurt": "卫道士:受伤", + "subtitles.entity.wandering_trader.ambient": "流浪商人:喃喃自语", + "subtitles.entity.wandering_trader.death": "流浪商人:死亡", + "subtitles.entity.wandering_trader.disappeared": "流浪商人:隐身", + "subtitles.entity.wandering_trader.drink_milk": "流浪商人:喝奶", + "subtitles.entity.wandering_trader.drink_potion": "流浪商人:饮用药水", + "subtitles.entity.wandering_trader.hurt": "流浪商人:受伤", + "subtitles.entity.wandering_trader.no": "流浪商人:拒绝", + "subtitles.entity.wandering_trader.reappeared": "流浪商人:现身", + "subtitles.entity.wandering_trader.trade": "流浪商人:交易", + "subtitles.entity.wandering_trader.yes": "流浪商人:同意", + "subtitles.entity.warden.agitated": "监守者:怒吼", + "subtitles.entity.warden.ambient": "监守者:呻吟", + "subtitles.entity.warden.angry": "监守者:狂怒", + "subtitles.entity.warden.attack_impact": "监守者:击中", + "subtitles.entity.warden.death": "监守者:死亡", + "subtitles.entity.warden.dig": "监守者:掘地", + "subtitles.entity.warden.emerge": "监守者:现身", + "subtitles.entity.warden.heartbeat": "监守者:心跳声", + "subtitles.entity.warden.hurt": "监守者:受伤", + "subtitles.entity.warden.listening": "监守者:察觉到某物", + "subtitles.entity.warden.listening_angry": "监守者:愤怒地察觉到某物", + "subtitles.entity.warden.nearby_close": "监守者:接近", + "subtitles.entity.warden.nearby_closer": "监守者:逼近", + "subtitles.entity.warden.nearby_closest": "监守者:迫近", + "subtitles.entity.warden.roar": "监守者:咆哮", + "subtitles.entity.warden.sniff": "监守者:嗅闻", + "subtitles.entity.warden.sonic_boom": "监守者:发射音波", + "subtitles.entity.warden.sonic_charge": "监守者:蓄力", + "subtitles.entity.warden.step": "监守者:脚步声", + "subtitles.entity.warden.tendril_clicks": "监守者:卷须颤动", + "subtitles.entity.wind_charge.throw": "风弹:飞行", + "subtitles.entity.wind_charge.wind_burst": "风弹:爆裂", + "subtitles.entity.witch.ambient": "女巫:暗笑", + "subtitles.entity.witch.celebrate": "女巫:欢呼", + "subtitles.entity.witch.death": "女巫:死亡", + "subtitles.entity.witch.drink": "女巫:饮用药水", + "subtitles.entity.witch.hurt": "女巫:受伤", + "subtitles.entity.witch.throw": "女巫:投掷", + "subtitles.entity.wither.ambient": "凋灵:愤怒", + "subtitles.entity.wither.death": "凋灵:死亡", + "subtitles.entity.wither.hurt": "凋灵:受伤", + "subtitles.entity.wither.shoot": "凋灵:攻击", + "subtitles.entity.wither.spawn": "凋灵:解放", + "subtitles.entity.wither_skeleton.ambient": "凋灵骷髅:咯咯声", + "subtitles.entity.wither_skeleton.death": "凋灵骷髅:死亡", + "subtitles.entity.wither_skeleton.hurt": "凋灵骷髅:受伤", + "subtitles.entity.wolf.ambient": "狼:喘息", + "subtitles.entity.wolf.bark": "狼:吠叫", + "subtitles.entity.wolf.death": "狼:死亡", + "subtitles.entity.wolf.growl": "狼:嚎叫", + "subtitles.entity.wolf.hurt": "狼:受伤", + "subtitles.entity.wolf.pant": "狼:喘息", + "subtitles.entity.wolf.shake": "狼:摇动", + "subtitles.entity.wolf.whine": "狼:呜咽", + "subtitles.entity.zoglin.ambient": "僵尸疣猪兽:咆哮", + "subtitles.entity.zoglin.angry": "僵尸疣猪兽:怒吼", + "subtitles.entity.zoglin.attack": "僵尸疣猪兽:攻击", + "subtitles.entity.zoglin.death": "僵尸疣猪兽:死亡", + "subtitles.entity.zoglin.hurt": "僵尸疣猪兽:受伤", + "subtitles.entity.zoglin.step": "僵尸疣猪兽:脚步声", + "subtitles.entity.zombie.ambient": "僵尸:低吼", + "subtitles.entity.zombie.attack_wooden_door": "门:晃动", + "subtitles.entity.zombie.break_wooden_door": "门:毁坏", + "subtitles.entity.zombie.converted_to_drowned": "僵尸:转化为溺尸", + "subtitles.entity.zombie.converted_to_frostbite": "僵尸:转化为冻尸", + "subtitles.entity.zombie.death": "僵尸:死亡", + "subtitles.entity.zombie.destroy_egg": "海龟蛋:被踩踏", + "subtitles.entity.zombie.hurt": "僵尸:受伤", + "subtitles.entity.zombie.infect": "僵尸:感染", + "subtitles.entity.zombie_horse.ambient": "僵尸马:嘶叫", + "subtitles.entity.zombie_horse.angry": "僵尸马:嘶鸣", + "subtitles.entity.zombie_horse.death": "僵尸马:死亡", + "subtitles.entity.zombie_horse.eat": "僵尸马:进食", + "subtitles.entity.zombie_horse.hurt": "僵尸马:受伤", + "subtitles.entity.zombie_nautilus.ambient": "僵尸鹦鹉螺:嗡嗡作响", + "subtitles.entity.zombie_nautilus.ambient_land": "僵尸鹦鹉螺:咕噜", + "subtitles.entity.zombie_nautilus.dash": "僵尸鹦鹉螺:喷进", + "subtitles.entity.zombie_nautilus.dash_land": "僵尸鹦鹉螺:喷进", + "subtitles.entity.zombie_nautilus.dash_ready": "僵尸鹦鹉螺:恢复体力", + "subtitles.entity.zombie_nautilus.dash_ready_land": "僵尸鹦鹉螺:恢复体力", + "subtitles.entity.zombie_nautilus.death": "僵尸鹦鹉螺:死亡", + "subtitles.entity.zombie_nautilus.death_land": "僵尸鹦鹉螺:死亡", + "subtitles.entity.zombie_nautilus.eat": "僵尸鹦鹉螺:进食", + "subtitles.entity.zombie_nautilus.hurt": "僵尸鹦鹉螺:受伤", + "subtitles.entity.zombie_nautilus.hurt_land": "僵尸鹦鹉螺:受伤", + "subtitles.entity.zombie_nautilus.riding": "僵尸鹦鹉螺:冒泡", + "subtitles.entity.zombie_nautilus.swim": "僵尸鹦鹉螺:游泳", + "subtitles.entity.zombie_villager.ambient": "僵尸村民:低吼", + "subtitles.entity.zombie_villager.converted": "僵尸村民:哀嚎", + "subtitles.entity.zombie_villager.cure": "僵尸村民:撕心裂肺", + "subtitles.entity.zombie_villager.death": "僵尸村民:死亡", + "subtitles.entity.zombie_villager.hurt": "僵尸村民:受伤", + "subtitles.entity.zombified_piglin.ambient": "僵尸猪灵:呼噜", + "subtitles.entity.zombified_piglin.angry": "僵尸猪灵:愤怒地呼噜", + "subtitles.entity.zombified_piglin.death": "僵尸猪灵:死亡", + "subtitles.entity.zombified_piglin.hurt": "僵尸猪灵:受伤", + "subtitles.event.mob_effect.bad_omen": "不祥之兆:显现", + "subtitles.event.mob_effect.freezing": "开始冰冻", + "subtitles.event.mob_effect.raid_omen": "袭击:迫在眉睫", + "subtitles.event.mob_effect.trial_omen": "不祥试炼:迫在眉睫", + "subtitles.event.raid.horn": "不祥号角:鸣响", + "subtitles.item.armor.equip": "盔甲:被装备", + "subtitles.item.armor.equip_chain": "锁链盔甲:碰擦", + "subtitles.item.armor.equip_copper": "铜盔甲:哐咚", + "subtitles.item.armor.equip_diamond": "钻石盔甲:碰擦", + "subtitles.item.armor.equip_elytra": "鞘翅:沙沙作响", + "subtitles.item.armor.equip_gold": "金盔甲:叮当", + "subtitles.item.armor.equip_iron": "铁盔甲:铿锵", + "subtitles.item.armor.equip_leather": "皮革盔甲:摩擦", + "subtitles.item.armor.equip_nautilus": "鹦鹉螺铠:被装备", + "subtitles.item.armor.equip_netherite": "下界合金盔甲:铿锵", + "subtitles.item.armor.equip_turtle": "海龟壳:咕咚", + "subtitles.item.armor.equip_wolf": "狼铠:被装备", + "subtitles.item.armor.unequip_nautilus": "鹦鹉螺铠:被卸下", + "subtitles.item.armor.unequip_wolf": "狼铠:滑落", + "subtitles.item.axe.scrape": "斧:刮削", + "subtitles.item.axe.strip": "斧:削皮", + "subtitles.item.axe.wax_off": "脱蜡", + "subtitles.item.bone_meal.use": "骨粉:沙沙作响", + "subtitles.item.book.page_turn": "书页:沙沙作响", + "subtitles.item.book.put": "书:放置", + "subtitles.item.bottle.empty": "玻璃瓶:倒空", + "subtitles.item.bottle.fill": "玻璃瓶:装满", + "subtitles.item.brush.brushing.generic": "刷扫中", + "subtitles.item.brush.brushing.gravel": "刷扫沙砾中", + "subtitles.item.brush.brushing.gravel.complete": "刷扫沙砾完毕", + "subtitles.item.brush.brushing.sand": "刷扫沙子中", + "subtitles.item.brush.brushing.sand.complete": "刷扫沙子完毕", + "subtitles.item.bucket.empty": "铁桶:倒空", + "subtitles.item.bucket.fill": "铁桶:装满", + "subtitles.item.bucket.fill_axolotl": "美西螈:被装起", + "subtitles.item.bucket.fill_fish": "鱼:被捕获", + "subtitles.item.bucket.fill_sulfur_cube": "硫方怪:被装起", + "subtitles.item.bucket.fill_tadpole": "蝌蚪:被捕获", + "subtitles.item.bundle.drop_contents": "收纳袋:倒空", + "subtitles.item.bundle.insert": "物品:被装入袋中", + "subtitles.item.bundle.insert_fail": "收纳袋:拒绝物品", + "subtitles.item.bundle.remove_one": "物品:被取出袋中", + "subtitles.item.chorus_fruit.teleport": "玩家:传送", + "subtitles.item.crop.plant": "作物:被种植", + "subtitles.item.crossbow.charge": "弩:蓄力", + "subtitles.item.crossbow.hit": "箭:击中", + "subtitles.item.crossbow.load": "弩:装填", + "subtitles.item.crossbow.shoot": "弩:发射", + "subtitles.item.dye.use": "染料:染色", + "subtitles.item.elytra.flying": "呼啸声", + "subtitles.item.firecharge.use": "火焰弹:呼啸", + "subtitles.item.flintandsteel.use": "打火石:点火", + "subtitles.item.glow_ink_sac.use": "荧光墨囊:涂抹", + "subtitles.item.goat_horn.play": "山羊角:吹奏", + "subtitles.item.golden_dandelion.unuse": "金蒲公英:恢复生物成长", + "subtitles.item.golden_dandelion.use": "金蒲公英:中止生物成长", + "subtitles.item.hoe.till": "锄:犁地", + "subtitles.item.honey_bottle.drink": "吞咽", + "subtitles.item.honeycomb.wax_on": "涂蜡", + "subtitles.item.horse_armor.unequip": "马铠:滑落", + "subtitles.item.ink_sac.use": "墨囊:涂抹", + "subtitles.item.lead.break": "拴绳:断开", + "subtitles.item.lead.tied": "拴绳:系上", + "subtitles.item.lead.untied": "拴绳:解开", + "subtitles.item.llama_carpet.unequip": "地毯:滑落", + "subtitles.item.lodestone_compass.lock": "磁石指针:绑定磁石", + "subtitles.item.mace.smash_air": "重锤:猛击", + "subtitles.item.mace.smash_ground": "重锤:猛击", + "subtitles.item.nautilus_saddle_equip": "鞍:被装备", + "subtitles.item.nautilus_saddle_underwater_equip": "鞍:被装备", + "subtitles.item.nether_wart.plant": "作物:被种植", + "subtitles.item.ominous_bottle.dispose": "玻璃瓶:碎裂", + "subtitles.item.saddle.unequip": "鞍:滑落", + "subtitles.item.shears.shear": "剪刀:咔嚓", + "subtitles.item.shears.snip": "剪刀:剪断", + "subtitles.item.shield.block": "盾牌:格挡", + "subtitles.item.shovel.flatten": "锹:压地", + "subtitles.item.spear.attack": "矛:戳刺", + "subtitles.item.spear.hit": "矛:击中", + "subtitles.item.spear.lunge": "矛:突进", + "subtitles.item.spear.use": "持矛冲锋", + "subtitles.item.spear_wood.attack": "矛:戳刺", + "subtitles.item.spear_wood.hit": "矛:击中", + "subtitles.item.spear_wood.use": "持矛冲锋", + "subtitles.item.spyglass.stop_using": "望远镜:缩小", + "subtitles.item.spyglass.use": "望远镜:放大", + "subtitles.item.totem.use": "图腾:发动", + "subtitles.item.trident.hit": "三叉戟:突刺", + "subtitles.item.trident.hit_ground": "三叉戟:振动", + "subtitles.item.trident.return": "三叉戟:收回", + "subtitles.item.trident.riptide": "三叉戟:突进", + "subtitles.item.trident.throw": "三叉戟:铿锵", + "subtitles.item.trident.thunder": "三叉戟:电闪雷鸣", + "subtitles.item.underwater_saddle.equip": "鞍:被装备", + "subtitles.item.wolf_armor.break": "狼铠:损坏", + "subtitles.item.wolf_armor.crack": "狼铠:裂开", + "subtitles.item.wolf_armor.damage": "狼铠:受损", + "subtitles.item.wolf_armor.repair": "狼铠:被修复", + "subtitles.particle.soul_escape": "灵魂:逸散", + "subtitles.ui.cartography_table.take_result": "地图:被绘制", + "subtitles.ui.hud.bubble_pop": "氧气值:减少", + "subtitles.ui.loom.take_result": "织布机:被使用", + "subtitles.ui.stonecutter.take_result": "切石机:被使用", + "subtitles.weather.end_flash": "末地闪光:轰鸣", + "subtitles.weather.rain": "雨:落下", + "symlink_warning.message": "从含有符号链接的文件夹中加载世界可能导致风险,尤其是在你不清楚链接目标内容时。请访问%s了解更多信息。", + "symlink_warning.message.pack": "继续操作前请知晓,加载含有符号链接的包可能会有风险。请访问%s了解更多信息。", + "symlink_warning.message.world": "继续操作前请知晓,从含有符号链接的文件夹中加载世界可能会有风险。请访问%s了解更多信息。", + "symlink_warning.more_info": "更多信息", + "symlink_warning.title": "世界文件夹包含符号链接", + "symlink_warning.title.pack": "添加的包中含有符号链接", + "symlink_warning.title.world": "世界文件夹中含有符号链接", + "team.collision.always": "总是碰撞", + "team.collision.never": "禁用碰撞", + "team.collision.pushOtherTeams": "队伍间碰撞", + "team.collision.pushOwnTeam": "队伍内碰撞", + "team.notFound": "未知的队伍“%s”", + "team.visibility.always": "始终显示", + "team.visibility.hideForOtherTeams": "对别队隐藏", + "team.visibility.hideForOwnTeam": "对本队隐藏", + "team.visibility.never": "始终隐藏", + "telemetry.event.advancement_made.description": "了解达成进度前后发生的事情有助于我们更好了解及改进游戏流程。", + "telemetry.event.advancement_made.title": "达成进度", + "telemetry.event.game_load_times.description": "此事件会测量游戏启动各个阶段的时长以帮助我们找到游戏启动的性能优化点。", + "telemetry.event.game_load_times.title": "游戏加载时间", + "telemetry.event.graphics_capabilities.description": "此事件告知我们运行游戏所使用的图形设备的功能信息。", + "telemetry.event.graphics_capabilities.title": "图形功能", + "telemetry.event.optional": "%s(可选)", + "telemetry.event.optional.disabled": "%s(可选,已禁用)", + "telemetry.event.p2p_connection.description": "此事件有助于我们了解玩家之间点对点连接的可靠性和性能情况。事件将记录连接是否成功、所使用的交互式连接建立(ICE,即我们用于建立点对点连接的协议)候选类型,以及建立连接所花费的时间。", + "telemetry.event.p2p_connection.title": "点对点连接", + "telemetry.event.performance_metrics.description": "了解Minecraft的整体性能概况,可以帮助我们针对各种机型和操作系统调整与优化游戏。 \n游戏版本信息也包含在其中,以帮助我们比较Minecraft新版本的性能概况。", + "telemetry.event.performance_metrics.title": "性能指标", + "telemetry.event.required": "%s(必要)", + "telemetry.event.world_load_times.description": "对我们而言,了解加入世界所需的时长,以及这一时长随着时间推移会如何变化是很重要的。比如,当我们加入新功能或进行较大的技术更改时,我们需要知道这对加载时间有何影响。", + "telemetry.event.world_load_times.title": "世界加载时间", + "telemetry.event.world_loaded.description": "了解玩家玩Minecraft时的细节(例如游戏模式、客户端或服务端是否被修改,以及游戏版本)有助于我们在游戏更新时注重改进玩家最关心的部分。\n世界加载事件将同世界卸载事件一起用于计算游戏会话的持续时长。", + "telemetry.event.world_loaded.title": "世界加载", + "telemetry.event.world_unloaded.description": "此事件与世界加载事件用于计算世界会话的持续时间。\n持续时间(以秒和刻为单位)在世界会话结束时(退回到标题屏幕,或从服务器断开连接)测量。", + "telemetry.event.world_unloaded.title": "世界卸载", + "telemetry.property.advancement_game_time.title": "游戏时间(刻)", + "telemetry.property.advancement_id.title": "进度ID", + "telemetry.property.backend_failure_message.title": "后端创建失败消息", + "telemetry.property.backend_failure_missing_capabilities.title": "后端创建失败缺失功能", + "telemetry.property.backend_failure_reason.title": "后端创建失败原因", + "telemetry.property.backend_name.title": "后端名称", + "telemetry.property.client_id.title": "客户端ID", + "telemetry.property.client_modded.title": "客户端是否被修改", + "telemetry.property.dedicated_memory_kb.title": "专用内存(kB)", + "telemetry.property.event_timestamp_utc.title": "事件时间戳(UTC)", + "telemetry.property.frame_rate_samples.title": "帧率样本(FPS)", + "telemetry.property.game_mode.title": "游戏模式", + "telemetry.property.game_version.title": "游戏版本", + "telemetry.property.launcher_name.title": "启动器名称", + "telemetry.property.load_time_bootstrap_ms.title": "启动引导时间(毫秒)", + "telemetry.property.load_time_loading_overlay_ms.title": "加载屏幕显示时间(毫秒)", + "telemetry.property.load_time_pre_window_ms.title": "窗口开启前时间(毫秒)", + "telemetry.property.load_time_total_time_ms.title": "总计加载时间(毫秒)", + "telemetry.property.minecraft_session_id.title": "Minecraft会话ID", + "telemetry.property.new_world.title": "是否为新的世界", + "telemetry.property.number_of_samples.title": "样本数", + "telemetry.property.operating_system.title": "操作系统", + "telemetry.property.opt_in.title": "可选择加入", + "telemetry.property.p2p_connection_failure_stage.title": "失败阶段", + "telemetry.property.p2p_connection_ice_connect_time_ms.title": "交互式连接建立(ICE)连接时间(毫秒)", + "telemetry.property.p2p_connection_ice_path.title": "交互式连接建立(ICE)路径", + "telemetry.property.p2p_connection_local_candidate_type.title": "本地候选类型", + "telemetry.property.p2p_connection_remote_candidate_type.title": "远程候选类型", + "telemetry.property.p2p_connection_signaling_time_ms.title": "信令连接时间(毫秒)", + "telemetry.property.p2p_connection_successful.title": "连接成功", + "telemetry.property.p2p_connection_total_time_ms.title": "总计连接时间(毫秒)", + "telemetry.property.platform.title": "平台", + "telemetry.property.realms_map_content.title": "Realm地图内容(小游戏名称)", + "telemetry.property.render_distance.title": "渲染距离", + "telemetry.property.render_time_samples.title": "渲染用时样本", + "telemetry.property.seconds_since_load.title": "自加载后的时间(秒)", + "telemetry.property.server_modded.title": "服务端是否被修改", + "telemetry.property.server_session_id.title": "服务器会话ID", + "telemetry.property.server_type.title": "服务器类型", + "telemetry.property.ticks_since_load.title": "自加载后的时间(刻)", + "telemetry.property.used_memory_samples.title": "已使用的内存", + "telemetry.property.user_id.title": "用户ID", + "telemetry.property.world_load_time_ms.title": "世界加载时间(毫秒)", + "telemetry.property.world_session_id.title": "世界会话ID", + "telemetry_info.button.give_feedback": "提供反馈", + "telemetry_info.button.privacy_statement": "隐私声明", + "telemetry_info.button.show_data": "打开我的数据", + "telemetry_info.opt_in.description": "我同意发送可选的遥测数据", + "telemetry_info.property_title": "包含的数据", + "telemetry_info.screen.description": "收集这些数据可以让我们了解与玩家有关的实际情况,以便帮助我们改进Minecraft。\n你也可以发送其他反馈来帮助我们持续改进Minecraft。", + "telemetry_info.screen.title": "遥测数据收集", + "test.error.block_property_mismatch": "属性%s应为%s:实际为%s", + "test.error.block_property_missing": "缺少方块属性。属性%s应为%s", + "test.error.entity_property": "实体%s测试失败:%s", + "test.error.entity_property_details": "实体%s测试失败:%s。应为%s:实际为%s", + "test.error.expected_block": "方块应为%s:实际为%s", + "test.error.expected_block_present": "应存在方块%s", + "test.error.expected_block_tag": "方块应在#%s中:实际为%s", + "test.error.expected_container_contents": "容器应包含:%s", + "test.error.expected_container_contents_single": "容器应含有单个:%s", + "test.error.expected_empty_container": "容器应为空", + "test.error.expected_entity": "缺少%s", + "test.error.expected_entity_around": "%2$s, %3$s, %4$s周围应存在%1$s", + "test.error.expected_entity_count": "应有%s个类型为%s的实体:实际有%s个", + "test.error.expected_entity_data": "实体数据应为%s:实际为%s", + "test.error.expected_entity_data_predicate": "%s的实体数据不匹配", + "test.error.expected_entity_effect": "%s应有%s %s效果", + "test.error.expected_entity_having": "实体物品栏中应包含%s", + "test.error.expected_entity_holding": "实体应持有%s", + "test.error.expected_entity_in_test": "%s应存在于测试中", + "test.error.expected_entity_not_touching": "%s不应接触%s, %s, %s(相对位置:%s, %s, %s)", + "test.error.expected_entity_touching": "%s应接触%s, %s, %s(相对位置:%s, %s, %s)", + "test.error.expected_item": "应为%s类型的物品", + "test.error.expected_items_count": "应为%s个%s类型的物品:实际为%s个", + "test.error.fail": "满足失败条件", + "test.error.invalid_block_type": "发现非预期的方块类型:%s", + "test.error.missing_block_entity": "缺少方块实体", + "test.error.not_a_block_item": "指定的方块不是方块物品", + "test.error.position": "%s,位于%s, %s, %s(相对位置:%s, %s, %s),时间为第%s刻", + "test.error.sequence.condition_already_triggered": "条件已在%s刻时触发", + "test.error.sequence.condition_not_triggered": "条件未触发", + "test.error.sequence.invalid_tick": "成功于无效的刻:应为%s", + "test.error.sequence.minimum_tick": "成功于预期的刻之前:应等待%s", + "test.error.sequence.not_completed": "测试在序列完成前超时", + "test.error.set_biome": "设置测试生物群系失败", + "test.error.spawn_failure": "创建实体%s失败", + "test.error.state_not_equal": "状态错误。应为%s:实际为%s", + "test.error.structure.failure": "为%s放置测试结构失败", + "test.error.tick": "%s,时间为%s刻", + "test.error.ticking_without_structure": "尚未放置结构时执行了测试", + "test.error.timeout.no_result": "未在%s刻内成功或失败", + "test.error.timeout.no_sequences_finished": "在%s刻内没有序列完成", + "test.error.too_many_entities": "%2$s, %3$s, %4$s周围应仅存在一个%1$s,实际为%5$s个", + "test.error.unexpected_block": "方块不应为%s", + "test.error.unexpected_entity": "%s不应存在", + "test.error.unexpected_item": "物品不应为类型%s", + "test.error.unknown": "未知的内部错误:%s", + "test.error.value_not_equal": "属性%s应为%s:实际为%s", + "test.error.value_not_in_between": "属性%s应在%s和%s之间:实际为%s", + "test.error.wrong_block_entity": "错误的方块实体类型:%s", + "test.player.coordinates": "玩家坐标:%s, %s, %s,位于%s", + "test.run.coordinates": "测试坐标:%s, %s, %s,位于%s", + "test_block.error.missing": "测试结构缺少%s方块", + "test_block.error.too_many": "%s方块过多", + "test_block.invalid_timeout": "无效的时限(%s),刻数必须为正数", + "test_block.message": "消息:", + "test_block.mode.accept": "接受", + "test_block.mode.fail": "失败", + "test_block.mode.log": "日志输出", + "test_block.mode.start": "启动", + "test_block.mode_info.accept": "接受模式 - 接受(部分)测试的成功", + "test_block.mode_info.fail": "失败模式 - 使测试失败", + "test_block.mode_info.log": "日志输出模式 - 将消息输出到日志", + "test_block.mode_info.start": "启动模式 - 测试的起点", + "test_instance.action.reset": "重置并加载", + "test_instance.action.run": "加载并运行", + "test_instance.action.save": "保存结构", + "test_instance.description.batch": "环境:%s", + "test_instance.description.dimension": "维度:%s", + "test_instance.description.failed": "失败:%s", + "test_instance.description.function": "函数:%s", + "test_instance.description.invalid_id": "无效的测试ID", + "test_instance.description.no_test": "此测试不存在", + "test_instance.description.structure": "结构:%s", + "test_instance.description.type": "类型:%s", + "test_instance.type.block_based": "基于方块的测试", + "test_instance.type.function": "内置函数测试", + "test_instance_block.entities": "实体:", + "test_instance_block.error.no_test": "存在未定义的测试,无法在%s, %s, %s处运行测试实例", + "test_instance_block.error.no_test_structure": "没有测试结构,无法在%s, %s, %s处运行测试实例", + "test_instance_block.error.unable_to_save": "无法保存%s, %s, %s处测试实例的测试结构模板", + "test_instance_block.invalid": "[无效]", + "test_instance_block.reset_success": "测试重置成功:%s", + "test_instance_block.rotation": "旋转角度:", + "test_instance_block.size": "测试结构大小", + "test_instance_block.starting": "正在启动测试%s", + "test_instance_block.test_id": "测试实例ID", + "title.32bit.deprecation": "检测到32位系统:未来将需要64位系统,使用32位系统可能将无法进行游戏!", + "title.32bit.deprecation.realms": "Minecraft不久后需要64位系统才能运行,届时你将无法使用该设备进行游戏或使用Realms服务。你需要自行取消所有Realms订阅。", + "title.32bit.deprecation.realms.check": "不再显示此屏幕", + "title.32bit.deprecation.realms.header": "检测到32位系统", + "title.credits": "© Mojang AB 请勿二次分发!", + "title.multiplayer.disabled": "多人游戏已被禁用,请检查你的Microsoft账户设置。", + "title.multiplayer.disabled.banned.name": "你必须在进行在线游戏前更改名称", + "title.multiplayer.disabled.banned.permanent": "你的账户已被永久封禁,无法进行多人游戏", + "title.multiplayer.disabled.banned.temporary": "你的账户已被暂时封禁,无法进行多人游戏", + "title.multiplayer.lan": "多人游戏(局域网)", + "title.multiplayer.online": "多人游戏(在线)", + "title.multiplayer.other": "多人游戏(第三方服务器)", + "title.multiplayer.realms": "多人游戏(Realms)", + "title.singleplayer": "单人游戏", + "translation.test.args": "%s %s", + "translation.test.complex": "前缀,%s%2$s 然后是 %s 和 %1$s 最后是 %s 还有 %1$s!", + "translation.test.escape": "%%s %%%s %%%%s %%%%%s", + "translation.test.invalid": "% 你好", + "translation.test.invalid2": "%s 你好", + "translation.test.none": "你好,世界!", + "translation.test.world": "世界", + "trim_material.minecraft.amethyst": "紫水晶质", + "trim_material.minecraft.copper": "铜质", + "trim_material.minecraft.diamond": "钻石质", + "trim_material.minecraft.emerald": "绿宝石质", + "trim_material.minecraft.gold": "金质", + "trim_material.minecraft.iron": "铁质", + "trim_material.minecraft.lapis": "青金石质", + "trim_material.minecraft.netherite": "下界合金质", + "trim_material.minecraft.quartz": "石英质", + "trim_material.minecraft.redstone": "红石质", + "trim_material.minecraft.resin": "树脂质", + "trim_pattern.minecraft.bolt": "镶铆盔甲纹饰", + "trim_pattern.minecraft.coast": "海岸盔甲纹饰", + "trim_pattern.minecraft.dune": "沙丘盔甲纹饰", + "trim_pattern.minecraft.eye": "眼眸盔甲纹饰", + "trim_pattern.minecraft.flow": "涡流盔甲纹饰", + "trim_pattern.minecraft.host": "雇主盔甲纹饰", + "trim_pattern.minecraft.raiser": "牧民盔甲纹饰", + "trim_pattern.minecraft.rib": "肋骨盔甲纹饰", + "trim_pattern.minecraft.sentry": "哨兵盔甲纹饰", + "trim_pattern.minecraft.shaper": "塑造盔甲纹饰", + "trim_pattern.minecraft.silence": "幽静盔甲纹饰", + "trim_pattern.minecraft.snout": "猪鼻盔甲纹饰", + "trim_pattern.minecraft.spire": "尖塔盔甲纹饰", + "trim_pattern.minecraft.tide": "潮汐盔甲纹饰", + "trim_pattern.minecraft.vex": "恼鬼盔甲纹饰", + "trim_pattern.minecraft.ward": "监守盔甲纹饰", + "trim_pattern.minecraft.wayfinder": "向导盔甲纹饰", + "trim_pattern.minecraft.wild": "荒野盔甲纹饰", + "tutorial.bundleInsert.description": "单击鼠标右键收纳物品", + "tutorial.bundleInsert.title": "使用收纳袋", + "tutorial.craft_planks.description": "配方书能提供帮助", + "tutorial.craft_planks.title": "合成木板", + "tutorial.find_tree.description": "敲击木头来采集它", + "tutorial.find_tree.title": "找到一棵树", + "tutorial.look.description": "使用你的鼠标来转动", + "tutorial.look.title": "环顾四周", + "tutorial.move.description": "用%s来跳跃", + "tutorial.move.title": "用%s、%s、%s和%s来移动", + "tutorial.open_inventory.description": "按下%s", + "tutorial.open_inventory.title": "打开你的物品栏", + "tutorial.other_players.description": "按%s打开", + "tutorial.other_players.title": "其他玩家", + "tutorial.punch_tree.description": "按住%s", + "tutorial.punch_tree.title": "摧毁树木", + "tutorial.socialInteractions.description": "按%s打开", + "tutorial.socialInteractions.title": "社交", + "upgrade.minecraft.netherite_upgrade": "下界合金升级", + "upgradeWorld.aborted.file_already_exists": "世界文件夹似乎已包含一些升级过的文件。游戏无法正确升级你的世界。", + "upgradeWorld.aborted.message": "出现这种情况,可能是因为在升级过程中,有其他程序访问此世界。\n重启电脑后再试一次,可能有助于解决此问题。如果问题仍然存在,请考虑报告漏洞。", + "upgradeWorld.aborted.reportBug": "报告漏洞", + "upgradeWorld.aborted.title": "升级世界失败", + "upgradeWorld.canceled.message": "你的世界将保持先前的状态。", + "upgradeWorld.canceled.title": "世界升级已取消", + "upgradeWorld.done": "世界升级完成", + "upgradeWorld.failed_cleanup.message": "此世界已成功升级到当前版本,现在可以在世界列表中找到。\n我们在清理过时数据时遇到了问题,因此世界文件夹名称已更改为%s。", + "upgradeWorld.failed_cleanup.title": "升级世界成功", + "upgradeWorld.info.converted": "已升级:%s", + "upgradeWorld.info.file_fix_stage": "阶段:%s/%s", + "upgradeWorld.info.scanning": "扫描文件中…", + "upgradeWorld.info.total": "总计:%s", + "upgradeWorld.joinNow": "你想现在加入这个世界吗?", + "upgradeWorld.progress.percentage": "%s%%", + "upgradeWorld.progress.type.files": "移动文件中", + "upgradeWorld.progress.type.legacy_structures": "升级原有结构中", + "upgradeWorld.progress.type.region": "升级区域中", + "upgradeWorld.symlink.message": "所选世界包含符号链接。\n将世界升级到当前版本的必要步骤不支持使用符号链接。要升级此世界,需先手动处理符号链接。", + "upgradeWorld.symlink.title": "无法升级此世界", + "upgradeWorld.title": "升级世界文件中" +} \ No newline at end of file diff --git a/event_handler/bot/bot_config_apply.ts b/event_handler/bot/bot_config_apply.ts new file mode 100644 index 0000000..12809db --- /dev/null +++ b/event_handler/bot/bot_config_apply.ts @@ -0,0 +1,6 @@ +import type {Socket} from 'socket.io-client'; +import {bot_config_apply_handler} from '../../utils/bot_config_apply_handler.ts'; + +export async function handler(socket: Socket, data: unknown, callback: unknown) { + await bot_config_apply_handler(socket, data, callback); +} diff --git a/event_handler/bot/forward_bot.ts b/event_handler/bot/forward_bot.ts new file mode 100644 index 0000000..13829a6 --- /dev/null +++ b/event_handler/bot/forward_bot.ts @@ -0,0 +1,8 @@ +import type {Socket} from "socket.io-client"; +import {ensure_forward} from "../../bot/forward/manager.ts"; + +export async function handler(socket: Socket, data: unknown, callback: unknown) { + if (typeof callback !== "function") return; + const response = await ensure_forward(socket, data); + try { callback(response); } catch { /* Main may have disconnected while the request was pending. */ } +} diff --git a/event_handler/bot/inject_plugin.ts b/event_handler/bot/inject_plugin.ts new file mode 100644 index 0000000..4dc9cea --- /dev/null +++ b/event_handler/bot/inject_plugin.ts @@ -0,0 +1,6 @@ +import type {Socket} from 'socket.io-client'; +import {plugin_event_handler} from '../../utils/plugin_event_handler.ts'; + +export async function handler(socket: Socket, data: unknown, callback: unknown) { + await plugin_event_handler('inject', socket, data, callback); +} diff --git a/event_handler/bot/start_bot.ts b/event_handler/bot/start_bot.ts new file mode 100644 index 0000000..4582ed8 --- /dev/null +++ b/event_handler/bot/start_bot.ts @@ -0,0 +1,30 @@ +import type {Socket} from "socket.io-client"; +import {bot_config} from "../../types/bot/instance/bot_runtime.ts"; +import {validate_code_map} from "../../utils/validate_code_map.ts"; +import {start_bot_context} from "../../bot/instance"; + +export async function handler(socket: Socket, data: unknown, callback: unknown) { + if (typeof callback !== "function") { + socket.emit("error", { + error: validate_code_map.web.method_not_allowed + }); + return; + } + let result_code: number = validate_code_map.web.invalid_parameter; + try { + if (data && typeof data === "object" && !Array.isArray(data) + && "node_id" in data && typeof data.node_id === "number" + && Number.isSafeInteger(data.node_id) && data.node_id > 0) { + result_code = await start_bot_context(socket, data as bot_config & {node_id: number}, data.node_id); + } + } catch (error) { + console.error("Bot startup request failed", error); + result_code = validate_code_map.bot_context.error; + } + callback({ + status: result_code === validate_code_map.bot_context.instance_connected + || result_code === validate_code_map.bot_context.instance_connecting + || result_code === validate_code_map.bot_context.context_created ? "success" : "error", + data: result_code + }); +} diff --git a/event_handler/bot/stop_bot.ts b/event_handler/bot/stop_bot.ts new file mode 100644 index 0000000..040a898 --- /dev/null +++ b/event_handler/bot/stop_bot.ts @@ -0,0 +1,27 @@ +import type {Socket} from "socket.io-client"; +import {validate_code_map} from "../../utils/validate_code_map.ts"; +import {stop_bot_context} from "../../bot/instance"; + +export async function handler(socket: Socket, data: unknown, callback: unknown) { + if (typeof callback !== "function") { + socket.emit("error", { + error: validate_code_map.web.method_not_allowed + }); + return; + } + let result_code: number = validate_code_map.web.invalid_parameter; + try { + if (data && typeof data === "object" && !Array.isArray(data) + && "bot_id" in data && typeof data.bot_id === "number" + && Number.isSafeInteger(data.bot_id) && data.bot_id > 0) { + result_code = await stop_bot_context(data.bot_id); + } + } catch (error) { + console.error("Bot stop request failed", error); + result_code = validate_code_map.bot_context.error; + } + callback({ + status: result_code === validate_code_map.bot_context.instance_end_by_user ? "success" : "error", + data: result_code + }); +} diff --git a/event_handler/bot/uninject_plugin.ts b/event_handler/bot/uninject_plugin.ts new file mode 100644 index 0000000..bfe7e42 --- /dev/null +++ b/event_handler/bot/uninject_plugin.ts @@ -0,0 +1,6 @@ +import type {Socket} from 'socket.io-client'; +import {plugin_event_handler} from '../../utils/plugin_event_handler.ts'; + +export async function handler(socket: Socket, data: unknown, callback: unknown) { + await plugin_event_handler('uninject', socket, data, callback); +} diff --git a/event_handler/bot/update_plugin_config.ts b/event_handler/bot/update_plugin_config.ts new file mode 100644 index 0000000..84e0e03 --- /dev/null +++ b/event_handler/bot/update_plugin_config.ts @@ -0,0 +1,6 @@ +import type {Socket} from 'socket.io-client'; +import {plugin_event_handler} from '../../utils/plugin_event_handler.ts'; + +export async function handler(socket: Socket, data: unknown, callback: unknown) { + await plugin_event_handler('update_config', socket, data, callback); +} diff --git a/event_handler/init.ts b/event_handler/init.ts new file mode 100644 index 0000000..4a35a5c --- /dev/null +++ b/event_handler/init.ts @@ -0,0 +1,68 @@ +import {readdir} from 'node:fs/promises'; +import {console_print} from '../utils/console_print.ts'; +import type {Socket} from "socket.io-client"; + +type handler = (socket: Socket, ...args: any[]) => unknown | Promise; +const handler_map = new Map(); + +export async function init_event_handler_list(): Promise { + try { + const next_handlers = new Map(); + const directories = [new URL('./', import.meta.url)]; + for (const directory of directories) { + const entries = await readdir(directory, {withFileTypes: true}); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const url = new URL(encodeURIComponent(entry.name), directory); + if (entry.isDirectory()) { + directories.push(new URL(`${url.href}/`)); + continue; + } + if (!entry.isFile() || !/^[a-zA-Z0-9_-]+\.ts$/.test(entry.name) + || url.pathname === new URL(import.meta.url).pathname) { + continue; + } + const event = entry.name.slice(0, -3); + if (next_handlers.has(event)) { + throw new Error(`事件 ${event} 的处理器重复定义`); + } + try { + url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`); + const module = await import(url.href); + if (typeof module.handler !== 'function') { + throw new Error('模块必须导出 handler 函数'); + } + next_handlers.set(event, module.handler); + } catch (error) { + throw new Error(`事件处理器 ${url.href} 加载失败: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + // 全部加载成功后再替换,避免失败时留下不完整的事件表。 + handler_map.clear(); + for (const [event, callback] of next_handlers) { + handler_map.set(event, callback); + } + } catch (error) { + console_print.log(console_print.error(error instanceof Error ? error.message : String(error))); + throw error; + } +} + +export async function event_handler(socket: Socket, event: string, ...args: any[]): Promise { + if (event.startsWith('forward_') && event !== 'forward_bot') { + return; + } + const callback = handler_map.get(event); + if (!callback) { + console_print.log(console_print.warning(`未找到事件 ${event} 的处理器`)); + return; + } + try { + await callback(socket, ...args); + } catch (error) { + const handler_error = new Error(`事件 ${event} 处理失败: ${error instanceof Error ? error.message : String(error)}`); + console_print.log(console_print.error(handler_error.message)); + throw handler_error; + } +} diff --git a/forward_server b/forward_server new file mode 120000 index 0000000..f3a3127 --- /dev/null +++ b/forward_server @@ -0,0 +1 @@ +../forward_server \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..5503c1d --- /dev/null +++ b/index.ts @@ -0,0 +1,351 @@ +import config from "./config.json" with {type: "json"}; +import { io } from "socket.io-client"; +import {console_print} from "./utils/console_print.ts"; +import {event_handler, init_event_handler_list} from "./event_handler/init.ts"; +import {update_plugins} from "./service/server/update/plugins.ts"; +import {update_version_list} from "./service/server/update/version_list.ts"; +import {update_function_list} from './service/server/update/function_list.ts'; +import {update_workflow_catalog} from './service/server/update/workflow_catalog.ts'; +import {start_forward_server, type ControlTransport, type ForwardServerHandle} from "./forward_server/index.ts"; +import {update_all_bot_status} from "./service/server/update/bot_status.ts"; +import {bind_node_session, clear_node_session} from "./bot/forward/manager.ts"; +import {Buffer} from 'node:buffer'; +import {get_bot_context_status, stop_bot_context} from './bot/instance/index.ts'; + +async function main() { + await init_event_handler_list(); + + const {main_server, node} = config; + if (!main_server || typeof main_server.host !== 'string' || !main_server.host.trim() + || !Number.isInteger(main_server.port) || main_server.port < 1 || main_server.port > 65535 + || typeof main_server.use_https !== 'boolean' + || !node || !Number.isSafeInteger(node.id) || node.id < 1 + || typeof node.key !== 'string' || !node.key.trim()) { + console_print.log(console_print.error('配置文件错误,请检查 main_server 和 node 配置')); + throw new Error('Invalid node configuration'); + } + + const reconnect_interval = retry_interval(); + + const protocol = main_server.use_https ? 'https' : 'http'; + const main_server_url = `${protocol}://${main_server.host}:${main_server.port}`; + const main_server_socket = io(main_server_url, { + path: '/api/node', + autoConnect: false, + timeout: 10_000, + reconnection: false, + }); + let reconnect_timer: ReturnType | undefined; + let reconnect_attempts = 0; + let connection_sequence = 0; + let stopped = false; + let node_session_id: string | undefined; + let relay_handle: ForwardServerHandle | undefined; + let relay_starting: Promise | undefined; + let release_control: (() => void) | undefined; + let shutdown: Promise | undefined; + let signal_exit_requested = false; + + function clear_session() { + node_session_id = undefined; + clear_node_session(main_server_socket); + release_control?.(); + release_control = undefined; + } + + function authenticated(sequence: number) { + return !stopped && sequence === connection_sequence + && main_server_socket.connected && node_session_id !== undefined; + } + + function control_transport(sequence: number): ControlTransport { + const listeners = new Map<(...args: any[]) => void, (...args: any[]) => void>(); + return { + on(event, listener) { + const guarded = (...args: any[]) => { + // Hosted relay commands may target any Node, not just this host. + const disconnected = event === 'disconnect' && !stopped + && !main_server_socket.connected && connection_sequence === sequence + 1; + if (authenticated(sequence) || disconnected) listener(...args); + }; + listeners.set(listener, guarded); + main_server_socket.on(event, guarded); + }, + off(event, listener) { + const guarded = listeners.get(listener); + if (guarded) main_server_socket.off(event, guarded); + listeners.delete(listener); + }, + emit(event, ...args) { + if (authenticated(sequence)) main_server_socket.emit(event, ...args); + }, + }; + } + + function stop_connection() { + if (!stopped) { + stopped = true; + connection_sequence++; + clearTimeout(reconnect_timer); + reconnect_timer = undefined; + clear_session(); + main_server_socket.disconnect(); + process.exitCode = 0; + shutdown = (async () => { + let deadline: ReturnType | undefined; + try { + await Promise.race([ + Promise.allSettled([ + (async () => { + // A failed startup has no handle to stop; it is already reported by initialization. + const handle = relay_handle ?? await relay_starting?.catch((): undefined => undefined); + await handle?.stop(); + })(), + (async () => { + const contexts = await get_bot_context_status(); + const results = await Promise.allSettled(contexts.map(({bot_id}) => stop_bot_context(bot_id))); + if (results.some(result => result.status === 'rejected')) throw new Error('Bot cleanup failed'); + })(), + ]).then(results => { + if (results.some(result => result.status === 'rejected')) throw new Error('Shutdown failed'); + }), + new Promise((_resolve, reject) => { + deadline = setTimeout(() => reject(new Error('Shutdown timed out')), 10_000); + }), + ]); + } catch { + console_print.log(console_print.error('节点关闭失败或超时')); + process.exitCode = 1; + } finally { + clearTimeout(deadline); + } + })(); + } + if (!signal_exit_requested) { + signal_exit_requested = true; + // Signal listeners override Node's default exit; do not leave bot timers alive. + void shutdown!.then(() => process.exit(process.exitCode ?? 0)); + } + } + + process.once('SIGINT', stop_connection); + process.once('SIGTERM', stop_connection); + + function schedule_reconnect() { + if (stopped || reconnect_timer !== undefined) { + return; + } + console_print.log(console_print.info(`将在 ${reconnect_interval} 毫秒后重连主服务器,持续重试直至成功`)); + reconnect_timer = setTimeout(() => { + reconnect_timer = undefined; + reconnect_attempts++; + console_print.log(console_print.info(`正在尝试重连主服务器,第 ${reconnect_attempts} 次`)); + try { + main_server_socket.connect(); + } catch { + console_print.log(console_print.error('连接主服务器失败,将继续重试')); + schedule_reconnect(); + } + }, reconnect_interval); + } + + function retry_connection() { + if (stopped) return; + connection_sequence++; + clear_session(); + main_server_socket.disconnect(); + schedule_reconnect(); + } + + main_server_socket.on('connect', async () => { + if (stopped) return; + const sequence = ++connection_sequence; + clearTimeout(reconnect_timer); + reconnect_timer = undefined; + clear_session(); + console_print.log(console_print.info('已连接主服务器,正在注册节点')); + try { + const response = await main_server_socket + .timeout(5000) + .emitWithAck('register_node', { + id: node.id, + key: node.key, + }); + // 旧连接的 ACK 不得影响重连后的注册状态。 + if (stopped || sequence !== connection_sequence || !main_server_socket.connected) { + return; + } + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('节点注册响应格式无效'); + } + if (response.status === 'error') { + console_print.log(console_print.error('节点注册被主服务器拒绝')); + retry_connection(); + return; + } + const {data} = response; + if (Object.keys(response).length !== 2 || !data || Array.isArray(data) + || Object.keys(data).length !== 2 || typeof data.name !== 'string' + || typeof data.node_session_id !== 'string' || !data.node_session_id.trim() + || Buffer.byteLength(data.node_session_id, 'utf8') > 256) { + throw new Error('节点注册响应格式无效'); + } + node_session_id = data.node_session_id; + bind_node_session(main_server_socket, node.id, node_session_id); + console_print.log(console_print.success('节点注册成功')); + } catch { + if (stopped || sequence !== connection_sequence) { + return; + } + console_print.log(console_print.error('节点注册失败,等待重新连接')); + retry_connection(); + return; + } + let initialization_stage = '插件列表上报'; + try { + // @ts-ignore + await update_plugins(main_server_socket, node.id, () => authenticated(sequence)); + if (!authenticated(sequence)) return; + initialization_stage = '函数列表上报'; + await update_function_list(main_server_socket, node.id, () => authenticated(sequence)); + if (!authenticated(sequence)) return; + initialization_stage = '工作流目录上报'; + await update_workflow_catalog(main_server_socket, node.id, () => authenticated(sequence)); + if (!authenticated(sequence)) return; + initialization_stage = '版本列表上报'; + // @ts-ignore + await update_version_list(main_server_socket, node.id); + if (!authenticated(sequence)) return; + initialization_stage = '机器人状态上报'; + // @ts-ignore + await update_all_bot_status(main_server_socket, node.id); + if (!authenticated(sequence)) return; + if (config.forward_server.status) { + initialization_stage = '转发服务器启动'; + const control = control_transport(sequence); + if (!relay_starting) { + relay_starting = start_forward_server({ + forward_id: config.forward_server.id, + key: config.forward_server.key, + allowed_origins: config.forward_server.allowed_origins, + control, + path: config.forward_server.path, + namespace: config.forward_server.namespace, + port: config.forward_server.port, + logger: console_print, + }).then(handle => { + relay_handle = handle; + return handle; + }).catch(error => { + relay_starting = undefined; + throw error; + }); + } + const handle = await relay_starting; + if (!authenticated(sequence)) return; + release_control = handle.bind_control(control); + console_print.log(console_print.info('转发服务器已就绪')); + try { + const response = await main_server_socket + .timeout(5000) + .emitWithAck('register_forward', { + id: config.forward_server.id, + key: config.forward_server.key, + node_id: node.id, + }); + if (!authenticated(sequence)) { + return; + } + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('转发服务器注册响应格式无效'); + } + if (response.status === 'error') { + const code = Number.isSafeInteger(response.data) ? String(response.data) : '未知'; + console_print.log(console_print.error(`转发服务器注册被主服务器拒绝,错误码: ${code}`)); + retry_connection(); + return; + } + console_print.log(console_print.success('转发服务器注册成功')); + } catch { + if (!authenticated(sequence)) { + return; + } + console_print.log(console_print.error('转发服务器注册请求异常')); + retry_connection(); + return; + } + } + reconnect_attempts = 0; + } catch (error) { + if (!authenticated(sequence)) return; + let reason = '操作失败,错误详情已隐藏'; + if (initialization_stage === '转发服务器启动') { + const messages: Record = { + 'A relay secret of at least 16 characters is required': 'forward_server.key 至少需要 16 个字符,且必须与 Main 登记值一致', + 'forward_id must be a nonnegative safe integer': 'forward_server.id 必须是非负安全整数', + 'allowed_origins must contain explicit HTTP(S) origins': 'forward_server.allowed_origins 必须是明确的 HTTP(S) Origin', + 'path must be an absolute Socket.IO path without a trailing slash': 'forward_server.path 格式无效', + 'namespace must be an absolute Socket.IO namespace': 'forward_server.namespace 格式无效', + 'Invalid relay port': 'forward_server.port 格式无效', + }; + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' + && Object.hasOwn(messages, error.message)) reason = messages[error.message]; + if (error && typeof error === 'object' && 'code' in error) { + if (error.code === 'EADDRINUSE') reason = '转发服务器监听端口已被占用'; + if (error.code === 'EACCES') reason = '没有权限监听转发服务器端口'; + } + } + console_print.log(console_print.error(`节点注册后初始化失败,阶段: ${initialization_stage},原因: ${reason}`)); + retry_connection(); + } + }); + + main_server_socket.on('connect_error', () => { + console_print.log(console_print.error('连接主服务器失败')); + schedule_reconnect(); + }); + + main_server_socket.on('error', () => { + console_print.log(console_print.error('主服务器返回错误')); + retry_connection(); + }); + + main_server_socket.on('disconnect', () => { + connection_sequence++; + clear_session(); + console_print.log(console_print.warning('与主服务器断开连接')); + if (stopped) { + return; + } + schedule_reconnect(); + }); + + main_server_socket.onAny((event: string, ...args: any[]) => { + if (!authenticated(connection_sequence)) return; + void event_handler(main_server_socket, event, ...args).catch(() => { + // event_handler 已记录错误,避免重复打印和未处理的 Promise 异常。 + }); + }); + + console_print.log(console_print.info('正在连接主服务器')); + try { + main_server_socket.connect(); + } catch { + console_print.log(console_print.error('连接主服务器失败,将继续重试')); + schedule_reconnect(); + } +} + +function retry_interval(): number { + const interval = config.main_server?.auto_reconnect?.interval; + return Number.isInteger(interval) && interval >= 1 && interval <= 2_147_483_647 ? interval : 10_000; +} + +function bootstrap() { + void main().catch(() => { + console_print.log(console_print.error('节点启动失败,将继续重试')); + setTimeout(bootstrap, retry_interval()); + }); +} + +bootstrap(); diff --git a/package.json b/package.json new file mode 100755 index 0000000..3b002d6 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "node_server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "tsx watch index.ts", + "test": "node --test tests/*.test.mjs", + "forward": "NODE_OPTIONS=\"--preserve-symlinks --preserve-symlinks-main\" tsx forward_server/index.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "12.5.1", + "onFail": "download" + } + }, + "packageManager": "pnpm@12.5.1", + "type": "module", + "dependencies": { + "@wp2508/minecraft-data": "^3.111.6", + "@wp2508/minecraft-protocol": "^1.69.5", + "@wp2508/mineflayer": "^4.42.6", + "async-mutex": "^0.5.0", + "esbuild": "^0.28.2", + "express": "^5.3.0", + "socket.io": "^4.8.4", + "socket.io-client": "^4.8.4", + "tsx": "^4.23.15", + "xstate": "^5.33.3" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^26.6.5", + "typescript": "^7.0.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..defbf68 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2281 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.5.1 + version: 12.5.1 + +packages: + + '@pnpm/exe.android-arm64@12.5.1': + resolution: {integrity: sha512-6LGBmQuDzUmgpHSuy+haG0jrmkwFNa4EuShDZqK3cIBv4Wfb75CzjJmowkzr+iJg40vnBKMQ62t4Ko6mwsId7A==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.5.1': + resolution: {integrity: sha512-091oCltP3Rx89yxL1Wh5rXkUXEyFzyxIzSHKRFDyDFlQkb8Ox8FaDcm+M8PNkMyqnLA+qeGOLzY7Rjj9izl1Ww==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.5.1': + resolution: {integrity: sha512-r1vEMQPVjRexZMyL2HonxJoo3+nuB/+G+tg+f2PK6faRdXX1TDwevKXOxJAfqlPInTy6W/pm912+s+m0NcR4BQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.5.1': + resolution: {integrity: sha512-YHwI1g2B+Y2cAEylOR31r781GWijdJL7Gy7TEn/ZpZ5v/Cuwp5/Pz0EFCgo0M2MKLafAbL/laUMGvOpAdnerAw==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.5.1': + resolution: {integrity: sha512-sFUMHiYFs6q5syFwgaVppzhQSmqnXoJIz/YwTXIOXTDE76ndIpTRpDGAnBCBKjsaEBnqjJA/2l6hpJcNDNoMiA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.5.1': + resolution: {integrity: sha512-zupuxYZBuBEGjUz805/4/Asq01UNziFADANt8yp+LdUjU9bbMa3xY78Bx1PsGDqBy7aiad4sdUMK8ZxH4n4gmQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.5.1': + resolution: {integrity: sha512-6hbMWW2/eQo1b5wvQPe/ndLHVTH88Rav6N+ffD4/h6IuFMjWCgasFYYsFv0GrvpqREAx3SwmZSvli+0NFXqbxA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.5.1': + resolution: {integrity: sha512-Gg3maDzaE/ZFMV9HIx/BwMN0Y6QbEs13URfxmiYHtKjjLAs5wEQZRMUP87YNLYO7cNaEfbM8GRbS9vPosyD9Gw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.5.1': + resolution: {integrity: sha512-6fgWxXYMBMsWQ8DAiRevscX4QZQjwh+FsyCj8ZOk8pMXvqeq84MqBLCmS6KckvnbdqJ+HlSVQ7fs/kd0ldwfhA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.5.1': + resolution: {integrity: sha512-nzMZruRvxDhEfYUhzu+7EmM39+82Dgagc/6hPFPqGqzXNGcIG0anuxbUxfeNtI64SHt91KuuYUmCB/inlM+Pag==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.5.1': + resolution: {integrity: sha512-kHCbegdTNHpzmPtUD4S5fqkVkqgdQR47spF9p2VPP3dIIkZl6Ytk8w0vhlAsAGgge8FmucfwX+vvPKgm0NtOCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.5.1': + resolution: {integrity: sha512-3PkUBYo5z4dgtlnTNIFj7QGpcDUAuqXz9WGVigPDCeccEnhGiRkWmA5102TmYJHtwJP3LfmE+ZF9PGeWhn8p9Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.5.1': + resolution: {integrity: sha512-49NvjZIXHly56/PLoEDD7VphbO4N8dwMXoApElDFCpVGDNGeKlwlib1tXtnpTF6i2/XIsAj7GgatHB5KHXgBnA==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.5.1': + resolution: {integrity: sha512-ePUmQFPAPJFNMwg1lelDx2PdjHqYRiF0yfg5e6qeDbPNOu97QNo557Ru/SxmCrmt8ydQWNuZXWpdEp019md5vw==} + cpu: [x64] + os: [win32] + + pnpm@12.5.1: + resolution: {integrity: sha512-4/MFvHhKK8ifWtO2E4iJRw+ujSr182thIW7JHCw9ZAiXdfneOKrDMQROpA8kXLDVZmOS399lgk4ZB+9qLGLeXw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.5.1': + optional: true + + '@pnpm/exe.android-x64@12.5.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.5.1': + optional: true + + '@pnpm/exe.darwin-x64@12.5.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.5.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.5.1': + optional: true + + '@pnpm/exe.linux-arm64@12.5.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.5.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.5.1': + optional: true + + '@pnpm/exe.linux-s390x@12.5.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.5.1': + optional: true + + '@pnpm/exe.linux-x64@12.5.1': + optional: true + + '@pnpm/exe.win32-arm64@12.5.1': + optional: true + + '@pnpm/exe.win32-x64@12.5.1': + optional: true + + pnpm@12.5.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.5.1 + '@pnpm/exe.android-x64': 12.5.1 + '@pnpm/exe.darwin-arm64': 12.5.1 + '@pnpm/exe.darwin-x64': 12.5.1 + '@pnpm/exe.freebsd-x64': 12.5.1 + '@pnpm/exe.linux-arm64': 12.5.1 + '@pnpm/exe.linux-arm64-musl': 12.5.1 + '@pnpm/exe.linux-ppc64': 12.5.1 + '@pnpm/exe.linux-riscv64': 12.5.1 + '@pnpm/exe.linux-s390x': 12.5.1 + '@pnpm/exe.linux-x64': 12.5.1 + '@pnpm/exe.linux-x64-musl': 12.5.1 + '@pnpm/exe.win32-arm64': 12.5.1 + '@pnpm/exe.win32-x64': 12.5.1 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@wp2508/minecraft-data': + specifier: ^3.111.6 + version: 3.111.6 + '@wp2508/minecraft-protocol': + specifier: ^1.69.5 + version: 1.69.5 + '@wp2508/mineflayer': + specifier: ^4.42.6 + version: 4.42.6 + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + express: + specifier: ^5.3.0 + version: 5.3.0 + socket.io: + specifier: ^4.8.4 + version: 4.8.4 + socket.io-client: + specifier: ^4.8.4 + version: 4.8.4 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + xstate: + specifier: ^5.33.3 + version: 5.33.3 + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + +packages: + + '@azure/msal-common@14.16.1': + resolution: {integrity: sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@2.16.3': + resolution: {integrity: sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==} + engines: {node: '>=16'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node-rsa@1.1.4': + resolution: {integrity: sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==} + + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/readable-stream@4.0.25': + resolution: {integrity: sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@wp2508/minecraft-data@3.111.6': + resolution: {integrity: sha512-0lBIyhPLsCIuQ9UnJVp4s5De0IRPEQTwGuv69EiLuqmKOE06xXYDQpKsS0bZ25G/hNm4P1Xqmdy5ULriH9fT/w==} + + '@wp2508/minecraft-data@3.111.7': + resolution: {integrity: sha512-ZW7n3uB0Ar8pwy/quIkakwl8ax/GcVl2ef/K3DaqmmhijqQEa+nCMXn3Pb/oyiVgT2Hny9JSfYkYlZJ0xlC8Ng==} + + '@wp2508/minecraft-protocol@1.69.5': + resolution: {integrity: sha512-vE0sxa3ZVshLQ4b+ceYRJljhhCozxpO7K2/aWdaiI1OGROtTc9tO0zMDICU/DpCyNIq0c31af4h46B76dDUefg==} + engines: {node: '>=22'} + + '@wp2508/mineflayer@4.42.6': + resolution: {integrity: sha512-NCK3Smwd6+1r0cMXlpw5xuJJhTK9TvEuVegcSOALzV76+qij1CpagriSlkMgHpohSKsy81l/DMczDOr3tbV0kQ==} + engines: {node: '>=22'} + + '@wp2508/prismarine-block@1.23.1': + resolution: {integrity: sha512-rsX3JVBwMT5UyJi5qkltr5hSxRkDLIn6bn5FxA+BW1KoOQQGcD9CTepwSXwfkRBNVTLJXk4j+YO0SbRX5I9bFA==} + + '@wp2508/prismarine-chat@1.13.4': + resolution: {integrity: sha512-809WCArTM05rFcOMSgZjzrZmVroz6XJv8PyJwd8Ibw2/G30kzDFbGZhMC2BbIDb2EVyYWCVkwcQxNPepGO2NAA==} + + '@wp2508/prismarine-chunk@1.41.5': + resolution: {integrity: sha512-HDq6RoNi2vWfFbqouo6Y/Cl9yT7fDuAxQDJOlq85FBBRrFqeeyREmJmTGnGkgZlIeuZYqRKaFh8qutblzmeoRw==} + engines: {node: '>=14'} + + '@wp2508/prismarine-physics@1.12.4': + resolution: {integrity: sha512-WjtC44ltJju9GiZmgU2yEet7SNZ2tsTHvxDYM6oMyb93TxIW/110Mg+ejddDBdC2v7WUwx/M7+iElCndOozyEg==} + + '@wp2508/prismarine-registry@1.12.1': + resolution: {integrity: sha512-Rb2vMJTVfblJRuhsMpJXQ+a5DUojd+07i6cRHQfKi6nlaTwjo/nHM8bmeT5WGg6eOEKxaJU02pql4+/hz13Rrg==} + + '@wp2508/protodef@1.19.2': + resolution: {integrity: sha512-LrfxgyQAKIk0K9GQ1X6VZlWWyB0aKPNIqjbXQ/Z6nLP47YZ6emXgC9F6djjYxbn9wk8j97FZ0bB/cFv4s3lqVw==} + engines: {node: '>=14'} + + '@wp2508/protodef@1.19.3': + resolution: {integrity: sha512-fhFZKeJ1VnyljujQMFXFlKqymtW/U1YQPa9KryMt3dWjkN7BMY5FlsrT5eilTs/bmaZuKrNPnqmmQ2MaWfMpqA==} + engines: {node: '>=14'} + + '@xboxreplay/xboxlive-auth@5.1.0': + resolution: {integrity: sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==} + engines: {node: '>=16.0.0'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + aes-js@3.1.2: + resolution: {integrity: sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + asn1@0.2.3: + resolution: {integrity: sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==} + + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-equal@1.0.1: + resolution: {integrity: sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==} + engines: {node: '>=0.4'} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + endian-toggle@0.0.0: + resolution: {integrity: sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==} + + engine.io-client@6.6.7: + resolution: {integrity: sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.11: + resolution: {integrity: sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==} + engines: {node: '>=10.2.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.reduce@4.6.0: + resolution: {integrity: sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==} + + macaddress@0.5.4: + resolution: {integrity: sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + minecraft-data@3.117.0: + resolution: {integrity: sha512-glKv0EEmVwKvJwdNAKAal2P7gi/nijU9rEMp8gJmqYYRVswS6hA0q9566lcGHH07Ocmpqw5bMRbARC25pR13gA==} + + minecraft-folder-path@1.2.0: + resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==} + + mojangson@2.1.0: + resolution: {integrity: sha512-uz0PPHAc/kRO5tBOmEZIWxLlne3NRqO2PRyvIXbU0523tItgpvt0XG83IFZgH21EaKY1/B9c9ca074/eZlI59Q==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-rsa@0.4.2: + resolution: {integrity: sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + prismarine-auth@3.1.1: + resolution: {integrity: sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==} + + prismarine-biome@1.4.0: + resolution: {integrity: sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==} + peerDependencies: + prismarine-registry: ^1.1.0 + + prismarine-block@1.23.0: + resolution: {integrity: sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==} + + prismarine-chat@1.13.0: + resolution: {integrity: sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==} + + prismarine-entity@2.6.0: + resolution: {integrity: sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==} + + prismarine-item@1.18.0: + resolution: {integrity: sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==} + + prismarine-nbt@2.8.0: + resolution: {integrity: sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==} + + prismarine-realms@1.6.0: + resolution: {integrity: sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==} + + prismarine-recipe@1.5.0: + resolution: {integrity: sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==} + peerDependencies: + prismarine-registry: ^1.4.0 + + prismarine-registry@1.12.0: + resolution: {integrity: sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==} + + prismarine-windows@2.10.0: + resolution: {integrity: sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==} + + prismarine-world@3.7.0: + resolution: {integrity: sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==} + engines: {node: '>=8.0.0'} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + protodef-validator@1.5.0: + resolution: {integrity: sha512-GrPKsNpcd7/4bIAZN3Ojgk5ILsRX9s1IItzU+o29D1o0AKbIaLuBCmaSGbZE90Gn8zxF2/Vj8aPUHjo0AbgFdw==} + hasBin: true + + protodef@1.19.0: + resolution: {integrity: sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==} + engines: {node: '>=14'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} + engines: {node: '>=0.6'} + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-client@4.8.4: + resolution: {integrity: sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.4: + resolution: {integrity: sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==} + engines: {node: '>=10.2.0'} + + socks@2.8.10: + resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typed-emitter@1.4.0: + resolution: {integrity: sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==} + + typed-emitter@2.1.0: + resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + uint4@0.1.2: + resolution: {integrity: sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + uuid-1345@1.0.2: + resolution: {integrity: sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vec3@0.1.10: + resolution: {integrity: sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==} + + vec3@0.2.0: + resolution: {integrity: sha512-jOjU4zbCNWOKOGLHop1I0J2Ar4h5Zz76455LJhJKhiOJwI21QP6d5TnKklNZDKG+c5h71zWtPAbd9wh+Va57OQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + xstate@5.33.3: + resolution: {integrity: sha512-AnDRjS/GyKHmbA1PyhVCYBqYsA7EGe3KGgeD0ht3NFsXzoz7leX/HQDFX9QlHFJ/ltLHPsgChJYwPzRvB/0b4g==} + + xxhash-wasm@0.4.2: + resolution: {integrity: sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==} + + yggdrasil@1.8.0: + resolution: {integrity: sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==} + +snapshots: + + '@azure/msal-common@14.16.1': {} + + '@azure/msal-node@2.16.3': + dependencies: + '@azure/msal-common': 14.16.1 + jsonwebtoken: 9.0.3 + uuid: 8.3.2 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@socket.io/component-emitter@3.1.2': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 26.6.5 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.6.5 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 26.6.5 + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 26.6.5 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node-rsa@1.1.4': + dependencies: + '@types/node': 26.6.5 + + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/readable-stream@4.0.25': + dependencies: + '@types/node': 26.6.5 + + '@types/send@1.2.1': + dependencies: + '@types/node': 26.6.5 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 26.6.5 + + '@types/ws@8.18.2': + dependencies: + '@types/node': 26.6.5 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@wp2508/minecraft-data@3.111.6': {} + + '@wp2508/minecraft-data@3.111.7': {} + + '@wp2508/minecraft-protocol@1.69.5': + dependencies: + '@types/node-rsa': 1.1.4 + '@types/readable-stream': 4.0.25 + aes-js: 3.1.2 + buffer-equal: 1.0.1 + debug: 4.4.3 + endian-toggle: 0.0.0 + lodash.merge: 4.6.2 + minecraft-data: '@wp2508/minecraft-data@3.111.7' + minecraft-folder-path: 1.2.0 + node-fetch: 2.7.0 + node-rsa: 0.4.2 + prismarine-auth: 3.1.1 + prismarine-chat: '@wp2508/prismarine-chat@1.13.4' + prismarine-nbt: 2.8.0 + prismarine-realms: 1.6.0 + protodef: '@wp2508/protodef@1.19.2' + readable-stream: 4.8.0 + uuid-1345: 1.0.2 + yggdrasil: 1.8.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@wp2508/mineflayer@4.42.6': + dependencies: + minecraft-data: '@wp2508/minecraft-data@3.111.7' + minecraft-protocol: '@wp2508/minecraft-protocol@1.69.5' + mojangson: 2.1.0 + prismarine-biome: 1.4.0(@wp2508/prismarine-registry@1.12.1) + prismarine-block: '@wp2508/prismarine-block@1.23.1' + prismarine-chat: '@wp2508/prismarine-chat@1.13.4' + prismarine-chunk: '@wp2508/prismarine-chunk@1.41.5' + prismarine-entity: 2.6.0 + prismarine-item: 1.18.0 + prismarine-nbt: 2.8.0 + prismarine-physics: '@wp2508/prismarine-physics@1.12.4' + prismarine-recipe: 1.5.0(@wp2508/prismarine-registry@1.12.1) + prismarine-registry: '@wp2508/prismarine-registry@1.12.1' + prismarine-windows: 2.10.0 + prismarine-world: 3.7.0 + protodef: '@wp2508/protodef@1.19.3' + socks: 2.8.10 + typed-emitter: 1.4.0 + uuid-1345: 1.0.2 + vec3: 0.1.10 + transitivePeerDependencies: + - encoding + - supports-color + + '@wp2508/prismarine-block@1.23.1': + dependencies: + minecraft-data: '@wp2508/minecraft-data@3.111.7' + prismarine-biome: 1.4.0(@wp2508/prismarine-registry@1.12.1) + prismarine-chat: '@wp2508/prismarine-chat@1.13.4' + prismarine-item: 1.18.0 + prismarine-nbt: 2.8.0 + prismarine-registry: '@wp2508/prismarine-registry@1.12.1' + transitivePeerDependencies: + - supports-color + + '@wp2508/prismarine-chat@1.13.4': + dependencies: + debug: 4.4.3 + mojangson: 2.1.0 + prismarine-nbt: 2.8.0 + prismarine-registry: '@wp2508/prismarine-registry@1.12.1' + transitivePeerDependencies: + - supports-color + + '@wp2508/prismarine-chunk@1.41.5': + dependencies: + minecraft-data: '@wp2508/minecraft-data@3.111.7' + prismarine-biome: 1.4.0(@wp2508/prismarine-registry@1.12.1) + prismarine-block: '@wp2508/prismarine-block@1.23.1' + prismarine-nbt: 2.8.0 + prismarine-registry: '@wp2508/prismarine-registry@1.12.1' + smart-buffer: 4.2.0 + uint4: 0.1.2 + vec3: 0.1.10 + xxhash-wasm: 0.4.2 + transitivePeerDependencies: + - supports-color + + '@wp2508/prismarine-physics@1.12.4': + dependencies: + minecraft-data: '@wp2508/minecraft-data@3.111.7' + prismarine-nbt: 2.8.0 + vec3: 0.2.0 + + '@wp2508/prismarine-registry@1.12.1': + dependencies: + minecraft-data: '@wp2508/minecraft-data@3.111.7' + prismarine-block: '@wp2508/prismarine-block@1.23.1' + prismarine-nbt: 2.8.0 + + '@wp2508/protodef@1.19.2': + dependencies: + lodash.reduce: 4.6.0 + protodef-validator: 1.5.0 + readable-stream: 4.8.0 + + '@wp2508/protodef@1.19.3': + dependencies: + lodash.reduce: 4.6.0 + protodef-validator: 1.5.0 + readable-stream: 4.8.0 + + '@xboxreplay/xboxlive-auth@5.1.0': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + aes-js@3.1.2: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + asn1@0.2.3: {} + + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + + base64-js@1.5.1: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.1 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + buffer-equal-constant-time@1.0.1: {} + + buffer-equal@1.0.1: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + commander@2.20.3: {} + + content-disposition@2.0.1: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + discontinuous-range@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + endian-toggle@0.0.0: {} + + engine.io-client@6.6.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.11: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 26.6.5 + '@types/ws': 8.18.2 + accepts: 1.3.8 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + events@3.3.0: {} + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.1 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ip-address@10.7.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + json-schema-traverse@0.4.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.reduce@4.6.0: {} + + macaddress@0.5.4: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + minecraft-data@3.117.0: {} + + minecraft-folder-path@1.2.0: {} + + mojangson@2.1.0: + dependencies: + nearley: 2.20.1 + + moo@0.5.3: {} + + ms@2.1.3: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + negotiator@0.6.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-rsa@0.4.2: + dependencies: + asn1: 0.2.3 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + prismarine-auth@3.1.1: + dependencies: + '@azure/msal-node': 2.16.3 + '@xboxreplay/xboxlive-auth': 5.1.0 + debug: 4.4.3 + smart-buffer: 4.2.0 + uuid-1345: 1.0.2 + transitivePeerDependencies: + - supports-color + + prismarine-biome@1.4.0(@wp2508/prismarine-registry@1.12.1): + dependencies: + prismarine-registry: '@wp2508/prismarine-registry@1.12.1' + + prismarine-biome@1.4.0(prismarine-registry@1.12.0): + dependencies: + prismarine-registry: 1.12.0 + + prismarine-block@1.23.0: + dependencies: + minecraft-data: 3.117.0 + prismarine-biome: 1.4.0(prismarine-registry@1.12.0) + prismarine-chat: 1.13.0 + prismarine-item: 1.18.0 + prismarine-nbt: 2.8.0 + prismarine-registry: 1.12.0 + transitivePeerDependencies: + - supports-color + + prismarine-chat@1.13.0: + dependencies: + debug: 4.4.3 + mojangson: 2.1.0 + prismarine-nbt: 2.8.0 + prismarine-registry: 1.12.0 + transitivePeerDependencies: + - supports-color + + prismarine-entity@2.6.0: + dependencies: + prismarine-chat: 1.13.0 + prismarine-item: 1.18.0 + prismarine-registry: 1.12.0 + vec3: 0.1.10 + transitivePeerDependencies: + - supports-color + + prismarine-item@1.18.0: + dependencies: + prismarine-nbt: 2.8.0 + prismarine-registry: 1.12.0 + + prismarine-nbt@2.8.0: + dependencies: + protodef: 1.19.0 + + prismarine-realms@1.6.0: + dependencies: + debug: 4.4.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + + prismarine-recipe@1.5.0(@wp2508/prismarine-registry@1.12.1): + dependencies: + prismarine-registry: '@wp2508/prismarine-registry@1.12.1' + + prismarine-registry@1.12.0: + dependencies: + minecraft-data: 3.117.0 + prismarine-block: 1.23.0 + prismarine-nbt: 2.8.0 + + prismarine-windows@2.10.0: + dependencies: + prismarine-item: 1.18.0 + prismarine-registry: 1.12.0 + typed-emitter: 2.1.0 + + prismarine-world@3.7.0: + dependencies: + vec3: 0.1.10 + + process@0.11.10: {} + + protodef-validator@1.5.0: + dependencies: + ajv: 6.15.0 + + protodef@1.19.0: + dependencies: + lodash.reduce: 4.6.0 + protodef-validator: 1.5.0 + readable-stream: 4.8.0 + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.16.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readable-stream@4.8.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + ret@0.1.15: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + optional: true + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + smart-buffer@4.2.0: {} + + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.7 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.4: + dependencies: + accepts: 1.3.8 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.11 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socks@2.8.10: + dependencies: + ip-address: 10.7.4 + smart-buffer: 4.2.0 + + statuses@2.0.2: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + tslib@2.8.1: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typed-emitter@1.4.0: {} + + typed-emitter@2.1.0: + optionalDependencies: + rxjs: 7.8.2 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + uint4@0.1.2: {} + + undici-types@8.9.0: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + uuid-1345@1.0.2: + dependencies: + macaddress: 0.5.4 + + uuid@10.0.0: {} + + uuid@8.3.2: {} + + vary@1.1.2: {} + + vec3@0.1.10: {} + + vec3@0.2.0: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + wrappy@1.0.2: {} + + ws@8.21.3: {} + + xmlhttprequest-ssl@2.1.2: {} + + xstate@5.33.3: {} + + xxhash-wasm@0.4.2: {} + + yggdrasil@1.8.0: + dependencies: + node-fetch: 2.7.0 + uuid: 10.0.0 + transitivePeerDependencies: + - encoding diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..69154f8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +allowBuilds: + esbuild: true +minimumReleaseAgeExclude: + - '@wp2508/minecraft-data@3.111.7' + - '@wp2508/minecraft-protocol@1.69.5' + - '@wp2508/mineflayer@4.42.6' + - '@wp2508/prismarine-block@1.23.1' + - '@wp2508/prismarine-chat@1.13.4' + - '@wp2508/prismarine-chunk@1.41.5' + - '@wp2508/prismarine-registry@1.12.1' diff --git a/service/server/update/bot_status.ts b/service/server/update/bot_status.ts new file mode 100644 index 0000000..ade7591 --- /dev/null +++ b/service/server/update/bot_status.ts @@ -0,0 +1,50 @@ +import {get_bot_context_status} from "../../../bot/instance"; +import type {Socket} from "socket.io-client"; +import {console_print} from "../../../utils/console_print.ts"; +import {bot_status} from "../../../types/bot/instance/bot_runtime.ts"; + +export async function update_all_bot_status(socket: Socket, node_id: number) { + const bot_status = await get_bot_context_status(); + if (!socket.connected) return; + try { + const response = await socket + .timeout(5000) + .emitWithAck('update_all_bot_status', { + id: node_id, + bot_status, + }); + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('响应格式无效'); + } + if (response.status === 'error') { + throw new Error(`收到错误码: ${response.data}`); + } + console_print.log(console_print.success("机器人状态更新成功")) + } catch (error) { + throw new Error(`机器人状态上报失败: ${error instanceof Error ? error.message : String(error)}`); + } +} + + +export async function update_one_bot_status(socket: Socket, node_id: number, bot_id: number, user_id: number, status: bot_status) { + if (!socket.connected) return; + try { + const response = await socket + .timeout(5000) + .emitWithAck('update_one_bot_status', { + id: node_id, + user_id, + bot_id, + status, + }); + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('响应格式无效'); + } + if (response.status === 'error') { + throw new Error(`收到错误码: ${response.data}`); + } + console_print.log(console_print.success(`机器人 ${bot_id} 状态更新成功 更新为 ${status}`)); + } catch (error) { + throw new Error(`机器人 ${bot_id} 状态上报失败: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/service/server/update/function_list.ts b/service/server/update/function_list.ts new file mode 100644 index 0000000..d351b5d --- /dev/null +++ b/service/server/update/function_list.ts @@ -0,0 +1,27 @@ +import type {Socket} from 'socket.io-client'; +import {get_function_list} from '../../../bot/function/init.ts'; +import {console_print} from '../../../utils/console_print.ts'; + +export async function update_function_list( + socket: Socket, + node_id: number, + is_current: () => boolean = () => socket.connected, +): Promise { + try { + const function_list = await get_function_list(); + if (!socket.connected || !is_current()) return; + const response = await socket.timeout(5000).emitWithAck('update_function_list', { + id: node_id, + function_list, + }); + if (!socket.connected || !is_current()) return; + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('响应格式无效'); + } + if (response.status === 'error') throw new Error(`收到错误码: ${response.data}`); + console_print.log(console_print.success('支持的函数列表更新成功')); + } catch (error) { + if (!socket.connected || !is_current()) return; + throw new Error(`函数列表上报失败: ${error instanceof Error ? error.message : String(error)}`, {cause: error}); + } +} diff --git a/service/server/update/plugins.ts b/service/server/update/plugins.ts new file mode 100644 index 0000000..1f0c55a --- /dev/null +++ b/service/server/update/plugins.ts @@ -0,0 +1,25 @@ +import {Socket} from "socket.io"; +import {get_plugin_list} from "../../../bot/plugins/init.ts"; +import {console_print} from "../../../utils/console_print.ts"; + +export async function update_plugins(socket: Socket, node_id: number, is_current: () => boolean = () => socket.connected) { + const plugin_list = await get_plugin_list(); + if (!socket.connected || !is_current()) return; + try { + const response = await socket + .timeout(5000) + .emitWithAck('update_plugins', { + id: node_id, + plugin_list, + }); + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('响应格式无效'); + } + if (response.status === 'error') { + throw new Error(`收到错误码: ${response.data}`); + } + console_print.log(console_print.success("支持的插件列表更新成功")) + } catch (error) { + throw new Error(`插件列表上报失败: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/service/server/update/version_list.ts b/service/server/update/version_list.ts new file mode 100644 index 0000000..f553dd6 --- /dev/null +++ b/service/server/update/version_list.ts @@ -0,0 +1,24 @@ +import minecraftProtocol from "@wp2508/minecraft-protocol"; +import type {Socket} from "socket.io-client"; +import {console_print} from "../../../utils/console_print.ts"; + +export async function update_version_list(socket: Socket, node_id: number) { + const version_list = minecraftProtocol.supportedVersions; + try { + const response = await socket + .timeout(5000) + .emitWithAck('update_version_list', { + id: node_id, + version_list, + }); + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('响应格式无效'); + } + if (response.status === 'error') { + throw new Error(`收到错误码: ${response.data}`); + } + console_print.log(console_print.success("支持的版本列表更新成功")) + } catch (error) { + throw new Error(`版本列表上报失败: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/service/server/update/workflow_catalog.ts b/service/server/update/workflow_catalog.ts new file mode 100644 index 0000000..376d9fc --- /dev/null +++ b/service/server/update/workflow_catalog.ts @@ -0,0 +1,28 @@ +import type {Socket} from 'socket.io-client'; +import {get_workflow_catalog} from '../../../bot/workflow/catalog.ts'; +import {console_print} from '../../../utils/console_print.ts'; + +export async function update_workflow_catalog( + socket: Socket, + node_id: number, + is_current: () => boolean = () => socket.connected, +): Promise { + try { + if (!socket.connected || !is_current()) return; + const workflow_catalog = get_workflow_catalog(); + if (!socket.connected || !is_current()) return; + const response = await socket.timeout(5000).emitWithAck('update_workflow_catalog', { + id: node_id, + workflow_catalog, + }); + if (!socket.connected || !is_current()) return; + if (!response || (response.status !== 'success' && response.status !== 'error')) { + throw new Error('Invalid workflow catalog acknowledgement'); + } + if (response.status === 'error') throw new Error('Workflow catalog report was rejected'); + console_print.log(console_print.success('工作流目录更新成功')); + } catch { + if (!socket.connected || !is_current()) return; + throw new Error('Workflow catalog report failed'); + } +} diff --git a/tests/block_functions.test.mjs b/tests/block_functions.test.mjs new file mode 100644 index 0000000..f1259dd --- /dev/null +++ b/tests/block_functions.test.mjs @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const names = ['destroy', 'place', 'use'].flatMap(action => + ['cursor', 'nearest', 'position', 'position_group'].map(target => `${action}_${target}_block`)); +const operations = Object.fromEntries(await Promise.all(names.map(async name => { + const {init} = await tsImport(`../bot/function/block/${name}.ts`, import.meta.url); + return [name, () => new init()]; +}))); +const require = createRequire(import.meta.url); +const mf = createRequire(require.resolve('@wp2508/mineflayer')); +const {Vec3} = mf('vec3'); + +function fixture() { + const bot = new EventEmitter(); + bot.entity = {position: new Vec3(0, 0, 0), eyeHeight: 1.62, height: 1.8, isValid: true}; + bot._client = Object.assign(new EventEmitter(), {state: 'play'}); + bot.heldItem = {name: 'stone'}; + const block = {name: 'stone', type: 1, stateId: 1, position: new Vec3(2, 1, 0), + boundingBox: 'block', shapes: [[0, 0, 0, 1, 1, 1]], diggable: true}; + const blocks = new Map([[block.position.toString(), block]]); + bot.blockAt = position => blocks.get(position.toString()) ?? null; + bot.findBlocks = options => [...blocks.values()].filter(options.matching).map(block => block.position); + bot.blockAtCursor = reach => { + bot.cursorReach = reach; + return {...block, face: 4, intersect: new Vec3(2, 1.25, 0.5)}; + }; + bot.calls = []; + bot.looks = []; + bot.lookAt = async point => { bot.looks.push(point); }; + bot.canDigBlock = () => true; + bot.dig = async (...args) => { bot.calls.push(['dig', ...args]); }; + bot.placeBlock = async (...args) => { bot.calls.push(['place', ...args]); }; + bot._placeBlockWithOptions = async (...args) => { bot.calls.push(['place', ...args]); }; + bot.activateBlock = async (...args) => { bot.calls.push(['use', ...args]); }; + bot.stopDigging = () => { bot.stops = (bot.stops ?? 0) + 1; bot.targetDigBlock = null; }; + return {bot, block, blocks}; +} + +test('position operations pass correct faces and await API completion', async () => { + const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]]; + for (const action of ['destroy', 'place', 'use']) { + for (let face = 0; face < 6; face++) { + const {bot, block} = fixture(); + assert.equal(await operations[`${action}_position_block`]().run(bot, [2, 1, 0, face]), true); + assert.equal(bot.calls[0][1], block); + const vector = bot.calls[0][action === 'destroy' ? 3 : 2]; + assert.deepEqual([vector.x, vector.y, vector.z], faces[face]); + } + } +}); + +test('cursor operations preserve the hit face and surface offset', async () => { + for (const action of ['destroy', 'place', 'use']) { + const {bot} = fixture(); + assert.equal(await operations[`${action}_cursor_block`]().run(bot, [4.5]), true); + assert.equal(bot.cursorReach, 4.5); + const call = bot.calls[0]; + const vector = call[action === 'destroy' ? 3 : 2]; + assert.deepEqual([vector.x, vector.y, vector.z], [-1, 0, 0]); + if (action === 'destroy') assert.equal(call[2], 'ignore'); + if (action === 'place') { + assert.equal(call[3].forceLook, 'ignore'); + assert.deepEqual([call[3].delta.x, call[3].delta.y, call[3].delta.z], [0, 0.25, 0.5]); + } + if (action === 'use') { + assert.equal(call[4], 'ignore'); + assert.deepEqual([call[3].x, call[3].y, call[3].z], [0, 0.25, 0.5]); + } + } +}); + +test('nearest operations match partial type IDs and choose the closest loaded block', async () => { + for (const action of ['destroy', 'place', 'use']) { + const {bot, block, blocks} = fixture(); + blocks.set('3,1,0', {...block, position: new Vec3(3, 1, 0)}); + assert.equal(await operations[`${action}_nearest_block`]().run(bot, ['MINECRAFT:STO', 4.5, 1]), true); + assert.equal(bot.calls[0][1], block); + assert.equal(await operations[`${action}_nearest_block`]().run(bot, ['dirt', 4.5, 1]), false); + } +}); + +test('all block functions default to no aim and disable internal aiming', async () => { + for (const name of names) { + for (const flag of [[], [false], [true]]) { + const {bot} = fixture(); + const args = name.includes('group') ? [[2, 1, 0, 4]] : name.includes('position') ? [2, 1, 0, 4] + : name.includes('nearest') ? ['stone', 4.5, 4] : [4.5]; + bot.placeBlock = () => { assert.fail('public placement may aim internally'); }; + assert.equal(await operations[name]().run(bot, [...args, ...flag]), true, name); + assert.equal(bot.looks.length, flag[0] === true ? 1 : 0, name); + if (flag[0]) assert.deepEqual(bot.looks[0], new Vec3(2, 1.5, 0.5)); + const call = bot.calls[0]; + if (name.startsWith('destroy')) assert.equal(call[2], 'ignore'); + if (name.startsWith('place')) { + assert.equal(call[3].forceLook, 'ignore'); + assert.equal(call[3].swingArm, 'right'); + if (!name.includes('cursor')) assert.equal(call[3].delta, undefined); + } + if (name.startsWith('use')) assert.equal(call[4], 'ignore'); + } + } +}); + +test('groups await a separate face-center look before every operation on all faces', async () => { + const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]]; + for (const action of ['destroy', 'place', 'use']) { + const {bot, block} = fixture(); + const events = []; + bot.lookAt = async point => { + events.push('look'); + await Promise.resolve(); + bot.looks.push(point); + events.push('looked'); + }; + const record = async () => { events.push('operate'); }; + bot.dig = bot._placeBlockWithOptions = bot.activateBlock = record; + assert.equal(await operations[`${action}_position_group_block`]().run(bot, + [faces.flatMap((_, face) => [2, 1, 0, face]), true]), true); + assert.deepEqual(events, faces.flatMap(() => ['look', 'looked', 'operate'])); + assert.deepEqual(bot.looks, faces.map(face => block.position.offset(0.5, 0.5, 0.5) + .plus(new Vec3(...face).scaled(0.5)))); + } +}); + +test('operations revalidate after aiming and clean up on cancellation or look failure', async () => { + for (const name of names) { + const changes = [ + ({bot}) => bot.emit('end'), + ({bot}) => { bot.entity = {...bot.entity}; }, + ({bot}) => { bot.entity.position = new Vec3(100, 0, 0); }, + ({blocks}) => blocks.clear(), + ({block, blocks}) => blocks.set(block.position.toString(), {...block, type: 2}), + ({block, blocks}) => blocks.set(block.position.toString(), {...block, stateId: 2}), + () => { throw Error('look failed'); }, + ]; + if (name.startsWith('place')) changes.push(({bot}) => { bot.heldItem = null; }); + if (name.startsWith('destroy')) changes.push(({bot}) => { bot.canDigBlock = () => false; }); + for (const change of changes) { + const context = fixture(); + const {bot} = context; + bot.lookAt = async () => { change(context); }; + const args = name.includes('group') ? [[2, 1, 0, 1]] : name.includes('position') ? [2, 1, 0, 1] + : name.includes('nearest') ? ['stone', 4.5, 1] : [4.5]; + assert.equal(await operations[name]().run(bot, [...args, true]), false, name); + assert.equal(bot.calls.length, 0, name); + assert.equal(bot.listenerCount('end'), 0, name); + } + } +}); + +test('dig ownership covers pending explicit aim and is released on disconnect', async () => { + const {bot} = fixture(); + let finishLook; + bot.lookAt = () => new Promise(resolve => { finishLook = resolve; }); + const fn = operations.destroy_position_block(); + const first = fn.run(bot, [2, 1, 0, 1, true]); + assert.equal(await fn.run(bot, [2, 1, 0, 1]), false); + bot.emit('end'); + assert.equal(await first, false); + finishLook(); + await Promise.resolve(); + assert.equal(bot.calls.length, 0); + assert.equal(bot.stops, undefined); + assert.equal(await fn.run(bot, [2, 1, 0, 1]), true); +}); + +test('groups prevalidate every tuple, execute in order and stop on a failure', async () => { + for (const action of ['destroy', 'place', 'use']) { + const {bot, block, blocks} = fixture(); + const second = {...block, position: new Vec3(3, 1, 0)}; + blocks.set(second.position.toString(), second); + const fn = operations[`${action}_position_group_block`](); + assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 7]]), false); + assert.equal(bot.calls.length, 0); + assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), true); + assert.deepEqual(bot.calls.map(call => call[1].position.x), [2, 3]); + bot.calls.length = 0; + blocks.delete(second.position.toString()); + assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), false); + assert.equal(bot.calls.length, 1); + } +}); + +test('block operations reject invalid parameters, missing targets and API errors', async () => { + for (const name of names) { + const {bot} = fixture(); + const args = name.includes('group') ? [[2, 1, 0, 1]] : name.includes('position') ? [2, 1, 0, 1] + : name.includes('nearest') ? ['stone', 4.5, 1] : [4.5]; + const fn = operations[name](); + for (const invalid of [[], null, [...args, 1], [...args, undefined], [...args, false, true]]) + assert.equal(await fn.run(bot, invalid), false, name); + const fail = async () => { throw Error('failed'); }; + bot.dig = bot.placeBlock = bot._placeBlockWithOptions = bot.activateBlock = fail; + assert.equal(await fn.run(bot, args), false, name); + } + const {bot, block} = fixture(); + for (const args of [[2.5, 1, 0, 1], [2, 1, 0, 6], [20, 1, 0, 1]]) + assert.equal(await operations.use_position_block().run(bot, args), false); + bot.heldItem = null; + assert.equal(await operations.place_position_block().run(bot, [2, 1, 0, 1]), false); + bot.canDigBlock = () => false; + assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false); + block.name = 'air'; block.type = 0; + assert.equal(await operations.use_position_block().run(bot, [2, 1, 0, 1]), false); +}); + +test('owned digging stops on disconnect and releases operation listeners', async () => { + const {bot} = fixture(); + bot.dig = async block => { bot.targetDigBlock = block; bot.emit('end', 'disconnected'); }; + assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false); + assert.equal(bot.stops, 1); + assert.equal(bot.listenerCount('end'), 0); +}); + +test('digging does not overlap and releases ownership after rejection', async () => { + const {bot} = fixture(); + let release; + bot.dig = () => new Promise(resolve => { release = resolve; }); + const first = operations.destroy_position_block().run(bot, [2, 1, 0, 1]); + for (let i = 0; i < 10; i++) await Promise.resolve(); + assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false); + release(); + assert.equal(await first, true); + bot.dig = async () => { throw Error('rejected'); }; + assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false); + bot.dig = async () => {}; + assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), true); + assert.equal(bot.listenerCount('end'), 0); +}); + +test('groups stop after disconnect or bot instance replacement', async () => { + for (const change of [bot => bot.emit('end'), bot => { bot.entity = {...bot.entity}; }]) { + const {bot, block, blocks} = fixture(); + const second = {...block, position: new Vec3(3, 1, 0)}; + blocks.set(second.position.toString(), second); + bot.activateBlock = async (...args) => { bot.calls.push(['use', ...args]); change(bot); }; + assert.equal(await operations.use_position_group_block().run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), false); + assert.equal(bot.calls.length, 1); + assert.equal(bot.listenerCount('end'), 0); + } +}); + +test('loader discovers all twelve block functions and valid metadata', async () => { + const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url); + const category = (await get_function_list()).find(item => item.path === 'block'); + assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort()); + for (const fn of category.function_list) { + const types = fn.name.includes('group') ? ['number-array'] : fn.name.includes('position') ? Array(4).fill('number') + : fn.name.includes('nearest') ? ['string', 'number', 'number'] : ['number']; + assert.deepEqual(fn.args.map(arg => arg.type), [...types, 'boolean']); + assert.equal(fn.args.at(-1).name, 'need_look'); + assert.equal(fn.args.at(-1).default, false); + } +}); diff --git a/tests/bot_files.test.mjs b/tests/bot_files.test.mjs new file mode 100644 index 0000000..de7487c --- /dev/null +++ b/tests/bot_files.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {mkdtemp, mkdir, writeFile, appendFile, rename, symlink, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {tsImport} from 'tsx/esm/api'; + +const {BotFileService} = await tsImport('../bot/forward/files.ts', import.meta.url); +const deadline = () => Date.now() + 20_000; +const code = expected => error => error.code === expected && !error.message.includes(tmpdir()); +async function fixture(t) { + const root = await mkdtemp(path.join(tmpdir(), 'bot-files-')); + t.after(() => rm(root, {recursive: true, force: true})); + const service = new BotFileService(1, {root}); + t.after(() => service.dispose()); + return {root, service}; +} + +test('sorted trees preserve empty directories and binary chunks use a fixed listed size', async t => { + const {root, service} = await fixture(t); + await mkdir(path.join(root, 'empty')); + const binary = Buffer.alloc(300_000); + for (let i = 0; i < binary.length; i++) binary[i] = i % 256; + await writeFile(path.join(root, 'z.bin'), binary); + await writeFile(path.join(root, 'a'), 'a'); + const tree = await service.list(deadline()); + assert.equal(tree.partial, false); + assert.deepEqual(tree.children.map(x => x.name), ['a', 'empty', 'z.bin']); + assert.deepEqual(tree.children[1], {type: 'directory', name: 'empty', children: []}); + assert.equal(JSON.stringify(tree).includes(root), false); + const id = tree.children[2].id; + const first = await service.read({id}, deadline()); + const second = await service.read({id, offset: first.next_offset}, deadline()); + assert.equal(first.next_offset, 262144); + assert.equal(first.eof, false); + assert.equal(second.eof, true); + assert.equal(second.total_bytes, binary.length); + assert.deepEqual(Buffer.concat([Buffer.from(first.data, 'base64'), Buffer.from(second.data, 'base64')]), binary); + const end = await service.read({id, offset: binary.length}, deadline()); + assert.equal(end.data, ''); + assert.equal(end.eof, true); + await writeFile(path.join(root, '..legal'), 'ok'); + const legal = (await service.list(deadline())).children.find(entry => entry.name === '..legal'); + assert.equal(Buffer.from((await service.read({id: legal.id}, deadline())).data, 'base64').toString(), 'ok'); +}); + +test('IDs are scoped, expiring, opaque and request ranges are strict', async t => { + const {root} = await fixture(t); + let now = Date.now(); + const service = new BotFileService(1, {root, now: () => now}); + const other = new BotFileService(1, {root}); + t.after(() => { service.dispose(); other.dispose(); }); + await writeFile(path.join(root, 'file'), '123'); + const tree = await service.list(now + 1000); + const id = tree.children[0].id; + assert.equal(tree.expires_at, now + 300_000); + await assert.rejects(other.read({id}, deadline()), code('INVALID_REQUEST')); + for (const payload of [{id: '../file'}, {id, offset: -1}, {id, offset: 4}, {id, length: 0}, + {id, length: 262145}, {id, path: 'file'}, {id, offset: 0.5}]) { + await assert.rejects(service.read(payload, now + 1000), code('INVALID_REQUEST')); + } + now = tree.expires_at; + await assert.rejects(service.read({id}, now + 1000), code('INVALID_REQUEST')); + const limited = await service.list(now + 1000, now + 500); + assert.equal(limited.expires_at, now + 500); + now += 500; + await assert.rejects(service.read({id: limited.children[0].id}, now + 1000), code('INVALID_REQUEST')); +}); + +test('absent roots and empty files work; symlinks are excluded and unsafe roots rejected', async t => { + const {root, service} = await fixture(t); + const absent = new BotFileService(1, {root: path.join(root, 'absent')}); + assert.deepEqual((await absent.list(deadline())).children, []); + await writeFile(path.join(root, 'empty'), ''); + await symlink('empty', path.join(root, 'link')); + await symlink(root, path.join(root, 'loop')); + const tree = await service.list(deadline()); + assert.deepEqual(tree.children.map(x => x.name), ['empty']); + assert.equal((await service.read({id: tree.children[0].id}, deadline())).data, ''); + const unsafe = new BotFileService(1, {root: path.join(root, 'loop')}); + await assert.rejects(unsafe.list(deadline()), code('RESYNC_REQUIRED')); +}); + +test('ordinary mutation, replacement, truncation and ancestor symlinks require resync', async t => { + const {root, service} = await fixture(t); + await mkdir(path.join(root, 'dir')); + const file = path.join(root, 'dir', 'file'); + await writeFile(file, 'before'); + const listedId = async () => (await service.list(deadline())).children[0].children[0].id; + let id = await listedId(); + await writeFile(file, 'after'); + await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); + id = await listedId(); + await rename(file, `${file}.old`); + await writeFile(file, 'after'); + await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); + id = await listedId(); + await rename(path.join(root, 'dir'), path.join(root, 'saved')); + await symlink('saved', path.join(root, 'dir')); + await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); +}); + +test('only direct log/*.log files may append, with fixed EOF; truncation and replacement reject', async t => { + const {root, service} = await fixture(t); + await mkdir(path.join(root, 'log')); + const file = path.join(root, 'log', 'bot.log'); + await writeFile(file, 'first'); + const id = (await service.list(deadline())).children[0].children[0].id; + await appendFile(file, 'second'); + const data = await service.read({id}, deadline()); + assert.equal(Buffer.from(data.data, 'base64').toString(), 'first'); + assert.equal(data.total_bytes, 5); + assert.equal(data.eof, true); + await writeFile(file, 'x'); + await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); + await rename(file, `${file}.old`); + await writeFile(file, 'replacement'); + await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED')); +}); + +test('deadline, disposal and concurrency limits are checked during asynchronous operations', async t => { + const {root, service} = await fixture(t); + await writeFile(path.join(root, 'file'), 'data'); + await assert.rejects(service.list(Date.now() - 1), code('TIMEOUT')); + const scan = service.list(deadline()); + await assert.rejects(service.list(deadline()), code('RATE_LIMITED')); + const id = (await scan).children[0].id; + const reads = Array.from({length: 4}, () => service.read({id}, deadline())); + await assert.rejects(service.read({id}, deadline()), code('RATE_LIMITED')); + await Promise.all(reads); + const pending = service.read({id}, deadline()); + service.dispose(); + await assert.rejects(pending, code('CANCELLED')); + await assert.rejects(service.list(deadline()), code('CANCELLED')); + await assert.rejects(service.read({id}, deadline()), code('CANCELLED')); + const cancelled = new BotFileService(1, {root}); + const listing = cancelled.list(deadline()); + cancelled.dispose(); + await assert.rejects(listing, code('CANCELLED')); +}); + +test('listings enforce node, depth and byte limits', async t => { + const {root, service} = await fixture(t); + await Promise.all(Array.from({length: 2010}, (_, i) => writeFile(path.join(root, `file-${i}`), ''))); + const tree = await service.list(deadline()); + assert.equal(tree.partial, true); + assert.ok(tree.children.length <= 2000); + assert.ok(Buffer.byteLength(JSON.stringify(tree)) <= 512 * 1024); + const firstId = tree.children[0].id; + for (let i = 0; i < 3; i++) await service.list(deadline()); + const exhausted = await service.list(deadline()); + assert.equal(exhausted.partial, true); + assert.deepEqual(exhausted.children, []); + assert.equal((await service.read({id: firstId}, deadline())).eof, true); + await rm(root, {recursive: true}); + await mkdir(root); + let directory = root; + for (let i = 0; i < 34; i++) { directory = path.join(directory, 'd'); await mkdir(directory); } + const deep = await service.list(deadline()); + let nodes = deep.children; + let depth = 0; + while (nodes.length) { depth++; nodes = nodes[0].children; } + assert.equal(depth, 32); + assert.equal(deep.partial, true); +}); + +test('byte bound truncates listings before the entry bound', async t => { + const {root, service} = await fixture(t); + await Promise.all(Array.from({length: 1800}, (_, i) => + writeFile(path.join(root, `${String(i).padStart(4, '0')}-${'x'.repeat(240)}`), ''))); + const tree = await service.list(deadline()); + assert.equal(tree.partial, true); + assert.ok(tree.children.length > 0 && tree.children.length < 1800); + assert.ok(Buffer.byteLength(JSON.stringify(tree)) <= 512 * 1024); +}); + +test('deadline expiry during asynchronous work rejects and releases the scan slot', async t => { + const {root} = await fixture(t); + let now = Date.now(); + const service = new BotFileService(1, {root, now: () => now}); + t.after(() => service.dispose()); + const end = now + 100; + const scan = service.list(end); + now = end; + await assert.rejects(scan, code('TIMEOUT')); + assert.deepEqual((await service.list(now + 100)).children, []); + await mkdir(path.join(root, 'log')); + await writeFile(path.join(root, 'log', 'ordinary.txt'), 'first'); + const id = (await service.list(now + 100)).children[0].children[0].id; + await appendFile(path.join(root, 'log', 'ordinary.txt'), 'more'); + await assert.rejects(service.read({id}, now + 100), code('RESYNC_REQUIRED')); +}); diff --git a/tests/bot_functions.test.mjs b/tests/bot_functions.test.mjs new file mode 100644 index 0000000..31b520e --- /dev/null +++ b/tests/bot_functions.test.mjs @@ -0,0 +1,291 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {register} from 'tsx/esm/api'; + +const loader = register({namespace: 'bot-functions-test'}); +const tsImport = loader.import; +test.after(() => loader.unregister()); + +const names = ['chat', 'jump', 'squat_once', 'squat_continue_switch', 'use_direction_key', 'look_at', 'stop_current_function']; +const operations = Object.fromEntries(await Promise.all(names.map(async name => { + const {init} = await tsImport(`../bot/function/bot/${name}.ts`, import.meta.url); + return [name, () => new init()]; +}))); +const require = createRequire(import.meta.url); +const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3'); + +function fixture() { + const bot = new EventEmitter(); + bot.entity = {position: new Vec3(1, 2, 3), eyeHeight: 1.62, onGround: true, isValid: true}; + bot._client = Object.assign(new EventEmitter(), {state: 'play'}); + bot.controlState = {}; + bot.calls = []; + bot.getControlState = key => bot.controlState[key] === true; + bot.setControlState = (key, enabled) => { + bot.calls.push([key, enabled]); + bot.controlState[key] = enabled; + }; + bot.chat = message => { bot.message = message; }; + bot.lookAt = async target => { bot.target = target; }; + return bot; +} + +function clean(bot, client = bot._client) { + for (const event of ['end', 'error', 'physicsTick']) assert.equal(bot.listenerCount(event), 0, event); + for (const event of ['end', 'error']) assert.equal(client.listenerCount(event), 0, `client ${event}`); +} + +// Track pending timers as well as using the test runner's deterministic clock. +function clock(t) { + t.mock.timers.enable({apis: ['setTimeout']}); + const schedule = globalThis.setTimeout; + const clear = globalThis.clearTimeout; + const pending = new Set(); + t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => { + const timer = schedule(() => { pending.delete(timer); callback(...args); }, delay); + pending.add(timer); + return timer; + }); + t.mock.method(globalThis, 'clearTimeout', timer => { pending.delete(timer); clear(timer); }); + return {tick: ms => t.mock.timers.tick(ms), clean: () => assert.equal(pending.size, 0, 'pending timers')}; +} + +test('chat preserves exact content and validates its single argument without server acknowledgement', async () => { + const bot = fixture(); + for (const message of [' hello ', '/help', 'x'.repeat(256), '你好']) { + assert.equal(await operations.chat().run(bot, [message]), true); + assert.equal(bot.message, message); + clean(bot); + } + for (const args of [null, [], ['a', 'b'], [1], [''], [' \t '], ['x'.repeat(257)], ['a\rb'], ['a\nb'], ['a\0b']]) { + delete bot.message; + assert.equal(await operations.chat().run(bot, args), false); + assert.equal(bot.message, undefined); + } + bot.chat = () => { throw Error('send failed'); }; + assert.equal(await operations.chat().run(bot, ['hello']), false); + clean(bot); +}); + +test('pulses hold their own key for one physicsTick and listen before enabling', async t => { + const timer = clock(t); + for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) { + const bot = fixture(); + bot.controlState.forward = true; + const pending = operations[name]().run(bot, []); + assert.equal(bot.controlState[key], true); + timer.tick(999); + assert.equal(bot.controlState[key], true); + bot.emit('physicsTick'); + assert.equal(await pending, true); + assert.deepEqual(bot.calls, [[key, true], [key, false]]); + assert.equal(bot.controlState.forward, true); + clean(bot); + timer.clean(); + + const synchronous = fixture(); + const set = synchronous.setControlState; + synchronous.setControlState = (control, enabled) => { + set(control, enabled); + if (enabled) synchronous.emit('physicsTick'); + }; + assert.equal(await operations[name]().run(synchronous, []), true); + assert.equal(synchronous.controlState[key], false); + clean(synchronous); + timer.clean(); + } +}); + +test('pulses time out after one second and reject held keys, airborne jump and extra args', async t => { + const timer = clock(t); + for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) { + const bot = fixture(); + const pending = operations[name]().run(bot, []); + timer.tick(1000); + assert.equal(await pending, false); + assert.equal(bot.controlState[key], false); + clean(bot); + timer.clean(); + bot.controlState[key] = true; + bot.calls = []; + assert.equal(await operations[name]().run(bot, []), false); + for (const args of [null, [false], [1]]) assert.equal(await operations[name]().run(bot, args), false); + assert.deepEqual(bot.calls, []); + clean(bot); + } + const bot = fixture(); + bot.entity.onGround = false; + assert.equal(await operations.jump().run(bot, []), false); + assert.deepEqual(bot.calls, []); + timer.clean(); +}); + +test('persistent sneak is explicit and idempotent and survives a concurrent squat pulse', async t => { + const timer = clock(t); + const bot = fixture(); + const fn = operations.squat_continue_switch(); + for (const enabled of [true, true, false, false]) { + assert.equal(await fn.run(bot, [enabled]), true); + assert.equal(bot.controlState.sneak, enabled); + clean(bot); + } + for (const args of [null, [], [0], ['true'], [undefined], [true, false]]) + assert.equal(await fn.run(bot, args), false); + const pending = operations.squat_once().run(bot, []); + assert.equal(await fn.run(bot, [true]), true); + bot.emit('physicsTick'); + assert.equal(await pending, true); + assert.equal(bot.controlState.sneak, true); + assert.equal(await operations.squat_once().run(bot, []), false); + assert.equal(bot.controlState.sneak, true); + assert.equal(await fn.run(bot, [false]), true); + clean(bot); + timer.clean(); +}); + +test('directions use exact millisecond durations, reject held keys and leave unrelated keys alone', async t => { + const timer = clock(t); + for (const direction of ['forward', 'back', 'left', 'right']) { + for (const duration of [1, 5000]) { + const bot = fixture(); + bot.controlState.sneak = true; + const pending = operations.use_direction_key().run(bot, [direction, duration]); + assert.equal(bot.controlState[direction], true); + bot.emit('physicsTick'); + timer.tick(duration - 1); + assert.equal(bot.controlState[direction], true); + assert.equal(await operations.use_direction_key().run(bot, [direction, 1]), false); + timer.tick(1); + assert.equal(await pending, true); + assert.deepEqual(bot.calls, [[direction, true], [direction, false]]); + assert.equal(bot.controlState.sneak, true); + clean(bot); + timer.clean(); + } + } + const bot = fixture(); + for (const args of [null, [], ['forward'], ['forward', 1, 2], ...['jump', 'sneak', 'Forward', '', 1].map(key => [key, 1]), + ...[0, -1, 5001, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '5', null].map(ms => ['forward', ms])]) + assert.equal(await operations.use_direction_key().run(bot, args), false); + assert.deepEqual(bot.calls, []); + clean(bot); + timer.clean(); +}); + +test('owned controls release on end, error, identity change, disconnect and synchronous failure', async t => { + const timer = clock(t); + for (const [name, key, args] of [['jump', 'jump', []], ['squat_once', 'sneak', []], ['use_direction_key', 'left', ['left', 20]]]) { + for (const reason of ['end', 'error', 'client-end', 'client-error', 'self', 'client', 'disconnect', 'throw', 'tick-throw']) { + const bot = fixture(); + const client = bot._client; + if (reason === 'throw' || reason === 'tick-throw') { + const set = bot.setControlState; + bot.setControlState = (control, enabled) => { + set(control, enabled); + if (enabled) { + if (reason === 'tick-throw') bot.emit('physicsTick'); + throw Error('failed'); + } + }; + } + const pending = operations[name]().run(bot, args); + if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed')); + if (reason.startsWith('client-')) client.emit(reason.slice(7), Error('closed')); + if (reason === 'self') bot.entity = {...bot.entity}; + if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'}); + if (reason === 'disconnect') client.state = 'disconnected'; + if (['self', 'client', 'disconnect'].includes(reason)) { + bot.emit('physicsTick'); + timer.tick(20); + } + assert.equal(await pending, false, `${name}: ${reason}`); + assert.equal(bot.controlState[key], false); + clean(bot, client); + timer.clean(); + } + } + const bot = fixture(); + bot.setControlState = () => { throw Error('API failure'); }; + assert.equal(await operations.squat_continue_switch().run(bot, [true]), false); + clean(bot); + timer.clean(); +}); + +test('look_at constructs a vector from position, accepts decimals and rejects invalid or eye-coincident targets', async t => { + const timer = clock(t); + const bot = fixture(); + assert.equal(await operations.look_at().run(bot, [-1.5, 4.25, 8.75]), true); + assert.ok(bot.target instanceof Vec3); + assert.deepEqual(bot.target, new Vec3(-1.5, 4.25, 8.75)); + assert.deepEqual(bot.entity.position, new Vec3(1, 2, 3)); + for (const args of [null, [], [1, 2], [1, 2, 3, 4], ['1', 2, 3], [Infinity, 2, 3], [1, NaN, 3], + [1, 2, undefined], [1, 2 + bot.entity.eyeHeight, 3]]) { + delete bot.target; + assert.equal(await operations.look_at().run(bot, args), false); + assert.equal(bot.target, undefined); + clean(bot); + timer.clean(); + } +}); + +test('look_at bounds waits and contains late rejections, synchronous errors and session changes', async t => { + const timer = clock(t); + for (const reason of ['timeout', 'end', 'client-end', 'error', 'self', 'client', 'disconnect', 'invalid', 'throw', 'reject']) { + const bot = fixture(); + const client = bot._client; + let resolveLook, rejectLook; + bot.lookAt = () => new Promise((resolve, reject) => { resolveLook = resolve; rejectLook = reject; }); + if (reason === 'throw') bot.lookAt = () => { throw Error('look failed'); }; + if (reason === 'reject') bot.lookAt = () => Promise.reject(Error('look failed')); + const pending = operations.look_at().run(bot, [1.5, 2.5, 3.5]); + if (reason === 'timeout') timer.tick(5000); + if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed')); + if (reason === 'client-end') client.emit('end'); + if (reason === 'self') bot.entity = {...bot.entity}; + if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'}); + if (reason === 'disconnect') client.state = 'disconnected'; + if (reason === 'invalid') bot.entity.isValid = false; + if (['self', 'client', 'disconnect', 'invalid'].includes(reason)) resolveLook(); + assert.equal(await pending, false, reason); + clean(bot, client); + timer.clean(); + rejectLook?.(Error('late failure')); + await Promise.resolve(); + } +}); + +test('all operations reject stale sessions before side effects', async () => { + for (const [name, args] of [['chat', ['hello']], ['jump', []], ['squat_once', []], + ['squat_continue_switch', [true]], ['use_direction_key', ['forward', 1]], ['look_at', [1, 2, 3]]]) { + for (const change of [bot => { bot.entity = null; }, bot => { bot.entity.isValid = false; }, + bot => { bot._client.state = 'disconnected'; }]) { + const bot = fixture(); + change(bot); + assert.equal(await operations[name]().run(bot, args), false, name); + assert.deepEqual(bot.calls, []); + assert.equal(bot.message, undefined); + assert.equal(bot.target, undefined); + clean(bot); + } + } +}); + +test('dynamic discovery publishes all bot operations and their exact positional metadata', async () => { + const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url); + const category = (await get_function_list()).find(item => item.path === 'bot'); + assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort()); + const expected = {stop_current_function: [], chat: [['message', 'string']], jump: [], squat_once: [], squat_continue_switch: [['enabled', 'boolean']], + use_direction_key: [['direction', 'string'], ['duration_ms', 'number']], look_at: [['x', 'number'], ['y', 'number'], ['z', 'number']]}; + for (const fn of category.function_list) { + assert.equal(fn.i18n.lang, 'zh-CN'); + assert.ok(fn.i18n.name && fn.i18n.description); + assert.deepEqual(fn.args.map(arg => [arg.name, arg.type]), expected[fn.name]); + for (const arg of fn.args) { + assert.equal(arg.i18n.lang, 'zh-CN'); + assert.ok(arg.i18n.name && arg.i18n.description); + assert.equal(Object.hasOwn(arg, 'default'), false); + } + } +}); diff --git a/tests/bot_lifecycle.test.mjs b/tests/bot_lifecycle.test.mjs new file mode 100644 index 0000000..073ee43 --- /dev/null +++ b/tests/bot_lifecycle.test.mjs @@ -0,0 +1,680 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; +import test from 'node:test'; +import {build} from 'esbuild'; +import {readFile} from 'node:fs/promises'; + +const require = createRequire(import.meta.url); +const root = fileURLToPath(new URL('../', import.meta.url)); +const compiled = await build({ + stdin: { + contents: `export {BotContext} from './bot/instance/bot_instance_init.ts'; + export {init} from './bot/plugins/runtime/auto_reconnect.ts';`, + resolveDir: root, + loader: 'ts', + }, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external', + write: false, + define: {'import.meta.url': JSON.stringify(new URL('../bot/plugins/init.ts', import.meta.url).href)}, + plugins: [{ + name: 'mock-bot-and-io', + setup(build) { + build.onLoad({filter: /bot\/plugins\/init\.ts$/}, async args => ({ + contents: (await readFile(args.path, 'utf8')).replace('return import(url.href);', 'return globalThis.mocks.load_plugin(url);'), loader: 'ts', + })); + build.onResolve({filter: /@wp2508\/mineflayer$|utils\/(logger|path_utils|validate)\.ts$|(?:info|function)\/init\.ts$/}, args => ({ + path: args.path, + namespace: 'mock', + })); + build.onLoad({filter: /.*/, namespace: 'mock'}, args => ({ + contents: args.path.endsWith('mineflayer') ? 'export const createBot = globalThis.mocks.createBot;' + : args.path.endsWith('info/init.ts') ? 'export const create_info_manager = (bot, options) => globalThis.mocks.create_info_manager(bot, options);' + : args.path.endsWith('function/init.ts') ? 'export const create_function_manager = context => globalThis.mocks.create_function_manager(context);' + : args.path.endsWith('logger.ts') ? 'export const Logger = globalThis.mocks.Logger;' + : args.path.endsWith('path_utils.ts') ? 'export const get_project_root_path = () => "/tmp";' + : `export const validate_username = () => globalThis.mocks.validation; + export const validate_host = () => 0; + export const validate_port = value => { + globalThis.mocks.validated_ports.push(value); + if (typeof value !== 'number') return 420; + if (Number.isNaN(value)) return 422; + if (!Number.isInteger(value)) return 421; + return value < 0 || value > 65535 ? 423 : 0; + }; + export const validate_version = () => 0; + export const validate_proxy = () => 0;`, + loader: 'js', + })); + }, + }], +}); + +const flush = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); }; + +function setup() { + const bots = []; + const managers = []; + const functions = []; + const timers = new Map(); + let timer_id = 0; + const mocks = { + load_plugin: async () => ({init: module.exports.init}), + validation: 0, + validated_ports: [], + created_options: [], + fail_create: false, + connect_mode: 'pending', + silent_end: false, + info_gate: undefined, + fail_info: false, + fail_info_subscribe: false, + async create_function_manager(context) { + const manager = {disposed: false, async run() { return true; }, dispose() { + this.disposed = true; + this.disposed_before_end = !bots.at(-1).ends; + }}; + functions.push(manager); + return manager; + }, + async create_info_manager(bot, options) { + if (mocks.info_gate) await mocks.info_gate; + if (mocks.fail_info) throw new Error('info loading failed'); + const callbacks = new Set(); + const manager = { + options, + disposed: false, + unsubscribe_count: 0, + get_info: name => name === 'health' ? {name, value: 20} : undefined, + get_all_info: () => [{name: 'health', value: 20}], + on_update(callback) { + if (mocks.fail_info_subscribe) throw new Error('info subscription failed'); + callbacks.add(callback); + manager.stale_callback = callback; + return () => { callbacks.delete(callback); manager.unsubscribe_count++; }; + }, + update(value) { for (const callback of callbacks) callback({name: 'health', value}); }, + dispose() { + if (manager.disposed) return; + manager.disposed = true; + manager.disposed_before_end = bot.ends === 0; + callbacks.clear(); + }, + }; + managers.push(manager); + return manager; + }, + Logger: class { + custom(text) { return text; } + info(text) { return text; } + error(text) { return text; } + warning(text) { return text; } + log() {} + async close() { this.closed = true; } + }, + createBot(options) { + mocks.created_options.push(options); + assert.equal(options.autoConnect, false); + if (mocks.fail_create) throw new Error('creation failed'); + const bot = new EventEmitter(); + bot._client = new EventEmitter(); + const any = []; + const emit = bot.emit; + bot.connects = 0; + bot.ends = 0; + bot.onAny = callback => any.push(callback); + bot.emit = function(event, ...args) { + const result = emit.call(this, event, ...args); + for (const callback of any) callback(event, ...args); + return result; + }; + bot.connect = () => { + bot.connects++; + if (mocks.connect_mode === 'throw') throw new Error('connect failed'); + if (mocks.connect_mode === 'error') bot.emit('error', new Error('connection error')); + if (mocks.connect_mode === 'login') bot.emit('initialLogin'); + }; + bot.end = () => { + bot.ends++; + if (!mocks.silent_end) bot.emit('end', 'closed'); + }; + bots.push(bot); + return bot; + }, + }; + const module = {exports: {}}; + vm.runInNewContext(compiled.outputFiles[0].text, { + module, exports: module.exports, require, mocks, process, console, queueMicrotask, URL, structuredClone, + setTimeout: (callback, delay) => { + const id = ++timer_id; + timers.set(id, {callback, delay}); + return id; + }, + clearTimeout: id => timers.delete(id), + clearInterval: id => timers.delete(id), + }); + const context = new module.exports.BotContext({ + username: 'TestBot', host: 'localhost', port: 25565, version: '1.20.4', bot_id: 1, user_id: 1, + }); + const statuses = []; + context.on('status_update', status => { + assert.equal(context.bot_status, status); + statuses.push(status); + }); + return { + context, mocks, bots, managers, functions, timers, statuses, + plugin(config = {}) { + return context.plugin_manager.inject({path: 'runtime', name: 'auto_reconnect', config: {reconnect_delay: 10, reconnect_max_attempts: 2, ...config}}); + }, + async tick(delay) { + const entry = [...timers.entries()].find(([, timer]) => timer.delay === delay); + assert.ok(entry, `missing timer for ${delay}ms`); + timers.delete(entry[0]); + entry[1].callback(); + await flush(); + }, + }; +} + +test('function manager follows instance creation, disconnect and replacement', async () => { + const {context, functions} = setup(); + assert.equal(context.function_manager, null); + context.on('instance_created', () => assert.equal(context.function_manager, functions.at(-1))); + await context.connect(); + const first = context.function_manager; + await context.disconnect(); + assert.equal(context.function_manager, null); + assert.equal(first.disposed, true); + assert.equal(first.disposed_before_end, true); + await context.connect(); + assert.notEqual(context.function_manager, first); + await context.clear_this_context(); + assert.equal(functions.at(-1).disposed, true); +}); + +test('info manager is available before instance_created and forwards updates across reconnects', async () => { + const {context, bots, managers} = setup(); + const updates = []; + let created = 0; + assert.equal(context.info_manager, null); + context.on('instance_created', () => { + created++; + assert.equal(context.info_manager, managers.at(-1)); + assert.equal(managers.at(-1).options.bot_id, context.bot_config.bot_id); + assert.equal(context.info_manager.get_info('health').value, 20); + assert.equal(bots.at(-1).connects, 0); + }); + context.on('info_update', event => updates.push({...event})); + assert.equal(await context.connect(), 0); + managers[0].update(18); + // Calling connect again changes the operation sequence but must preserve updates. + context.cancel_reconnect(); + managers[0].update(17); + assert.deepEqual(updates.map(event => event.value), [18, 17]); + await context.disconnect(); + assert.equal(context.info_manager, null); + assert.equal(managers[0].disposed, true); + assert.equal(managers[0].disposed_before_end, true); + assert.equal(managers[0].unsubscribe_count, 1); + await context.connect(); + assert.equal(created, 2); + assert.notEqual(managers[0], context.info_manager); + managers[0].stale_callback({name: 'health', value: 1}); + managers[1].update(19); + assert.deepEqual(updates.map(event => event.value), [18, 17, 19]); + await context.clear_this_context(); + assert.equal(context.info_manager, null); + assert.equal(managers[1].disposed, true); +}); + +test('info loading blocks connection and initialization failures roll back without success events', async () => { + for (const failure of ['fail_info', 'fail_info_subscribe']) { + const {context, mocks, bots, managers} = setup(); + let release; + mocks.info_gate = new Promise(resolve => { release = resolve; }); + let created = 0; + const logs = []; + context.on('instance_created', () => created++); + context.on('log', segments => logs.push(...segments)); + const pending = context.connect(); + await flush(); + assert.equal(bots[0].connects, 0); + assert.equal(context.info_manager, null); + assert.equal(created, 0); + mocks[failure] = true; + release(); + assert.equal(await pending, 466); + assert.equal(context.bot_instance, null); + assert.equal(context.info_manager, null); + assert.equal(context.bot_status, 'offline'); + assert.equal(bots[0].ends, 1); + assert.equal(created, 0); + assert.equal(logs.includes('[NODE] 创建机器人实例成功'), false); + assert.ok(managers.every(manager => manager.disposed)); + await context.clear_this_context(); + } +}); + +test('stop and disposal during info loading discard the late manager without connecting', async () => { + for (const stop of ['disconnect', 'clear_this_context']) { + const {context, mocks, bots, managers} = setup(); + let release; + mocks.info_gate = new Promise(resolve => { release = resolve; }); + const pending = context.connect(); + await flush(); + const stopped = context[stop](); + release(); + assert.equal(await pending, stop === 'disconnect' ? 468 : 467); + await stopped; + assert.equal(bots[0].connects, 0); + assert.equal(bots[0].ends, 1); + assert.equal(managers[0].disposed, true); + assert.equal(context.bot_instance, null); + assert.equal(context.info_manager, null); + assert.equal(context.bot_status, 'offline'); + await context.clear_this_context(); + } +}); + +test('terminal events during info loading cannot install a stale manager or duplicate retries', async () => { + const {context, mocks, bots, managers, timers, plugin, tick} = setup(); + await plugin(); + await context.connect(); + bots[0].emit('end'); + await flush(); + let release; + mocks.info_gate = new Promise(resolve => { release = resolve; }); + let lost = 0; + context.on('connection_lost', () => lost++); + await tick(10); + assert.equal(bots.length, 2); + bots[1].emit('error', new Error('failed while loading info')); + await flush(); + release(); + await flush(); + assert.equal(managers[1].disposed, true); + assert.equal(bots[1].connects, 0); + assert.equal(context.info_manager, null); + assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 1); + assert.equal(lost, 1); + assert.equal(timers.size, 1); + mocks.info_gate = undefined; + await tick(10); + assert.equal(context.info_manager, managers[2]); + assert.equal(bots[2].connects, 1); + await context.clear_this_context(); +}); + +test('info manager is disposed before timeout cleanup even when end is never emitted', async () => { + const {context, mocks, managers, tick} = setup(); + await context.connect(); + mocks.silent_end = true; + const stopped = context.disconnect(); + await flush(); + assert.equal(context.bot_status, 'disconnecting'); + assert.equal(context.info_manager, null); + assert.equal(managers[0].disposed, true); + assert.equal(managers[0].disposed_before_end, true); + await tick(1000); + await stopped; + assert.equal(context.bot_status, 'offline'); + await context.clear_this_context(); +}); + +test('instance_created listener can cancel initialization and dispose the installed manager', async () => { + const {context, bots, managers} = setup(); + let stopped; + context.on('instance_created', () => { stopped = context.disconnect(); }); + assert.equal(await context.connect(), 468); + await stopped; + assert.equal(bots[0].connects, 0); + assert.equal(managers[0].disposed, true); + assert.equal(context.info_manager, null); + await context.clear_this_context(); +}); + +test('creation logs once on direct creation and connect, but never on failure', async () => { + for (const method of ['create_instance', 'connect']) { + const {context} = setup(); + const logs = []; + context.on('log', segments => logs.push(...segments)); + assert.equal(await context[method](), 0); + assert.deepEqual(logs, ['[NODE] 创建机器人实例成功']); + await context.create_instance(); + assert.equal(logs.length, 1); + await context.clear_this_context(); + + const failed = setup(); + const failed_logs = []; + failed.context.on('log', segments => failed_logs.push(...segments)); + failed.mocks.fail_create = true; + assert.notEqual(await failed.context[method](), 0); + assert.deepEqual(failed_logs, []); + await failed.context.clear_this_context(); + } +}); + +test('first connect creates once; create leaves ready; concurrent connects never duplicate', async () => { + const {context, bots, statuses} = setup(); + assert.equal(await context.create_instance(), 0); + assert.equal(context.bot_status, 'ready'); + assert.equal(bots[0].connects, 0); + const results = await Promise.all([context.connect(), context.connect()]); + assert.deepEqual(results, [0, 463]); + assert.equal(bots.length, 1); + assert.equal(bots[0].connects, 1); + bots[0].emit('initialLogin'); + assert.equal(context.bot_status, 'online'); + assert.equal(await context.connect(), 462); + assert.deepEqual(statuses.slice(0, 3), ['ready', 'connecting', 'online']); + await context.clear_this_context(); + assert.equal(context.bot_status, 'offline'); + assert.equal(context.logger.closed, true); +}); + +test('null and omitted ports skip validation and are absent from creation options', async () => { + for (const omitted of [false, true]) { + const {context, mocks} = setup(); + if (omitted) delete context.bot_config.port; + else context.bot_config.port = null; + assert.equal(context.validate_config(context.bot_config), 0); + assert.equal(await context.connect(), 0); + assert.equal(context.bot_status, 'connecting'); + assert.deepEqual(mocks.validated_ports, []); + assert.equal(Object.hasOwn(mocks.created_options[0], 'port'), false); + await context.clear_this_context(); + } +}); + +test('explicit ports are validated and passed through without substituting defaults', async () => { + for (const port of [0, 25565, 25566]) { + const {context, mocks} = setup(); + context.bot_config.port = port; + assert.equal(await context.connect(), 0); + assert.equal(mocks.created_options[0].port, port); + assert.deepEqual(mocks.validated_ports, [port]); + await context.clear_this_context(); + } + for (const port of [-1, 65536, '25565', '', 1.5]) { + const {context, mocks} = setup(); + context.bot_config.port = port; + assert.notEqual(await context.connect(), 0); + assert.equal(mocks.created_options.length, 0); + await context.clear_this_context(); + } +}); + +test('fresh connect auto-creates; validation and factory failures do not report success', async () => { + const {context, mocks, bots} = setup(); + mocks.validation = 404; + assert.equal(await context.connect(), 404); + assert.equal(bots.length, 0); + mocks.validation = 0; + mocks.fail_create = true; + assert.equal(await context.connect(), 466); + assert.equal(context.bot_status, 'offline'); + mocks.fail_create = false; + assert.equal(await context.connect(), 0); + assert.equal(bots.length, 1); + await context.clear_this_context(); +}); + +test('connection exceptions and synchronous error events clean up to offline', async () => { + for (const mode of ['throw', 'error']) { + const {context, mocks, bots} = setup(); + mocks.connect_mode = mode; + assert.equal(await context.connect(), 466); + assert.equal(context.bot_instance, null); + assert.equal(context.bot_status, 'offline'); + assert.equal(bots[0].ends, 1); + await context.clear_this_context(); + } +}); + +test('duplicate terminal events clean once; old callbacks cannot clear or log in new instance', async () => { + const {context, bots} = setup(); + await context.connect(); + const old = bots[0]; + const old_end = old.listeners('end')[0]; + const old_login = old.listeners('initialLogin')[0]; + let lost = 0; + context.on('connection_lost', () => lost++); + old.emit('error', new Error('failed')); + old.emit('kicked', 'kicked'); + old.emit('end', 'closed again'); + await flush(); + assert.equal(lost, 1); + assert.equal(old.ends, 1); + await context.connect(); + const current = context.bot_instance; + old_end(); old_login(); + assert.equal(context.bot_instance, current); + assert.equal(context.bot_status, 'connecting'); + await context.clear_this_context(); +}); + +test('disconnect cancels pending connect, including connecting instances; no reconnect after manual stop', async () => { + const {context, bots, timers, plugin} = setup(); + await plugin(); + const pending = context.connect(); + const stopped = context.disconnect(); + assert.equal(await pending, 468); + assert.equal(await stopped, 0); + assert.equal(bots.length, 0); + await context.connect(); + await context.disconnect(); + assert.equal(context.bot_status, 'offline'); + assert.equal(timers.size, 0); + await context.clear_this_context(); +}); + +test('cleanup preserves end callbacks and waits for timeout fallback before replacement', async () => { + const {context, mocks, bots, tick} = setup(); + await context.connect(); + let internal_cleanup = 0; + bots[0].on('end', () => internal_cleanup++); + await context.disconnect(); + assert.equal(internal_cleanup, 1); + await context.connect(); + mocks.silent_end = true; + const stopped = context.disconnect(); + await flush(); + assert.equal(context.bot_status, 'disconnecting'); + const next = context.connect(); + await flush(); + assert.equal(bots.length, 2); + await tick(1000); + await stopped; + assert.equal(await next, 0); + assert.equal(bots.length, 3); + mocks.silent_end = false; + await context.clear_this_context(); +}); + +test('reconnect uses new instances, caps failures, and resets attempts only after login', async () => { + const {context, mocks, bots, timers, plugin, tick} = setup(); + await plugin(); + await context.connect(); + bots[0].emit('end'); await flush(); + assert.equal(context.bot_status, 'reconnecting'); + assert.equal(timers.size, 1); + mocks.fail_create = true; + await tick(10); + assert.equal(context.bot_status, 'reconnecting'); + await tick(10); + assert.equal(context.bot_status, 'offline'); + assert.equal(timers.size, 0); + assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 2); + mocks.fail_create = false; + await context.connect(); + bots.at(-1).emit('end'); await flush(); + mocks.connect_mode = 'login'; + await tick(10); + assert.equal(context.bot_status, 'online'); + assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 0); + await context.clear_this_context(); +}); + +test('uninject cancels queued retry; disposal is terminal and idempotent', async () => { + const {context, bots, timers, plugin} = setup(); + await plugin(); + await context.connect(); + bots[0].emit('end'); await flush(); + await context.plugin_manager.uninject('auto_reconnect'); + assert.equal(context.bot_status, 'offline'); + assert.equal(timers.size, 0); + await plugin(); + await context.connect(); + bots.at(-1).emit('end'); await flush(); + await Promise.all([context.clear_this_context(), context.clear_this_context()]); + assert.equal(timers.size, 0); + assert.equal(context.bot_status, 'offline'); + assert.equal(await context.connect(), 467); + assert.equal(await context.create_instance(), 467); + assert.equal(await context.disconnect(), 467); + await context.schedule_reconnect(10); + assert.equal(timers.size, 0); +}); + +test('plugin manager persists across disconnect and reconnect and disposes before context listeners', async () => { + const {context, plugin} = setup(); + const manager = context.plugin_manager; + await plugin(); + await context.connect(); + await context.disconnect(); + assert.equal(context.plugin_manager, manager); + assert.ok(manager.get_plugin('auto_reconnect')); + await context.connect(); + assert.equal(context.plugin_manager, manager); + assert.ok(manager.get_plugin('auto_reconnect')); + await context.clear_this_context(); + assert.equal(manager.get_plugin('auto_reconnect').lifecycle, 'disabled'); + await assert.rejects(manager.inject({path: 'runtime', name: 'auto_reconnect', config: {}}), /disposed/); +}); + +test('context disposal during plugin init does not hold the instance lock needed by the plugin', async () => { + const {context, mocks} = setup(); + let resume; + const gate = new Promise(resolve => { resume = resolve; }); + let cleaned = false; + mocks.load_plugin = async () => ({init: class { + name = 'test'; + config_template = {name: 'test', version: '1', i18n: [{lang: 'en', name: 'Test', description: ''}], config: []}; + async init() { await gate; await context.schedule_reconnect(10); } + get_status() { return {}; } + on_states_update() { return () => {}; } + async dispose() { cleaned = true; } + }}); + const loading = assert.rejects(context.plugin_manager.inject({name: 'test', path: 'runtime', config: {}}), /disposed/); + await flush(); + const disposal = context.clear_this_context(); + resume(); + await loading; await disposal; + assert.equal(cleaned, true); +}); + +test('manual connect resets retry attempts, cancels waiting timer, and preserves active transport', async () => { + const {context, mocks, bots, timers, plugin, tick} = setup(); + await plugin(); + await context.connect(); + bots[0].emit('end'); await flush(); + mocks.fail_create = true; + await tick(10); + const retry_status = () => context.plugin_manager.get_plugin('auto_reconnect').states; + assert.equal(retry_status().attempts, 1); + assert.equal(context.bot_status, 'reconnecting'); + const stale_retry = [...timers.values()][0].callback; + mocks.fail_create = false; + assert.equal(await context.connect(), 0); + assert.equal(retry_status().attempts, 0); + assert.equal(timers.size, 0); + assert.equal(context.bot_status, 'connecting'); + const current = bots.at(-1); + stale_retry(); await flush(); + assert.equal(bots.length, 2); + assert.equal(current.connects, 1); + + context.emit('reconnect_attempt'); + assert.equal(await context.connect(), 463); + assert.equal(retry_status().attempts, 0); + assert.equal(current.connects, 1); + current.emit('initialLogin'); + context.emit('reconnect_attempt'); + assert.equal(await context.connect(), 462); + assert.equal(retry_status().attempts, 0); + assert.equal(current.connects, 1); + await context.clear_this_context(); +}); + +test('status listener can cancel a connection before the transport starts', async () => { + const {context, bots} = setup(); + let stopped; + context.on('status_update', status => { + if (status === 'connecting') stopped = context.disconnect(); + }); + assert.equal(await context.connect(), 468); + await stopped; + assert.equal(bots[0].connects, 0); + assert.equal(context.bot_instance, null); + assert.equal(context.bot_status, 'offline'); + await context.clear_this_context(); +}); + +test('synchronous retry failures produce one retry per attempt', async () => { + const {context, mocks, bots, plugin, tick, timers} = setup(); + await plugin(); + await context.connect(); + bots[0].emit('end'); await flush(); + mocks.connect_mode = 'error'; + await tick(10); + assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 1); + assert.equal(timers.size, 1); + await tick(10); + assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 2); + assert.equal(context.bot_status, 'offline'); + assert.equal(timers.size, 0); + await context.clear_this_context(); +}); + +test('disposal invalidates pending retry callbacks and status listener reentry', async () => { + const {context, bots, timers, plugin} = setup(); + await plugin(); + await context.connect(); + bots[0].emit('end'); await flush(); + const queued = [...timers.values()][0].callback; + const disposed = context.clear_this_context(); + queued(); + await disposed; + assert.equal(bots.length, 1); + assert.equal(context.bot_status, 'offline'); + assert.equal(timers.size, 0); + + const second = setup(); + let cleanup; + second.context.on('status_update', status => { + if (status === 'ready') cleanup = second.context.clear_this_context(); + }); + assert.equal(await second.context.create_instance(), 467); + await cleanup; + assert.equal(second.bots[0].connects, 0); + assert.equal(second.context.bot_status, 'offline'); +}); + +test('context disposal reports offline once even when no instance was created', async () => { + for (const connect of [false, true]) { + const {context, statuses} = setup(); + if (connect) await context.connect(); + await context.clear_this_context(); + await context.clear_this_context(); + assert.equal(context.bot_status, 'offline'); + assert.equal(statuses.at(-1), 'offline'); + assert.equal(statuses.filter(status => status === 'offline').length, 1); + assert.equal(statuses.includes('disposed'), false); + assert.equal(await context.connect(), 467); + } +}); diff --git a/tests/bot_startup.test.mjs b/tests/bot_startup.test.mjs new file mode 100644 index 0000000..27be006 --- /dev/null +++ b/tests/bot_startup.test.mjs @@ -0,0 +1,356 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const require = createRequire(import.meta.url); +const root = fileURLToPath(new URL('../', import.meta.url)); +const sources = { + './bot_instance_init': 'export const BotContext = globalThis.mocks.BotContext;', + '../workflow/init': 'export const init_workflow = globalThis.mocks.init_workflow;', + '../../service/server/update/bot_status.ts': 'export const update_one_bot_status = globalThis.mocks.report;', +}; +const compiled = await build({ + stdin: { + contents: `export * from './bot/instance/index.ts'; + export {handler} from './event_handler/bot/start_bot.ts'; + export {handler as stop_handler} from './event_handler/bot/stop_bot.ts';`, + resolveDir: root, + loader: 'ts', + }, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external', + write: false, + plugins: [{ + name: 'mock-startup-dependencies', + setup(build) { + build.onResolve({filter: /.*/}, args => Object.hasOwn(sources, args.path) + ? {path: args.path, namespace: 'mock'} : undefined); + build.onLoad({filter: /.*/, namespace: 'mock'}, args => ({ + contents: sources[args.path], + loader: 'js', + })); + }, + }], +}); + +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return {promise, resolve}; +} + +function config(bot_id = 1) { + return { + instance_config: { + username: 'TestBot', host: 'localhost', port: 25565, version: '1.20.4', bot_id, user_id: 1, + }, + plugins: [], + workflow: [], + }; +} + +function setup() { + const contexts = []; + const errors = []; + const reports = []; + const socket = {emitted: [], emit(...args) { this.emitted.push(args); }}; + const mocks = { + validation: 0, + connect_result: 0, + connect_error: false, + plugin_error: false, + plugin_calls: 0, + workflow_calls: 0, + uninject_calls: 0, + report_error: false, + report_gate: null, + async initialize_plugins(context) { + mocks.plugin_calls++; + assert.equal(context.listenerCount('status_update'), 1); + context.plugin_installed = true; + if (mocks.plugin_error) throw new Error('plugin initialization failed'); + }, + async init_workflow() { mocks.workflow_calls++; }, + async report(socket, node_id, bot_id, user_id, status) { + reports.push({node_id, bot_id, user_id, status, snapshot: api.get_bot_context_status()}); + if (mocks.report_error) throw new Error('status reporting failed'); + if (mocks.report_gate) await mocks.report_gate; + }, + BotContext: class extends EventEmitter { + constructor(config) { + super(); + this.bot_config = config; + this.bot_status = 'offline'; + this.plugin_manager = {init: () => mocks.initialize_plugins(this), dispose: async () => { + if (this.plugin_installed) { mocks.uninject_calls++; this.plugin_installed = false; } + }}; + this.connect_calls = 0; + this.cleanup_calls = 0; + this.cleanup_gate = null; + this.cleanup_entered = deferred(); + contexts.push(this); + } + validate_config() { return mocks.validation; } + async connect() { + this.connect_calls++; + if (this.bot_status === 'online') return 462; + if (this.bot_status === 'connecting') return 463; + if (mocks.connect_error) throw new Error('connection failed'); + if ([0, 462, 463].includes(mocks.connect_result)) { + this.bot_status = mocks.connect_result === 462 ? 'online' : 'connecting'; + this.emit('status_update', this.bot_status); + } + return mocks.connect_result; + } + clear_this_context() { + this.cleanup_calls++; + if (this.cleanup_promise) return this.cleanup_promise; + this.bot_status = 'disconnecting'; + this.cleanup_entered.resolve(); + this.cleanup_promise = (async () => { + if (this.cleanup_gate) await this.cleanup_gate; + await this.plugin_manager.dispose(); + this.bot_status = 'offline'; + this.emit('status_update', 'offline'); + this.removeAllListeners(); + })(); + return this.cleanup_promise; + } + }, + }; + const module = {exports: {}}; + vm.runInNewContext(compiled.outputFiles[0].text, { + module, exports: module.exports, require, mocks, structuredClone, + console: {error: (...args) => errors.push(args)}, + }); + const api = module.exports; + return { + ...api, contexts, errors, reports, mocks, socket, + start(payload = config(), node_id = 1) { return api.start_bot_context(socket, payload, node_id); }, + async ack(...args) { + const payload = args.length ? args[0] : {...config(), node_id: 1}; + const replies = []; + await api.handler(socket, payload, reply => replies.push(reply)); + assert.equal(replies.length, 1); + return {...replies[0]}; + }, + }; +} + +test('startup rejects malformed payloads, arrays, and node/user/bot IDs before creating a context', async () => { + const app = setup(); + const malformed = [ + null, undefined, [], 'invalid', {}, + {...config(), instance_config: null}, + {...config(), instance_config: []}, + {...config(), plugins: null}, + {...config(), plugins: {}}, + {...config(), plugins: [null]}, + {...config(), plugins: [[]]}, + {...config(), workflow: undefined}, + {...config(), workflow: {}}, + {...config(), workflow: ['invalid']}, + {...config(), workflow: [null]}, + ]; + for (const payload of malformed) { + assert.equal(await app.start_bot_context(app.socket, payload, 1), 484); + } + for (const id of [undefined, null, '1', 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.equal(await app.start_bot_context(app.socket, config(), id), 484); + for (const field of ['bot_id', 'user_id']) { + const payload = config(); + payload.instance_config[field] = id; + assert.equal(await app.start(payload), 484); + } + } + assert.equal(app.contexts.length, 0); + assert.equal(app.mocks.plugin_calls, 0); +}); + +test('stop handler validates bot_id and ACKs stopped, busy and failed cleanup', async () => { + const app = setup(); + const ack = async payload => { + const replies = []; + await app.stop_handler(app.socket, payload, reply => replies.push({...reply})); + assert.equal(replies.length, 1); + return replies[0]; + }; + for (const payload of [null, undefined, [], 1, {}, {bot_id: '1'}, {bot_id: 0}, + {bot_id: -1}, {bot_id: 1.5}, {bot_id: Number.MAX_SAFE_INTEGER + 1}]) { + assert.deepEqual(await ack(payload), {status: 'error', data: 484}); + } + assert.deepEqual(await ack({bot_id: 1}), {status: 'success', data: 461}); + assert.equal(await app.start(), 464); + const context = app.contexts[0]; + const gate = deferred(); + context.cleanup_gate = gate.promise; + const stopping = ack({bot_id: 1}); + await context.cleanup_entered.promise; + assert.deepEqual(await ack({bot_id: 1}), {status: 'error', data: 465}); + gate.resolve(); + assert.deepEqual(await stopping, {status: 'success', data: 461}); + assert.equal((await app.get_bot_context_status()).length, 0); + assert.deepEqual(await ack({bot_id: 1}), {status: 'success', data: 461}); + + await app.start(); + app.contexts.at(-1).clear_this_context = async () => { throw new Error('cleanup failed'); }; + assert.deepEqual(await ack({bot_id: 1}), {status: 'error', data: 466}); +}); + +test('stop handler requires ACK callback before attempting cleanup', async () => { + const app = setup(); + await app.start(); + await app.stop_handler(app.socket, {bot_id: 1}, undefined); + assert.equal(app.contexts[0].cleanup_calls, 0); + assert.equal(app.socket.emitted[0][0], 'error'); + assert.equal(app.socket.emitted[0][1].error, 481); +}); + +test('configuration validation runs before plugins and disposes rejected contexts', async () => { + const app = setup(); + app.mocks.validation = 403; + assert.equal(await app.start(), 403); + assert.equal(app.mocks.plugin_calls, 0); + assert.equal(app.mocks.workflow_calls, 0); + assert.equal(app.contexts[0].connect_calls, 0); + assert.equal(app.contexts[0].cleanup_calls, 1); + assert.equal(app.contexts[0].bot_status, 'offline'); + assert.equal((await app.get_bot_context_status()).length, 0); +}); + +test('connect failures propagate their codes, roll back, and release the lock for retry', async () => { + for (const code of [403, 430, 465, 466, 467, 468]) { + const app = setup(); + app.mocks.connect_result = code; + assert.equal(await app.start(), code); + assert.equal(app.contexts[0].cleanup_calls, 1); + assert.equal(app.contexts[0].bot_status, 'offline'); + assert.equal(app.mocks.uninject_calls, 1); + assert.equal((await app.get_bot_context_status()).length, 0); + app.mocks.connect_result = 0; + assert.equal(await app.start(), 464); + assert.equal(app.contexts.length, 2); + } +}); + +test('plugin and connect exceptions clean partially initialized contexts before retry', async () => { + for (const failure of ['plugin_error', 'connect_error']) { + const app = setup(); + app.mocks[failure] = true; + assert.equal(await app.start(), 466); + assert.equal(app.contexts[0].cleanup_calls, 1); + assert.equal(app.contexts[0].bot_status, 'offline'); + assert.equal(app.mocks.uninject_calls, 1); + assert.equal(app.errors.length, 1); + assert.equal((await app.get_bot_context_status()).length, 0); + if (failure === 'plugin_error') { + assert.equal(app.mocks.workflow_calls, 0); + assert.equal(app.contexts[0].connect_calls, 0); + } + app.mocks[failure] = false; + assert.equal(await app.start(), 464); + } +}); + +test('replacement awaits disposal, holds the action lock, and reports offline before removal', async () => { + const app = setup(); + assert.equal(await app.start(), 464); + const old = app.contexts[0]; + old.bot_status = 'offline'; + const gate = deferred(); + old.cleanup_gate = gate.promise; + const replacing = app.start(); + await old.cleanup_entered.promise; + assert.equal(app.contexts.length, 1); + assert.equal((await app.get_bot_context_status())[0].status, 'disconnecting'); + assert.equal(await app.start(), 465); + gate.resolve(); + assert.equal(await replacing, 464); + assert.equal(app.contexts.length, 2); + const terminal = app.reports.find(report => report.status === 'offline'); + assert.ok(terminal); + assert.equal((await terminal.snapshot)[0].status, 'offline'); + assert.equal((await app.get_bot_context_status())[0].status, 'connecting'); +}); + +test('cleanup of an old context cannot remove its replacement and current cleanup removes its own entry', async () => { + const app = setup(); + await app.start(); + const old = app.contexts[0]; + old.bot_status = 'offline'; + await app.start(); + const current = app.contexts[1]; + await old.clear_this_context(); + assert.equal((await app.get_bot_context_status())[0].status, 'connecting'); + assert.equal(await app.start(), 463); + await current.clear_this_context(); + assert.equal((await app.get_bot_context_status()).length, 0); +}); + +test('manual start calls connect on existing active contexts and starts waiting retries immediately', async () => { + const app = setup(); + await app.start(); + for (const [status, code] of [['online', 462], ['connecting', 463], ['ready', 464], ['reconnecting', 464], ['disconnecting', 465]]) { + app.contexts[0].bot_status = status; + assert.equal(await app.start(), code); + } + assert.equal(app.contexts.length, 1); + assert.equal(app.contexts[0].cleanup_calls, 0); + assert.equal(app.contexts[0].connect_calls, 5); +}); + +test('status reporting failures are logged and pending reports do not block startup or disposal', async () => { + const app = setup(); + app.mocks.report_error = true; + assert.equal(await app.start(), 464); + assert.equal(app.errors.length, 1); + assert.match(app.errors[0][0], /status update failed/); + const gate = deferred(); + app.mocks.report_error = false; + app.mocks.report_gate = gate.promise; + await app.contexts[0].clear_this_context(); + assert.equal((await app.get_bot_context_status()).length, 0); + assert.equal(await app.start(), 464); + gate.resolve(); +}); + +test('handler ACKs only connected, connecting, and created results as success', async () => { + for (const [result, code] of [[0, 464], [462, 462], [463, 463], [403, 403], [465, 465], [466, 466], [467, 467], [468, 468]]) { + const app = setup(); + app.mocks.connect_result = result; + assert.deepEqual(await app.ack(), { + status: [462, 463, 464].includes(code) ? 'success' : 'error', data: code, + }); + } +}); + +test('handler ACKs malformed requests and unexpected exceptions as errors', async () => { + const app = setup(); + for (const payload of [null, undefined, [], {}, {node_id: 0}, {node_id: '1'}, {node_id: Infinity}, + {...config(0), node_id: 1}, {...config(), node_id: 1, plugins: null}]) { + assert.deepEqual(await app.ack(payload), {status: 'error', data: 484}); + } + const payload = {get node_id() { throw new Error('unexpected payload failure'); }}; + assert.deepEqual(await app.ack(payload), {status: 'error', data: 466}); + assert.equal(app.errors.length, 1); + assert.equal(app.contexts.length, 0); +}); + +test('handler emits method-not-allowed without a callback and does not start a bot', async () => { + const app = setup(); + for (const callback of [undefined, null, {}, 'invalid']) { + await app.handler(app.socket, {...config(), node_id: 1}, callback); + } + assert.equal(app.socket.emitted.length, 4); + for (const [event, payload] of app.socket.emitted) { + assert.equal(event, 'error'); + assert.equal(payload.error, 481); + } + assert.equal(app.contexts.length, 0); +}); diff --git a/tests/bot_stream.test.mjs b/tests/bot_stream.test.mjs new file mode 100644 index 0000000..af887c2 --- /dev/null +++ b/tests/bot_stream.test.mjs @@ -0,0 +1,713 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const require = createRequire(import.meta.url); +const root = fileURLToPath(new URL('../', import.meta.url)); +const sources = { + './bot_instance_init': 'export const BotContext = globalThis.mocks.BotContext;', + '../workflow/init': 'export const init_workflow = async () => {};', + '../../service/server/update/bot_status.ts': 'export const update_one_bot_status = async () => {};', +}; +const compiled = await build({ + stdin: { + contents: `export * from './bot/forward/stream.ts'; export * from './bot/instance/index.ts'; export * from './utils/command_completion.ts'; export {init as InventoryInfo} from './bot/info/bot/inventory.ts';`, + resolveDir: root, loader: 'ts', + }, + bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, + plugins: [{ + name: 'mock-stream-lifecycle', + setup(build) { + build.onResolve({filter: /.*/}, args => Object.hasOwn(sources, args.path) + ? {path: args.path, namespace: 'mock'} : undefined); + build.onLoad({filter: /.*/, namespace: 'mock'}, args => ({contents: sources[args.path], loader: 'js'})); + }, + }], +}); + +function setup() { + const contexts = []; + const mocks = { + cleanup_error: false, + BotContext: class extends EventEmitter { + constructor(config) { + super(); + this.bot_config = {...config, secret: 'SECRET'}; + this.bot_status = 'offline'; + this.plugin_manager = {async init() {}, async dispose() {}}; + this.bot_instance = null; + this.logger = {get_recent_log() { throw new Error('Disk access forbidden'); }}; + contexts.push(this); + } + validate_config() { return 0; } + async connect() { + this.bot_status = 'online'; + this.sent = []; + this.bot_instance = {chat: message => this.sent.push(message)}; + this.emit('status_update', 'online'); + return 0; + } + async clear_this_context() { + this.bot_status = 'offline'; + this.bot_instance = null; + this.emit('status_update', 'offline'); + if (mocks.cleanup_error) throw new Error('SECRET disposal failure'); + this.removeAllListeners(); + } + }, + }; + const module = {exports: {}}; + vm.runInNewContext(compiled.outputFiles[0].text, { + module, exports: module.exports, require, mocks, console, Buffer, structuredClone, setTimeout, clearTimeout, + }); + const api = module.exports; + return { + ...api, contexts, mocks, + start: () => api.start_bot_context({}, { + instance_config: {bot_id: 1, user_id: 1}, plugins: [], workflow: [], + }, 1), + }; +} + +function source() { + let current; + const observers = new Set(); + return { + get: () => current, + observe(id, callback) { + observers.add(callback); + callback(current); + return () => observers.delete(callback); + }, + set(context) { + current = context; + for (const callback of observers) callback(context); + }, + get count() { return observers.size; }, + }; +} + +function context() { + return Object.assign(new EventEmitter(), { + bot_status: 'online', bot_instance: {chat() {}}, secret: 'SECRET', + logger: {get_recent_log() { throw new Error('Disk access forbidden'); }}, + }); +} + +const segment = message => ({message, font_color: '#abcdef', font_style: ['normal']}); +const plain = value => JSON.parse(JSON.stringify(value)); +const fails = (action, code) => assert.throws(action, error => error.code === code); + +test('context snapshot accepts the complete inventory translation tree alongside other information', () => { + const {BotStream, InventoryInfo} = setup(); + const instance = Object.assign(new EventEmitter(), {_client: new EventEmitter(), inventory: null, currentWindow: null}); + const inventory = new InventoryInfo(instance); + const context_source = source(); + const bot = context(); + const records = [ + {name: 'health', i18n: {lang: 'zh-CN', name: '生命值', description: ''}, value: 20}, + {name: inventory.name, i18n: inventory.i18n, value: inventory.get_info()}, + ]; + bot.info_manager = {get_all_info: () => records}; + context_source.set(bot); + const stream = new BotStream(1, {source: context_source, now: () => 100}); + try { + assert.deepEqual(plain(stream.get_context_info({}, 200)), plain(records)); + } finally { stream.dispose(); inventory.dispose(); } +}); + +test('context info is online-only, cloned and rechecks state after reading', () => { + const {BotStream} = setup(); + const context_source = source(); + const stream = new BotStream(1, {source: context_source, now: () => 100}); + const read = () => stream.get_context_info({}, 200); + fails(read, 'NO_CONTEXT'); + const bot = context(); + context_source.set(bot); + for (const status of ['offline', 'ready', 'connecting', 'disconnecting', 'reconnecting']) { + bot.bot_status = status; + fails(read, 'BOT_NOT_ONLINE'); + } + bot.bot_status = 'online'; + fails(read, 'INTERNAL_ERROR'); + const records = [{name: 'health', i18n: {lang: 'zh-CN', name: 'Health', description: ''}, value: {health: 20}}]; + bot.info_manager = {get_all_info: () => records}; + assert.deepEqual(plain(read()), records); + const result = read(); result[0].value.health = 1; + assert.equal(records[0].value.health, 20); + fails(() => stream.get_context_info({extra: true}, 200), 'INVALID_REQUEST'); + fails(() => stream.get_context_info({}, 100), 'TIMEOUT'); + bot.info_manager.get_all_info = () => {bot.bot_instance = null; return records;}; + fails(read, 'CANCELLED'); + fails(read, 'BOT_NOT_ONLINE'); + stream.dispose(); + fails(read, 'CANCELLED'); +}); + +test('context info rejects malformed, cyclic, oversized and non-JSON module results without invoking getters', () => { + const {BotStream} = setup(); + const context_source = source(); const bot = context(); context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + const record = {name: 'health', i18n: {lang: 'en', name: 'Health', description: ''}, value: 20}; + const cyclic = {}; cyclic.self = cyclic; + let getter_calls = 0; + const getter = Object.defineProperty({}, 'value', {enumerable: true, get() {getter_calls++; return 1;}}); + const deep = {}; let leaf = deep; + for (let i = 0; i < 70; i++) leaf = leaf.next = {}; + const sparse = []; sparse.length = 2; + for (const value of [undefined, NaN, Infinity, 1n, () => {}, cyclic, getter, new Date(), sparse, + {toJSON() {throw new Error('must not call');}}, 'x'.repeat(4 * 1024 * 1024 + 1), deep]) { + bot.info_manager = {get_all_info: () => [{...record, value}]}; + fails(() => stream.get_context_info({}, Date.now() + 1000), 'INTERNAL_ERROR'); + } + for (const records of [[{...record, name: 'bad.name'}], [record, record], + [{...record, i18n: {...record.i18n, fields: {x: {lang: 'en', name: 'X'}}}}], + [{...record, i18n: {...record.i18n, values: []}}], + [{...record, i18n: {...record.i18n, extra: true}}], [{...record, secret: true}], + [{...record, i18n: {...record.i18n, name: 'x'.repeat(257)}}], Array(257).fill(record)]) { + bot.info_manager = {get_all_info: () => records}; + fails(() => stream.get_context_info({}, Date.now() + 1000), 'INTERNAL_ERROR'); + } + assert.equal(getter_calls, 0); + stream.dispose(); +}); + +test('info updates share log ordering, clone values and detach stale contexts', () => { + const {BotStream} = setup(); + const context_source = source(); const bot = context(); context_source.set(bot); + bot.info_manager = {get_all_info: () => []}; + const stream = new BotStream(1, {source: context_source}); + const events = []; stream.subscribe(event => events.push(event)); + bot.emit('log', [segment('before')]); + const input = {name: 'health', value: {health: 19}}; + bot.emit('info_update', input); input.value.health = 1; + bot.emit('log', [segment('after')]); + assert.deepEqual(events.map(event => event.name), ['log', 'info_update', 'log']); + assert.equal(events[1].payload.value.health, 19); + assert.ok(events[0].seq < events[1].seq && events[1].seq < events[2].seq); + bot.bot_status = 'offline'; bot.emit('info_update', {name: 'health', value: 0}); + assert.equal(events.length, 3); + bot.bot_status = 'online'; + const stale = bot.listeners('info_update')[0]; + context_source.set(context()); + const count = events.length; + stale({name: 'health', value: 1}); + assert.equal(events.length, count); + assert.equal(bot.listenerCount('info_update'), 0); + stream.dispose(); + assert.equal(context_source.get().listenerCount('info_update'), 0); +}); + +test('invalid info pushes are skipped without interrupting later valid updates', t => { + const diagnostics = []; + t.mock.method(console, 'error', message => diagnostics.push(message)); + const {BotStream} = setup(); + const context_source = source(); const bot = context(); context_source.set(bot); + bot.info_manager = {get_all_info: () => []}; + const stream = new BotStream(1, {source: context_source}); + const events = []; stream.subscribe(event => events.push(event)); + let called = false; + const accessor = Object.defineProperty({name: 'health'}, 'value', { + enumerable: true, get() {called = true; return 10;}, + }); + for (const update of [accessor, {name: 'health', value: NaN}, {name: 'health', value: 1, extra: true}]) + bot.emit('info_update', update); + assert.equal(called, false); + assert.equal(events.length, 0); + assert.equal(diagnostics.length, 3); + bot.emit('info_update', {name: 'health', value: 12}); + assert.equal(events.length, 1); + assert.equal(events[0].payload.value, 12); + stream.dispose(); +}); + +test('deep and large inventory values work for both queries and pushes with bounded diagnostics', t => { + const diagnostics = []; + t.mock.method(console, 'error', (...args) => diagnostics.push(args)); + const {BotStream} = setup(); + const context_source = source(); const bot = context(); context_source.set(bot); + let deep = {text: 'book page'}; + for (let i = 0; i < 24; i++) deep = {value: deep}; + const value = {window: {slots: Array.from({length: 90}, () => ({ + type: 1, nbt: deep, components: [{type: 'custom_data', data: 'x'.repeat(4096)}], + }))}}; + const records = [{name: 'inventory', i18n: {lang: 'en', name: 'Inventory', description: ''}, value}]; + bot.info_manager = {get_all_info: () => records}; + const stream = new BotStream(1, {source: context_source}); + try { + assert.ok(Buffer.byteLength(JSON.stringify(value)) > 196_608); + assert.deepEqual(plain(stream.get_context_info({}, Date.now() + 1000)), records); + const events = []; stream.subscribe(event => events.push(event)); + bot.emit('info_update', {name: 'inventory', value}); + assert.equal(events.length, 1); + assert.deepEqual(plain(events[0].payload.value), value); + let too_deep = {secret: 'DO_NOT_LOG'}; + for (let i = 0; i < 70; i++) too_deep = {next: too_deep}; + bot.emit('info_update', {name: 'inventory', value: too_deep}); + assert.equal(events.length, 1); + assert.equal(diagnostics[0][1].module, 'inventory'); + assert.equal(diagnostics[0][1].reason, 'max_depth'); + assert.ok(diagnostics[0][1].path.startsWith('$.value.')); + assert.equal(JSON.stringify(diagnostics).includes('DO_NOT_LOG'), false); + bot.emit('info_update', {name: 'inventory', value: {window: null}}); + assert.equal(events.length, 2); + } finally {stream.dispose();} +}); + +test('observers follow absent, insertion, replacement and removal; unsubscribe releases observer', async () => { + const app = setup(); + const seen = []; + const unsubscribe = app.observe_bot_context(1, value => seen.push(value)); + assert.deepEqual(seen, [undefined]); + assert.equal(app.get_bot_context(1), undefined); + await app.start(); + const first = app.contexts[0]; + assert.equal(app.get_bot_context(1), first); + first.bot_status = 'offline'; + await app.start(); + const second = app.contexts[1]; + assert.deepEqual(seen, [undefined, first, undefined, second]); + await first.clear_this_context(); + assert.equal(app.get_bot_context(1), second); + assert.equal(seen.length, 4); + await app.stop_bot_context(1); + assert.equal(seen.at(-1), undefined); + unsubscribe(); + unsubscribe(); + await app.start(); + assert.equal(seen.length, 5); +}); + +test('cleanup failure still removes context and notifies observers; failed observer cannot interrupt startup', async () => { + const app = setup(); + const seen = []; + const unsubscribe = app.observe_bot_context(1, value => seen.push(value)); + const failing = app.observe_bot_context(1, () => { throw new Error('consumer failure'); }); + assert.equal(await app.start(), 464); + app.mocks.cleanup_error = true; + assert.equal(await app.stop_bot_context(1), 466); + assert.equal(app.get_bot_context(1), undefined); + assert.equal(seen.at(-1), undefined); + unsubscribe(); failing(); +}); + +test('default source follows start/stop/restart with stable stream ID and UUID generation per context lifetime', async () => { + const app = setup(); + const stream = new app.BotStream(1); + const events = []; + stream.subscribe(event => events.push(event)); + const absent = stream.snapshot({}); + assert.equal(absent.context_exists, false); + assert.equal(absent.context_generation, null); + assert.equal(absent.bot_status, 'offline'); + assert.equal(absent.seq, 0); + await app.start(); + const first = stream.snapshot(); + assert.match(first.context_generation, /^[0-9a-f-]{36}$/); + app.contexts[0].emit('log', [segment('first')]); + const twin = new app.BotStream(1); + assert.equal(twin.snapshot().context_generation, first.context_generation); + assert.notEqual(twin.stream_id, stream.stream_id); + twin.dispose(); + await app.stop_bot_context(1); + const stopped = stream.snapshot(); + assert.equal(stopped.context_exists, false); + assert.equal(stopped.context_generation, null); + assert.equal(stopped.bot_status, 'offline'); + assert.deepEqual(plain(events.at(-1).payload), {status: 'offline', context_exists: false}); + await app.start(); + const restarted = stream.snapshot(); + assert.notEqual(restarted.context_generation, first.context_generation); + assert.equal(restarted.stream_id, absent.stream_id); + assert.ok(restarted.seq > first.seq); + assert.equal(stream.logs({limit: 200}).records[0].context_generation, first.context_generation); + for (let i = 1; i < events.length; i++) assert.ok(events[i].seq > events[i - 1].seq); + stream.dispose(); + await app.stop_bot_context(1); +}); + +test('bounded replay has shared sequence watermarks, gap detection, limit paging and stream reset detection', () => { + const {BotStream} = setup(); + const context_source = source(); + const bot = context(); + context_source.set(bot); + const stream = new BotStream(1, {source: context_source, capacity: 3, now: () => 123}); + const start = stream.snapshot(); + const events = []; + stream.subscribe(event => events.push(event)); + for (let i = 0; i < 5; i++) bot.emit('log', [segment(String(i))]); + bot.emit('message', 'hello', 'chat'); + const snapshot = stream.snapshot(); + assert.equal(snapshot.seq, events.at(-1).seq); + const latest = stream.logs({limit: 2}); + assert.equal(latest.seq, snapshot.seq); + assert.deepEqual(latest.records.map(record => record.segments[0].message), ['3', '4']); + assert.equal(latest.truncated, true); + const missing = stream.logs({limit: 2, stream_id: stream.stream_id, after_seq: start.seq}); + assert.deepEqual(missing.records.map(record => record.segments[0].message), ['2', '3']); + assert.equal(missing.truncated, true); + const last = stream.logs({limit: 2, stream_id: stream.stream_id, after_seq: missing.records.at(-1).seq}); + assert.deepEqual(last.records.map(record => record.segments[0].message), ['4']); + assert.equal(last.truncated, false); + assert.equal(stream.logs({limit: 2, stream_id: randomUUID(), after_seq: 0}).truncated, true); + fails(() => stream.logs({limit: 1, stream_id: stream.stream_id, after_seq: snapshot.seq + 1}), 'RESYNC_REQUIRED'); + stream.dispose(); +}); + +test('resync emits a fresh status watermark for absent and current contexts without resetting stream', () => { + const {BotStream} = setup(); + const context_source = source(); + const stream = new BotStream(1, {source: context_source}); + const events = []; + stream.subscribe(event => events.push(event)); + stream.resync(); + assert.deepEqual(plain(events[0].payload), {status: 'offline', context_exists: false}); + const bot = context(); + context_source.set(bot); + const current = stream.snapshot(); + stream.resync(); + assert.equal(events.at(-1).stream_id, current.stream_id); + assert.equal(events.at(-1).context_generation, current.context_generation); + assert.ok(events.at(-1).seq > current.seq); + assert.equal(events.at(-1).seq, stream.snapshot().seq); + stream.dispose(); + fails(() => stream.resync(), 'CANCELLED'); +}); + +test('safe projection never serializes raw objects, message JSON, accessors, error details or extra fields', () => { + const {BotStream} = setup(); + const context_source = source(); + const bot = context(); + context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + const events = []; + stream.subscribe(event => events.push(event)); + let conversions = 0; + const secret = {secret: 'SECRET', toString() { conversions++; throw new Error('Do not stringify'); }}; + const accessor = {get message() { conversions++; throw new Error('Do not invoke'); }}; + bot.emit('log', [segment(secret), accessor]); + assert.equal(events.length, 0); + bot.emit('log', [{...segment('safe'), secret, font_color: 'url(SECRET)', font_style: ['bold', 'unsafe']}]); + bot.emit('message', {json: {text: 'hello', extra: [{text: ' world'}], secret}, toString: secret.toString}, 'chat'); + const error = new Error('SECRET stack/password'); + error.code = 'SECRET'; + bot.emit('error', error); + assert.equal(conversions, 0); + assert.deepEqual(events.map(event => event.name), ['log', 'message', 'bot_error']); + assert.deepEqual(plain(events[0].payload.records[0].segments), [{ + message: 'safe', font_color: '#ffffff', font_style: ['bold'], + }]); + assert.deepEqual(plain(events[1].payload), {text: 'hello world', position: 'chat'}); + assert.deepEqual(plain(events[2].payload), {message: 'Bot connection error'}); + assert.equal(JSON.stringify({events, snapshot: stream.snapshot(), logs: stream.logs({limit: 200})}).includes('SECRET'), false); + assert.deepEqual(Object.keys(events[0]).sort(), ['bot_id', 'context_generation', 'emitted_at', 'name', 'payload', 'seq', 'stream_id']); + stream.dispose(); +}); + +test('projection and buffer sizes stay bounded even for huge Unicode messages and log arrays', () => { + const {BotStream} = setup(); + const context_source = source(); + const bot = context(); + context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + const input = Array.from({length: 500}, () => ({ + ...segment('😀'.repeat(10_000)), font_style: ['normal', 'italic', 'bold', 'light', 'underline', 'url'], + })); + for (let i = 0; i < 250; i++) bot.emit('log', input); + const logs = stream.logs({limit: 200}); + assert.ok(logs.records.length > 0 && logs.records.length < 200); + assert.equal(logs.truncated, true); + assert.ok(Buffer.byteLength(JSON.stringify(logs)) < 262_144); + assert.ok(logs.records.every(record => record.segments.every(item => Buffer.byteLength(item.message) <= 16_000))); + const next = stream.logs({limit: 200, stream_id: stream.snapshot().stream_id, after_seq: 0}); + const page = stream.logs({limit: 200, stream_id: next.stream_id, after_seq: next.records.at(-1).seq}); + assert.ok(page.records[0].seq > next.records.at(-1).seq); + stream.dispose(); +}); + +test('forwarded logs preserve every colored character and the string prefix', () => { + const {BotStream} = setup(); + const context_source = source(); + const bot = context(); + context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + const input = [{...segment('chat: '), hoverEvent: {action: 'show_text', value: 'tooltip', segments: [{message: 'tooltip', font_color: '#55ffff', font_style: ['bold']}]}, + clickEvent: {action: 'suggest_command', value: '/help'}, insertion: 'chat'}, ...Array.from('完整的多色聊天消息 ABC 123 😀', (message, index) => ({ + ...segment(message), font_color: index % 2 ? '#ff0000' : '#00ff00', + }))]; + let live; + stream.subscribe(event => { if (event.name === 'log') live = event; }); + bot.emit('log', input); + const expected = input.map(item => item.message).join(''); + const segments = live.payload.records[0].segments; + assert.equal(segments.length, input.length); + assert.equal(segments.map(item => item.message).join(''), expected); + assert.deepEqual(plain(segments[0]), input[0]); + assert.deepEqual(plain(stream.logs({limit: 1}).records[0].segments), plain(segments)); + stream.dispose(); +}); + +test('disk history works without a context and does not change live stream cursors', async () => { + const {BotStream} = setup(); + const context_source = source(); + const entries = Array.from({length: 25}, (_, index) => ({id: randomUUID(), timestamp: index, + log: [{...segment(`disk ${index}`), hoverEvent: {action: 'show_text', value: 'tooltip'}}]})); + let requested; + const stream = new BotStream(1, {source: context_source, history_reader: async count => { + requested = count; + return entries.slice(-count); + }}); + const before = stream.snapshot(); + const result = await stream.get_log_history(); + assert.equal(requested, 20); + assert.equal(result.records.length, 20); + assert.deepEqual(plain(result.records.map(record => record.id)), entries.slice(-20).map(entry => entry.id)); + assert.equal(result.records[0].segments[0].hoverEvent.value, 'tooltip'); + assert.notEqual(result.stream_id, before.stream_id); + assert.equal(stream.snapshot().seq, before.seq); + assert.equal(stream.logs({limit: 20}).records.length, 0); + stream.dispose(); +}); + +test('subscriber mutations and exceptions cannot corrupt other consumers or buffered records', () => { + const {BotStream} = setup(); + const context_source = source(); + const bot = context(); + context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + stream.subscribe(event => { + if (event.name === 'log') event.payload.records[0].segments[0].message = 'changed'; + throw new Error('consumer failure'); + }); + let received; + stream.subscribe(event => { received = event; }); + const input = [segment('original')]; + bot.emit('log', input); + input[0].message = 'changed'; + assert.equal(received.payload.records[0].segments[0].message, 'original'); + const result = stream.logs({limit: 1}); + result.records[0].segments[0].message = 'changed'; + assert.equal(stream.logs({limit: 1}).records[0].segments[0].message, 'original'); + stream.dispose(); +}); + +test('reentrant subscribers preserve event sequence order for every consumer', () => { + const {BotStream} = setup(); + const context_source = source(); + const bot = context(); + context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + const seen = []; + stream.subscribe(event => { + if (event.name === 'log') stream.resync(); + }); + stream.subscribe(event => seen.push(event.seq)); + bot.emit('log', [segment('nested')]); + assert.equal(seen.length, 2); + assert.ok(seen[1] > seen[0]); + stream.dispose(); +}); + +test('replacement detaches listeners, rejects stale callbacks; disposal releases source and context subscriptions', () => { + const {BotStream} = setup(); + const context_source = source(); + const old = context(); + context_source.set(old); + const stream = new BotStream(1, {source: context_source}); + const events = []; + const unsubscribe = stream.subscribe(event => events.push(event)); + const old_log = old.listeners('log')[0]; + const replacement = context(); + context_source.set(replacement); + assert.equal(old.listenerCount('log'), 0); + const count = events.length; + old_log([segment('stale')]); + assert.equal(events.length, count); + replacement.emit('log', [segment('current')]); + assert.equal(events.length, count + 1); + unsubscribe(); + replacement.emit('log', [segment('unsubscribed')]); + assert.equal(events.length, count + 1); + assert.equal(context_source.count, 1); + stream.dispose(); stream.dispose(); + assert.equal(context_source.count, 0); + for (const event of ['status_update', 'log', 'message', 'error']) assert.equal(replacement.listenerCount(event), 0); + fails(() => stream.snapshot(), 'CANCELLED'); + fails(() => stream.subscribe(() => {}), 'CANCELLED'); +}); + +test('tab completion normalizes mixed matches, deduplicates and cancels stale results', async () => { + const {BotStream} = setup(); + const context_source = source(); + const stream = new BotStream(1, {source: context_source}); + const reject = (promise, code) => assert.rejects(promise, error => error.code === code); + await reject(stream.tab_complete({text: '/he'}, Date.now() + 1000), 'NO_CONTEXT'); + const bot = context(); bot.bot_status = 'online'; + let release; + bot.bot_instance.tabComplete = () => new Promise(resolve => { release = resolve; }); + context_source.set(bot); + const pending = stream.tab_complete({text: '/he'}, Date.now() + 1000); + await reject(stream.tab_complete({text: '/he'}, Date.now() + 1000), 'RATE_LIMITED'); + release(['help', {match: 'help'}, {match: 'hello'}, 'hello', '', {match: 42}]); + assert.deepEqual(plain(await pending), {matches: ['help', 'hello'], start: 1, length: 2}); + bot.bot_instance.tabComplete = async () => ['Alex', 'Alex']; + assert.deepEqual(plain(await stream.tab_complete({text: '/tell Al'}, Date.now() + 1000)), + {matches: ['Alex'], start: 6, length: 2}); + assert.deepEqual(plain(await stream.tab_complete({text: '/tell '}, Date.now() + 1000)), + {matches: ['Alex'], start: 6, length: 0}); + bot.bot_instance.tabComplete = async () => ['/help', 'help']; + assert.deepEqual(plain(await stream.tab_complete({text: '/he'}, Date.now() + 1000)), + {matches: ['help'], start: 1, length: 2}); + bot.bot_instance.tabComplete = () => new Promise(resolve => { release = resolve; }); + for (const payload of [{}, {text: 2}, {text: 'a\n'}, {text: 'x'.repeat(257)}]) + await reject(stream.tab_complete(payload, Date.now() + 1000), 'INVALID_REQUEST'); + const stale = stream.tab_complete({text: ''}, Date.now() + 1000); + context_source.set(undefined); release(['stale']); + await reject(stale, 'CANCELLED'); + stream.dispose(); +}); + +test('modern completion matches transaction IDs and cleans listeners on timeout', async () => { + const {BotStream} = setup(); + const context_source = source(); + const bot = context(); bot.bot_status = 'online'; + const client = new EventEmitter(); client.protocolVersion = 393; + let sent; + client.write = (name, packet) => { sent = packet; assert.equal(name, 'tab_complete'); }; + bot.bot_instance._client = client; + bot.bot_instance.tabComplete = () => { throw new Error('Legacy API must not be used'); }; + context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + const pending = stream.tab_complete({text: '/he'}, Date.now() + 1000); + client.emit('tab_complete', {transactionId: sent.transactionId + 1, matches: ['wrong']}); + client.emit('tab_complete', {transactionId: sent.transactionId, start: 1, length: 2, matches: [{match: 'help'}, {match: 'help'}]}); + assert.deepEqual(plain(await pending), {matches: ['help'], start: 1, length: 2}); + const parameter = stream.tab_complete({text: '/tell Alex he'}, Date.now() + 1000); + client.emit('tab_complete', {transactionId: sent.transactionId, start: 11, length: 2, matches: [{match: 'hello'}]}); + assert.deepEqual(plain(await parameter), {matches: ['hello'], start: 11, length: 2}); + const malformed = stream.tab_complete({text: '/tell '}, Date.now() + 1000); + client.emit('tab_complete', {transactionId: sent.transactionId, start: 6, length: 10, matches: []}); + await assert.rejects(malformed, error => error.code === 'INTERNAL_ERROR'); + assert.equal(client.listenerCount('tab_complete'), 0); + await assert.rejects(stream.tab_complete({text: '/he'}, Date.now() + 15), error => error.code === 'TIMEOUT'); + assert.equal(client.listenerCount('tab_complete'), 0); + assert.equal(client.listenerCount('end'), 0); + stream.dispose(); +}); + +test('declared commands supply missing literals, subcommands and player arguments', async () => { + const {BotStream, remember_command_tree} = setup(); + const context_source = source(); + const bot = context(); bot.bot_status = 'online'; + const client = new EventEmitter(); client.protocolVersion = 393; + client.write = (_name, packet) => queueMicrotask(() => client.emit('tab_complete', { + transactionId: packet.transactionId, start: 0, length: 0, matches: [], + })); + bot.bot_instance._client = client; + bot.bot_instance.tabComplete = async () => []; + bot.bot_instance.players = {Alex: {}, Alice: {}, Bob: {}}; + const literal = (name, children = []) => ({flags: {command_node_type: 1}, extraNodeData: {name}, children}); + remember_command_tree(bot.bot_instance, {rootIndex: 0, nodes: [ + {children: [1, 3]}, literal('tell', [2]), + {flags: {command_node_type: 2}, extraNodeData: {name: 'player', parser: 'minecraft:entity'}, children: []}, + literal('team', [4]), literal('join', [5]), literal('red'), + ]}); + context_source.set(bot); + const stream = new BotStream(1, {source: context_source}); + assert.deepEqual(plain(await stream.tab_complete({text: '/te'}, Date.now() + 1000)), + {matches: ['tell', 'team'], start: 1, length: 2}); + assert.deepEqual(plain(await stream.tab_complete({text: '/tell Al'}, Date.now() + 1000)), + {matches: ['Alex', 'Alice'], start: 6, length: 2}); + assert.deepEqual(plain(await stream.tab_complete({text: '/team join r'}, Date.now() + 1000)), + {matches: ['red'], start: 11, length: 1}); + client.write = () => {}; + assert.deepEqual(plain(await stream.tab_complete({text: '/tell Al'}, Date.now() + 1000)), + {matches: ['Alex', 'Alice'], start: 6, length: 2}); + stream.dispose(); +}); + +test('26.3 codec preserves actual command suggestion request and response fields', () => { + const {createSerializer, createDeserializer} = require('@wp2508/minecraft-protocol/src/transforms/serializer'); + for (const isServer of [false, true]) { + const packet = {name: 'tab_complete', params: isServer + ? {transactionId: 7, start: 6, length: 2, matches: [{match: 'Alex'}]} + : {transactionId: 7, text: '/tell Al'}}; + const serializer = createSerializer({state: 'play', isServer, version: '26.3'}); + const decoder = createDeserializer({state: 'play', isServer: !isServer, version: '26.3'}); + assert.deepEqual(JSON.parse(JSON.stringify(decoder.parsePacketBuffer(serializer.createPacketBuffer(packet)).data)), packet); + } +}); + +test('function RPC returns booleans and prevents expired queued work', async () => { + const {BotStream} = setup(); + const context_source = source(); + let now = 100; + const stream = new BotStream(1, {source: context_source, now: () => now}); + const bot = context(); + context_source.set(bot); + const payload = {function: 'inventory', name: 'left_click_slot', args: [9]}; + for (const result of [true, false, undefined]) { + bot.function_manager = {async run(path, name, args, before_run) { + assert.equal(path, payload.function); + assert.equal(name, payload.name); + assert.deepEqual(args, payload.args); + before_run(); + return result; + }}; + assert.equal(await stream.execute_function(payload, 200), result === true); + } + bot.function_manager = {async run() { throw new Error('SECRET'); }}; + assert.equal(await stream.execute_function(payload, 200), false); + bot.bot_status = 'offline'; + assert.equal(await stream.execute_function(payload, 200), false); + bot.bot_status = 'online'; + bot.function_manager = {async run(path, name, args, before_run) { now = 200; before_run(); }}; + await assert.rejects(stream.execute_function(payload, 200), error => error.code === 'TIMEOUT'); + now = 100; + bot.function_manager = {async run(path, name, args, before_run) { + context_source.set(context()); + before_run(); + }}; + await assert.rejects(stream.execute_function(payload, 200), error => error.code === 'CANCELLED'); + stream.dispose(); +}); + +test('strict RPC inputs and chat eligibility/deadlines prevent side effects and sanitize failures', () => { + const {BotStream} = setup(); + const context_source = source(); + let now = 100; + const stream = new BotStream(1, {source: context_source, now: () => now}); + fails(() => stream.chat({message: 'hello'}, 200), 'NO_CONTEXT'); + const bot = context(); + const sent = []; + bot.bot_instance.chat = message => sent.push(message); + context_source.set(bot); + for (const payload of [null, [], {}, {message: ''}, {message: ' '}, {message: 'x'.repeat(257)}, + {message: 'hello', extra: 'SECRET'}, {get message() { throw new Error('getter'); }}]) { + fails(() => stream.chat(payload, 200), 'INVALID_REQUEST'); + } + for (const deadline of [100, 99, NaN, Infinity]) fails(() => stream.chat({message: 'hello'}, deadline), 'TIMEOUT'); + for (const status of ['ready', 'connecting', 'reconnecting', 'disconnecting', 'offline']) { + bot.bot_status = status; + fails(() => stream.chat({message: 'hello'}, 200), 'BOT_NOT_ONLINE'); + } + assert.deepEqual(sent, []); + bot.bot_status = 'online'; + assert.deepEqual(plain(stream.chat({message: 'hello'}, 200)), {accepted: true}); + assert.deepEqual(sent, ['hello']); + bot.bot_instance.chat = () => { throw new Error('SECRET chat failed'); }; + assert.throws(() => stream.chat({message: 'hello'}, 200), error => error.code === 'INTERNAL_ERROR' && !error.message.includes('SECRET')); + for (const payload of [null, [], {extra: true}]) fails(() => stream.snapshot(payload), 'INVALID_REQUEST'); + for (const payload of [null, [], {}, {limit: 0}, {limit: 201}, {limit: 1.5}, {limit: 1, after_seq: 0}, + {limit: 1, stream_id: 'bad'}, {limit: 1, stream_id: stream.stream_id, after_seq: -1}, {limit: 1, extra: true}]) { + fails(() => stream.logs(payload), 'INVALID_REQUEST'); + } + now = 300; + fails(() => stream.chat({message: 'hello'}, 200), 'TIMEOUT'); + stream.dispose(); +}); diff --git a/tests/config_apply.test.mjs b/tests/config_apply.test.mjs new file mode 100644 index 0000000..a241d2e --- /dev/null +++ b/tests/config_apply.test.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import {randomUUID} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {build} from 'esbuild'; + +const compiled = await build({ + entryPoints: [new URL('../utils/bot_config_apply_handler.ts', import.meta.url).pathname], + bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, + plugins: [{name: 'isolation', setup(build) { + build.onResolve({filter: /bot\/instance\/index\.ts$/}, () => ({path: 'context', namespace: 'mock'})); + build.onResolve({filter: /console_print\.ts$/}, () => ({path: 'logger', namespace: 'mock'})); + build.onResolve({filter: /runtime\/compile\.ts$/}, () => ({path: 'compile', namespace: 'mock'})); + build.onLoad({filter: /.*/, namespace: 'mock'}, ({path}) => ({contents: path === 'context' + ? 'export const get_bot_context = id => globalThis.lookup(id);' + : path === 'compile' ? 'export const compile_workflow = value => globalThis.compile(value);' + : 'export const console_print={log:value=>globalThis.logged(value),success:value=>value,error:value=>value};'})); + }}], +}); +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; }; +const flush = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); }; +const dto = (revision = 1, extras = {}) => ({command_id: randomUUID(), payload_version: 1, node_session_id: 'session', + bot_id: 1, config_revision: revision, plugins: [], workflows: [], ...extras}); +const success = (request, state) => ({status: 'success', data: {command_id: request.command_id, bot_id: request.bot_id, + config_revision: request.config_revision, state}}); + +function setup() { + const calls = [], logs = []; + const contexts = new Map(); + const options = {}; + const module = {exports: {}}; + vm.runInNewContext(compiled.outputFiles[0].text, {module, exports: module.exports, require: createRequire(import.meta.url), + Buffer, structuredClone, lookup: id => contexts.get(id), logged: value => logs.push(value), + compile: value => { + calls.push(['compile', structuredClone(value)]); + if (options.compile_error || !value?.id) throw new Error('secret workflow code'); + return {definition: value}; + }}); + const context = {bot_config: {config_revision: 0, plugins: [], workflow: []}, bot_status: 'offline', is_disposed: false, + plugin_manager: {async apply_desired(plugins) { + calls.push(['plugins', structuredClone(plugins)]); + if (options.plugin_gate) await options.plugin_gate; + if (options.plugin_error) throw options.plugin_error; + }}, workflow_manager: {async apply(workflows, revision) { + calls.push(['workflows', structuredClone(workflows), revision]); + if (options.workflow_gate) await options.workflow_gate; + if (options.workflow_error) throw options.workflow_error; + }}, logger: {info: value => value, error: value => value}, log: value => logs.push(value)}; + contexts.set(1, context); + const handler = module.exports.create_bot_config_apply_handler(); + const socket = {connected: true, id: 'session'}; + return {calls, logs, contexts, options, context, handler, socket, async run(request, ack) { + const replies = []; + await handler(socket, request, value => { replies.push(structuredClone(value)); ack?.(); }); + assert.equal(replies.length, 1); + return replies[0]; + }}; +} + +test('processing ACK is immediate, duplicate jobs install once and completed ACK has exact fields', async () => { + const s = setup(), request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {value: 1}}]}); + const gate = deferred(); s.options.plugin_gate = gate.promise; + assert.deepEqual(await s.run(request), success(request, 'processing')); + for (let i = 0; i < 3; i++) assert.deepEqual(await s.run(request), success(request, 'processing')); + assert.deepEqual(s.calls.map(call => call[0]), ['plugins']); + gate.resolve(); await flush(); + assert.deepEqual(await s.run(request), success(request, 'completed')); + assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']); + assert.equal(s.context.bot_config.config_revision, 1); + assert.deepEqual(s.context.bot_config.plugins, request.plugins); + assert.equal(s.logs.length, 2); +}); + +test('strict bounded JSON validation rejects malformed input before lookup or mutation', async () => { + const s = setup(), request = dto(); + const unsafe = Object.defineProperty({...request}, 'plugins', {enumerable: true, get() { throw new Error('getter secret'); }}); + const cyclic = dto(); cyclic.plugins.push(cyclic); + const bad = [null, [], {}, {...request, extra: true}, {...request, payload_version: 2}, {...request, command_id: 'bad'}, + {...request, bot_id: 0}, {...request, config_revision: 0}, {...request, workflows: {}}, unsafe, cyclic, + new Proxy(request, {}), {...request, plugins: [{path: '../evil', name: 'a', config: {}}]}, + {...request, plugins: [{path: 'runtime', name: 'a', config: {bad: NaN}}]}, + {...request, plugins: [{path: 'runtime', name: 'a', config: {}}, {path: 'other', name: 'a', config: {}}]}, + {...request, plugins: [{path: 'runtime', name: 'a', config: {huge: 'x'.repeat(256 * 1024)}}]}]; + for (const data of bad) assert.equal((await s.run(data)).data.code, 'INVALID_PAYLOAD'); + assert.deepEqual(s.calls, []); +}); + +test('exact 256KiB serialized limit accepts boundary and rejects next byte', async () => { + const s = setup(); + const request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {text: ''}}]}); + request.plugins[0].config.text = 'x'.repeat(256 * 1024 - Buffer.byteLength(JSON.stringify(request))); + assert.equal(Buffer.byteLength(JSON.stringify(request)), 256 * 1024); + assert.equal((await s.run(request)).status, 'success'); + const excess = structuredClone(request); excess.command_id = randomUUID(); excess.plugins[0].config.text += 'x'; + assert.equal((await s.run(excess)).data.code, 'INVALID_PAYLOAD'); + await flush(); +}); + +test('all workflows compile before plugins and duplicate graph IDs reject without mutation', async () => { + const s = setup(); + for (const workflows of [[{id: 'valid'}, {}], [{id: 'same'}, {id: 'same'}]]) { + assert.equal((await s.run(dto(1, {workflows}))).data.code, 'INVALID_PAYLOAD'); + assert.ok(s.calls.every(call => call[0] === 'compile')); + } + s.calls.length = 0; + const request = dto(1, {workflows: [{id: 'a'}, {id: 'b'}]}); + await s.run(request); await flush(); + assert.deepEqual(s.calls.map(call => call[0]), ['compile', 'compile', 'plugins', 'workflows']); +}); + +test('missing context is explicit and never creates managers, ACK throws do not repeat work', async () => { + const s = setup(), request = dto(); + s.contexts.clear(); + assert.deepEqual(await s.run(request), success(request, 'no_context')); + s.contexts.set(1, s.context); + assert.deepEqual(await s.run(request), success(request, 'no_context')); + await s.run(dto(), () => { throw new Error('ACK failed'); }); await flush(); + assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']); + await s.handler(s.socket, new Proxy({}, {get() { throw new Error('unread'); }})); +}); + +test('socket session and context disposal checked before and after each await', async () => { + const s = setup(); + assert.equal((await s.run(dto(1, {node_session_id: 'old'}))).data.code, 'SESSION_MISMATCH'); + s.socket.connected = false; + assert.equal((await s.run(dto())).data.code, 'SESSION_MISMATCH'); + s.socket.connected = true; + const request = dto(), gate = deferred(); s.options.plugin_gate = gate.promise; + await s.run(request); + s.context.is_disposed = true; gate.resolve(); await flush(); + assert.equal((await s.run(request)).data.code, 'CONTEXT_CHANGED'); + assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 0); + assert.equal(s.context.bot_config.config_revision, 0); +}); + +test('replacement context and reconnect never receive stale async completion', async () => { + for (const change of ['replace', 'session', 'disconnect']) { + const s = setup(), request = dto(), gate = deferred(); s.options.workflow_gate = gate.promise; + await s.run(request); await flush(); + if (change === 'replace') s.contexts.set(1, {...s.context}); + if (change === 'session') s.socket.id = 'new'; + if (change === 'disconnect') s.socket.connected = false; + gate.resolve(); await flush(); + if (change !== 'replace') { s.socket.id = 'session'; s.socket.connected = true; } + assert.equal((await s.run(request)).data.code, change === 'replace' ? 'CONTEXT_CHANGED' : 'SESSION_MISMATCH'); + assert.equal(s.context.bot_config.config_revision, 0); + } +}); + +test('per-context queue serializes revisions and rejects stale, conflicting revision and command payloads', async () => { + const s = setup(), first = dto(2), gate = deferred(); s.options.plugin_gate = gate.promise; + await s.run(first); + const second = dto(3, {plugins: [{path: 'runtime', name: 'a', config: {}}]}); + await s.run(second); + assert.equal((await s.run(dto(1))).data.code, 'STALE_REVISION'); + assert.equal((await s.run({...second, command_id: randomUUID(), plugins: []})).data.code, 'REVISION_CONFLICT'); + assert.equal((await s.run({...first, config_revision: 4})).data.code, 'COMMAND_CONFLICT'); + assert.deepEqual(s.calls.map(call => call[0]), ['plugins']); + gate.resolve(); await flush(); + assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows', 'plugins', 'workflows']); + assert.equal(s.context.bot_config.config_revision, 3); + assert.deepEqual(await s.run(second), success(second, 'completed')); +}); + +test('initial context revision fences old commands and identical applied configs preserve managers', async () => { + const s = setup(); + s.context.bot_config.config_revision = 9; + assert.equal((await s.run(dto(8))).data.code, 'STALE_REVISION'); + const request = dto(9); + await s.run(request); await flush(); + assert.deepEqual(await s.run(request), success(request, 'completed')); + assert.deepEqual(s.calls, []); + assert.equal((await s.run(dto(9, {plugins: [{path: 'runtime', name: 'a', config: {}}]}))).data.code, 'REVISION_CONFLICT'); +}); + +test('workflow partial failure is reported safely and same command recovers on next attempt', async () => { + const s = setup(), request = dto(); + s.options.workflow_error = {status: 'error', data: {code: 'LEAK', message: 'password secret'}}; + await s.run(request); await flush(); + const reply = await s.run(request); + assert.equal(reply.data.code, 'PARTIAL_APPLY_FAILED'); + assert.equal(JSON.stringify([reply, s.logs]).includes('secret'), false); + assert.equal(s.context.bot_config.config_revision, 0); + s.options.workflow_error = undefined; + assert.deepEqual(await s.run(request), success(request, 'processing')); + await flush(); + assert.deepEqual(await s.run(request), success(request, 'completed')); + assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 2); +}); + +test('pending jobs capped at 32 without evicting inflight work', async () => { + const s = setup(), gate = deferred(); s.options.plugin_gate = gate.promise; + const requests = Array.from({length: 32}, (_, i) => dto(i + 1)); + for (const request of requests) assert.equal((await s.run(request)).data.state, 'processing'); + assert.equal((await s.run(dto(33))).data.code, 'APPLY_BUSY'); + assert.deepEqual(await s.run(requests[0]), success(requests[0], 'processing')); + gate.resolve(); await new Promise(setImmediate); + assert.deepEqual(await s.run(requests.at(-1)), success(requests.at(-1), 'completed')); + assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 2); + assert.equal((await s.run(requests[1])).data.code, 'STALE_REVISION'); +}); + +test('completed job cache retains 100 entries and evicted commands cannot roll revisions back', async () => { + const s = setup(), first = dto(1); + await s.run(first); await flush(); + for (let revision = 2; revision <= 101; revision++) { await s.run(dto(revision)); await flush(); } + assert.equal((await s.run(first)).data.code, 'STALE_REVISION'); + assert.equal(s.context.bot_config.config_revision, 101); + assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 101); +}); diff --git a/tests/entity_functions.test.mjs b/tests/entity_functions.test.mjs new file mode 100644 index 0000000..7ac7321 --- /dev/null +++ b/tests/entity_functions.test.mjs @@ -0,0 +1,411 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const names = ['attack_cursor_entity', 'attack_nearest_entity', 'use_item_on_cursor_entity', + 'use_item_on_nearest_entity', 'feed_cursor_entity', 'feed_nearest_entity', + 'open_cursor_entity_container', 'open_nearest_entity_container', + 'null_main_hand_right_cursor_entity', 'null_main_hand_right_nearest_entity']; +const operations = Object.fromEntries(await Promise.all(names.map(async name => { + const {init} = await tsImport(`../bot/function/entity/${name}.ts`, import.meta.url); + return [name, () => new init()]; +}))); +const require = createRequire(import.meta.url); +const mf = createRequire(require.resolve('@wp2508/mineflayer')); +const {Vec3} = mf('vec3'); + +function fixture() { + const bot = new EventEmitter(); + bot.entity = {id: 1, position: new Vec3(0, 0, 0), height: 1.8, eyeHeight: 1.62, isValid: true}; + bot.entities = {1: bot.entity}; + bot._client = Object.assign(new EventEmitter(), {state: 'play'}); + bot.currentWindow = null; + bot.inventory = {slots: []}; + bot.heldItem = {name: 'wheat'}; + bot.supportFeature = () => false; + bot.looks = []; + bot.attacks = []; + bot.interactions = []; + bot.lookAt = async point => { bot.looks.push(point); }; + bot.attack = target => { bot.attacks.push(target); }; + bot.interactEntity = (target, options) => { bot.interactions.push({target, options}); }; + const target = {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 0.9, height: 1.4, isValid: true}; + bot.entities[2] = target; + bot.entityAtCursor = (...args) => { bot.cursorArgs = args; return target; }; + return {bot, target}; +} + +test('nearest attack matches names and selects by eye-to-bounding-box distance', async () => { + const {bot, target} = fixture(); + const other = {id: 3, name: 'player', username: 'COW_keeper', isValid: true, + position: new Vec3(2.2, 0, 0), width: 3, height: 2}; + bot.entities[3] = other; + assert.equal(await operations.attack_nearest_entity().run(bot, ['CoW', 1, false]), true); + assert.deepEqual(bot.attacks, [other]); + assert.equal(bot.looks.length, 0); + other.isValid = false; + assert.equal(await operations.attack_nearest_entity().run(bot, ['cow', 1, false]), false); + assert.equal(await operations.attack_nearest_entity().run(bot, ['cow', 3, false]), true); + assert.equal(bot.attacks.at(-1), target); + for (const args of [['', 3, false], ['cow', 0, false], ['cow', Infinity, false], ['cow'], ['cow', 3]]) + assert.equal(await operations.attack_nearest_entity().run(bot, args), false); +}); + +test('cursor attack forwards reach and block options and rejects stale or distant targets', async () => { + const {bot, target} = fixture(); + assert.equal(await operations.attack_cursor_entity().run(bot, [3, false]), true); + assert.deepEqual(bot.cursorArgs, [3, true, false]); + target.position.x = 20; + assert.equal(await operations.attack_cursor_entity().run(bot, [3, true]), false); + target.position.x = 2; + target.isValid = false; + assert.equal(await operations.attack_cursor_entity().run(bot, [3, false]), false); + assert.equal(bot.attacks.length, 1); +}); + +test('all entity functions aim at the center only when need_look is true', async () => { + for (const name of names) { + for (const needLook of [undefined, false, true]) { + const {bot, target} = fixture(); + if (name.startsWith('null_main_hand')) bot.heldItem = null; + const args = name.includes('cursor') ? [3, false] : ['cow', 3]; + if (name.startsWith('feed') || name.startsWith('use_item')) args.push(false); + if (name.includes('nearest')) args.push(false); + if (needLook !== undefined) args.push(needLook); + bot.activateEntity = () => { throw Error('automatic aim must not be used'); }; + if (name.startsWith('open')) bot.interactEntity = (entity, options) => { + assert.equal(entity, target); + assert.deepEqual(options, {hand: 0}); + bot.currentWindow = {id: 7}; + bot.emit('windowOpen', bot.currentWindow); + }; + const label = `${name}: ${needLook}`; + assert.equal(await operations[name]().run(bot, args), true, label); + assert.deepEqual(bot.looks, needLook ? [target.position.offset(0, target.height / 2, 0)] : [], label); + } + } +}); + +test('all entity functions revalidate after optional aiming before interaction', async () => { + for (const name of names) { + const {bot, target} = fixture(); + if (name.startsWith('null_main_hand')) bot.heldItem = null; + const args = name.includes('cursor') ? [3, false] : ['cow', 3]; + if (name.startsWith('feed') || name.startsWith('use_item')) args.push(false); + if (name.includes('nearest')) args.push(false); + args.push(true); + bot.lookAt = async () => { target.isValid = false; }; + assert.equal(await operations[name]().run(bot, args), false, name); + assert.deepEqual(bot.attacks, [], name); + assert.deepEqual(bot.interactions, [], name); + assert.equal(bot.listenerCount('windowOpen'), 0, name); + } +}); + +test('item interaction and feeding select hands and aim before interaction', async () => { + for (const name of ['use_item_on_cursor_entity', 'feed_cursor_entity', 'use_item_on_nearest_entity', 'feed_nearest_entity']) { + const {bot, target} = fixture(); + const args = name.includes('cursor') ? [3, false, false, true] : ['cow', 4.5, false, false, true]; + assert.equal(await operations[name]().run(bot, args), true); + assert.equal(bot.interactions[0].target, target); + assert.equal(bot.interactions[0].options.hand, 0); + assert.ok(bot.interactions[0].options.position instanceof Vec3); + assert.equal(bot.looks.length, 1); + args[2] = true; + assert.equal(await operations[name]().run(bot, args), false); + bot.inventory.slots[45] = {name: 'wheat'}; + assert.equal(await operations[name]().run(bot, args), true); + assert.equal(bot.interactions.at(-1).options.hand, 1); + bot.supportFeature = () => true; + assert.equal(await operations[name]().run(bot, args), false); + args[2] = false; + bot.heldItem = null; + assert.equal(await operations[name]().run(bot, args), false); + } +}); + +test('look completion rechecks entity validity, range and held items before side effects', async () => { + for (const change of [ + ({target}) => { target.isValid = false; }, + ({target}) => { target.position.x = 30; }, + ({bot}) => { delete bot.entities[2]; }, + ({bot}) => { bot.entity = {...bot.entity}; }, + ]) { + const f = fixture(); + f.bot.lookAt = async () => change(f); + assert.equal(await operations.attack_nearest_entity().run(f.bot, ['cow', 3, false, true]), false); + assert.equal(f.bot.attacks.length, 0); + } + const {bot} = fixture(); + bot.lookAt = async () => { bot.heldItem = null; }; + assert.equal(await operations.use_item_on_nearest_entity().run(bot, ['cow', 4.5, false, false, true]), false); + assert.equal(bot.interactions.length, 0); +}); + +test('entity container opening waits for windowOpen and cleans listeners', async () => { + for (const name of ['open_cursor_entity_container', 'open_nearest_entity_container']) { + const {bot} = fixture(); + const args = name.includes('cursor') ? [3, false] : ['cow', 4.5, false]; + bot.interactEntity = () => { + bot.currentWindow = {id: 7}; + bot.emit('windowOpen', bot.currentWindow); + }; + assert.equal(await operations[name]().run(bot, args), true); + assert.equal(bot.listenerCount('windowOpen'), 0); + assert.equal(bot.listenerCount('end'), 0); + assert.equal(await operations[name]().run(bot, args), false); + bot.currentWindow = null; + bot.interactEntity = () => { throw Error('rejected'); }; + assert.equal(await operations[name]().run(bot, args), false); + assert.equal(bot.listenerCount('windowOpen'), 0); + bot.interactEntity = () => { bot.emit('end', 'closed'); }; + assert.equal(await operations[name]().run(bot, args), false); + assert.equal(bot.listenerCount('windowOpen'), 0); + } +}); + +test('entity container opening times out and removes its listeners', async t => { + t.mock.timers.enable({apis: ['setTimeout']}); + const {bot} = fixture(); + bot.interactEntity = () => {}; + const pending = operations.open_cursor_entity_container().run(bot, [3, false]); + for (let i = 0; i < 20; i++) await Promise.resolve(); + t.mock.timers.tick(5000); + assert.equal(await pending, false); + assert.equal(bot.listenerCount('windowOpen'), 0); + assert.equal(bot.listenerCount('end'), 0); +}); + +test('entity functions reject malformed arguments and contain API failures', async () => { + for (const name of names) { + const {bot} = fixture(); + const cursor = name.includes('cursor'); + const item = name.startsWith('use_item') || name.startsWith('feed'); + const args = cursor ? [3, false] : ['cow', 3]; + if (item) args.push(false); + if (!cursor) args.push(false); + const fn = operations[name](); + for (const invalid of [null, [], ...[undefined, null, 1, 'true', {}].map(value => [...args, value]), + [...args, false, false], cursor ? [-1, false] : ['', 3]]) + assert.equal(await fn.run(bot, invalid), false, name); + if (!cursor) { + for (const invalid of [args.slice(0, -1), ...[undefined, null, 0, 1, 'true'].map(lock => [...args.slice(0, -1), lock])]) + assert.equal(await fn.run(bot, invalid), false, `${name}: mandatory boolean lock_target`); + } + bot.attack = () => { throw Error('failed'); }; + bot.interactEntity = () => { throw Error('failed'); }; + assert.equal(await fn.run(bot, args), false, name); + } +}); + +test('empty-main-hand interaction requires an empty hand before and after aiming', async () => { + for (const name of names.filter(name => name.startsWith('null_main_hand'))) { + const {bot, target} = fixture(); + const args = name.includes('cursor') ? [3, false, true] : ['cow', 3, false, true]; + const fn = operations[name](); + assert.equal(await fn.run(bot, args), false); + assert.equal(bot.looks.length, 0); + assert.equal(bot.interactions.length, 0); + bot.heldItem = null; + bot.inventory.slots[45] = {name: 'shield'}; + assert.equal(await fn.run(bot, args), true); + assert.deepEqual(bot.interactions, [{target, options: {hand: 0}}]); + assert.equal(bot.looks.length, 1); + bot.lookAt = async () => { bot.heldItem = {name: 'wheat'}; }; + assert.equal(await fn.run(bot, args), false); + assert.equal(bot.interactions.length, 1); + bot.heldItem = null; + bot.lookAt = async () => { target.isValid = false; }; + assert.equal(await fn.run(bot, args), false); + assert.equal(bot.interactions.length, 1); + } +}); + +test('all nearest functions match partial type IDs with an optional Minecraft namespace', async () => { + for (const name of names.filter(name => name.includes('nearest'))) { + for (const query of ['co', 'MINECRAFT:CO', 'minecraft:cow']) { + const {bot, target} = fixture(); + if (name.startsWith('null_main_hand')) bot.heldItem = null; + if (name.startsWith('open')) bot.interactEntity = entity => { + assert.equal(entity, target); + bot.currentWindow = {id: 7}; + bot.emit('windowOpen', bot.currentWindow); + }; + const args = [query, 3]; + if (name.startsWith('feed') || name.startsWith('use_item')) args.push(false); + args.push(false); + assert.equal(await operations[name]().run(bot, args), true, `${name}: ${query}`); + } + } + const {bot, target} = fixture(); + target.name = 'minecraft:zombie'; + assert.equal(await operations.attack_nearest_entity().run(bot, ['zom', 3, false]), true); + assert.equal(await operations.attack_nearest_entity().run(bot, ['minecraft:', 3, false]), false); +}); + +const nearestNames = names.filter(name => name.includes('nearest')); + +function nearestArgs(name, {query = 'cow', reach = 5, offHand = false, lock = true, needLook} = {}) { + const args = name.startsWith('feed') || name.startsWith('use_item') + ? [query, reach, offHand, lock] : [query, reach, lock]; + if (needLook !== undefined) args.push(needLook); + return args; +} + +function addCloser(bot) { + const closer = {id: 3, name: 'cow', position: new Vec3(1, 0, 0), width: 0.9, height: 1.4, isValid: true}; + bot.entities[closer.id] = closer; + return closer; +} + +async function runNearest(name, bot, options) { + bot.heldItem = name.startsWith('null_main_hand') ? null : {name: 'wheat'}; + bot.inventory.slots[45] = {name: 'wheat'}; + bot.currentWindow = null; + const acted = []; + bot.attack = target => { acted.push(target); }; + bot.interactEntity = target => { + acted.push(target); + if (name.startsWith('open')) { + bot.currentWindow = {id: 7}; + bot.emit('windowOpen', bot.currentWindow); + } + }; + // Recreating init on every call verifies that lock state belongs to the bot and invocation key. + const result = await operations[name]().run(bot, nearestArgs(name, options)); + return {result, acted}; +} + +test('all nearest locks survive init recreation and ignore a newly closer entity', async () => { + for (const name of nearestNames) { + const {bot, target} = fixture(); + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name); + addCloser(bot); + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name); + } +}); + +test('all nearest locks share omitted and explicit false need_look keys', async () => { + for (const name of nearestNames) { + for (const firstLook of [undefined, false]) { + const {bot, target} = fixture(); + assert.deepEqual(await runNearest(name, bot, {needLook: firstLook}), {result: true, acted: [target]}, name); + addCloser(bot); + assert.deepEqual(await runNearest(name, bot, {needLook: firstLook === false ? undefined : false}), + {result: true, acted: [target]}, name); + assert.equal(bot.looks.length, 0, name); + } + } +}); + +test('all nearest locks reacquire after target loss, invalidation or same-id object replacement', async () => { + for (const name of nearestNames) { + for (const reason of ['lost', 'invalid', 'replaced']) { + const {bot, target} = fixture(); + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, `${name}: ${reason}`); + const closer = addCloser(bot); + if (reason === 'lost') delete bot.entities[target.id]; + if (reason === 'invalid') target.isValid = false; + if (reason === 'replaced') bot.entities[target.id] = {...target}; + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [closer]}, `${name}: ${reason}`); + target.position.x = 0.5; + if (reason === 'lost') bot.entities[target.id] = target; + if (reason === 'invalid') target.isValid = true; + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [closer]}, `${name}: reacquired lock persists`); + } + } +}); + +test('all nearest locks return false out of range and retain the original target', async () => { + for (const name of nearestNames) { + const {bot, target} = fixture(); + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name); + addCloser(bot); + target.position.x = 20; + const lookCount = bot.looks.length; + assert.deepEqual(await runNearest(name, bot), {result: false, acted: []}, name); + assert.equal(bot.looks.length, lookCount, `${name}: no aiming outside reach`); + target.position.x = 2; + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name); + } +}); + +test('all nearest functions with lock_target false select afresh without replacing a true lock', async () => { + for (const name of nearestNames) { + const {bot, target} = fixture(); + assert.deepEqual(await runNearest(name, bot, {lock: false}), {result: true, acted: [target]}, name); + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name); + const closer = addCloser(bot); + assert.deepEqual(await runNearest(name, bot, {lock: false}), {result: true, acted: [closer]}, name); + closer.position.x = 4; + assert.deepEqual(await runNearest(name, bot, {lock: false}), {result: true, acted: [target]}, name); + closer.position.x = 1; + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name); + } +}); + +test('all nearest locks isolate exact query, reach, off_hand and need_look argument values', async () => { + for (const name of nearestNames) { + const variations = [{query: 'COW'}, {query: ' cow '}, {query: 'minecraft:cow'}, {query: 'co'}, {reach: 6}, {needLook: true}]; + if (name.startsWith('feed') || name.startsWith('use_item')) variations.push({offHand: true}); + for (const variation of variations) { + const {bot, target} = fixture(); + const label = `${name}: ${JSON.stringify(variation)}`; + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, label); + const closer = addCloser(bot); + assert.deepEqual(await runNearest(name, bot, variation), {result: true, acted: [closer]}, label); + assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, label); + closer.position.x = 4; + assert.deepEqual(await runNearest(name, bot, variation), {result: true, acted: [closer]}, label); + } + } +}); + +test('nearest locks isolate function names, including feed and use_item sharing an operation', async () => { + for (const first of nearestNames) { + for (const second of nearestNames.filter(name => name !== first)) { + const {bot, target} = fixture(); + const label = `${first} / ${second}`; + assert.deepEqual(await runNearest(first, bot), {result: true, acted: [target]}, label); + const closer = addCloser(bot); + assert.deepEqual(await runNearest(second, bot), {result: true, acted: [closer]}, label); + assert.deepEqual(await runNearest(first, bot), {result: true, acted: [target]}, label); + closer.position.x = 4; + assert.deepEqual(await runNearest(second, bot), {result: true, acted: [closer]}, label); + } + } +}); + +test('all nearest locks isolate bots with the same entity IDs and exact arguments', async () => { + for (const name of nearestNames) { + const first = fixture(); + const second = fixture(); + assert.deepEqual(await runNearest(name, first.bot), {result: true, acted: [first.target]}, name); + addCloser(first.bot); + assert.deepEqual(await runNearest(name, second.bot), {result: true, acted: [second.target]}, name); + addCloser(second.bot); + assert.deepEqual(await runNearest(name, first.bot), {result: true, acted: [first.target]}, name); + assert.deepEqual(await runNearest(name, second.bot), {result: true, acted: [second.target]}, name); + } +}); + +test('dynamic loader publishes all entity functions with positional metadata', async () => { + const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url); + const category = (await get_function_list()).find(item => item.path === 'entity'); + assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort()); + for (const template of category.function_list) { + const cursor = template.name.includes('cursor'); + const item = template.name.startsWith('feed') || template.name.startsWith('use_item'); + assert.deepEqual(template.args.map(arg => arg.type), [...(cursor ? ['number', 'boolean'] : ['string', 'number']), + ...(item ? ['boolean'] : []), ...(cursor ? [] : ['boolean']), 'boolean']); + if (!cursor) { + assert.equal(template.args.at(-2).name, 'lock_target'); + assert.equal(Object.hasOwn(template.args.at(-2), 'default'), false); + } + assert.equal(template.args.at(-1).name, 'need_look'); + assert.equal(template.args.at(-1).default, false); + assert.equal(template.args.at(-1).i18n.lang, 'zh-CN'); + } +}); diff --git a/tests/fixtures/forward_vectors.json b/tests/fixtures/forward_vectors.json new file mode 100644 index 0000000..fe5de3b --- /dev/null +++ b/tests/fixtures/forward_vectors.json @@ -0,0 +1,12 @@ +{ + "source": "forward_server/tests/token.test.ts:57 AES-256-GCM fixed wire vector", + "token": "AAECAwQFBgcICQoLKReCzFKlEY6g42nAQLV9_x0ByGStIbnU7jVnJBA372eKB2zodLxLE9mBUS1agWpMmTc7swZYqNfSWy53I7FUGk6-ik0it6dgU_Sr5B4xex5F89Dl89k2hg2wkenWZpeIksBqjx2kPX2vj1o05iSueFxOXLopUmDFt7SWUFLzsQUdE5Q1nyTH7cWi71k5S5Fmc-Fo9qetCUp3QDyMPaD5Bn4sTrmUoSztp1AHREowEAMFMNAhjQ5Y5-aVDg08biMmw9X2KbBMukqomk5t-cHk0-l6r2XudPdPxH6B4YFhfE8iqIPnekUXz-GyCwuOODvdPajUKQs48s13OCVyhW5WzvxsoimN1KrM0ac0S1ybB9aRvkXSbkDHuEfy9Q-NCcddJacIKjIpFqeIrRV0EJAdCaMirNPIer_5F4YvmJB9H5UKNGSg4mZ-DYkvmXsvYAleGpSJzIpwjWG_pXb8dbjltryrQQ5HbXSIvs_8GBVGZdROTmwXCOrI_ZS8azkFA29zrHRBHiz-H3Rs0bv0GUgfZPoz_eJWxyA", + "scope": { + "forward_id": 0, + "forward_session_id": "a89acd9a-0ab3-44ac-812e-e221a5b915e0", + "node_session_id": "mZAb09_-SocketIOmainID", + "node_id": 1, + "bot_id": 2 + }, + "request_id": "45264c09-7529-478f-b3ef-6a84bec8c1a7" +} diff --git a/tests/forward_integration.test.mjs b/tests/forward_integration.test.mjs new file mode 100644 index 0000000..b580b62 --- /dev/null +++ b/tests/forward_integration.test.mjs @@ -0,0 +1,720 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {EventEmitter} from 'node:events'; +import {readFile, mkdtemp, mkdir, writeFile, rm, unlink} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {setTimeout as delay} from 'node:timers/promises'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; +import {io} from 'socket.io-client'; +import {tsImport} from 'tsx/esm/api'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const vectors = JSON.parse(await readFile(new URL('./fixtures/forward_vectors.json', import.meta.url), 'utf8')); +const compiled = await build({ + stdin: {contents: `export * from './bot/forward/manager.ts'; + export * from './bot/forward/stream.ts'; export * from './bot/forward/files.ts'; + export * from './utils/forward_protocol.ts';`, + resolveDir: root, loader: 'ts'}, + bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, metafile: true, + plugins: [{name: 'isolated-default-bot-registry', setup(build) { + build.onResolve({filter: /^\.\.\/instance\/index\.ts$/}, () => ({path: 'registry', namespace: 'test'})); + build.onLoad({filter: /.*/, namespace: 'test'}, () => ({loader: 'js', contents: + 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};'})); + }}], +}); +const module = {exports: {}}; +new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(createRequire(import.meta.url), module, module.exports); +const {NodeForwardManager, BotStream, BotFileService, validate_command, validate_join, validate_rpc, success_data, error_ack} = module.exports; +const success = data => ({status: 'success', data}); +const scope = () => ({forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'main-socket-session', node_id: 2, bot_id: 3}); +const command = (s = vectors.scope, overrides = {}) => ({...s, url: 'http://127.0.0.1:1', path: '/relay/socket.io', + namespace: '/forward', token: vectors.token, expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000, ...overrides}); +const request = (method = 'bot.snapshot', payload = {}, request_id = randomUUID()) => ({request_id, method, payload}); +const node_request = (s, overrides = {}) => ({...request(), correlation_id: randomUUID(), + forward_session_id: s.forward_session_id, deadline_at: Date.now() + 1000, ...overrides}); +function code(ack, expected) { + assert.equal(ack.status, 'error'); + assert.deepEqual(Object.keys(ack.data).sort(), ['code', 'message', 'retryable']); + assert.equal(ack.data.code, expected); + assert.equal(typeof ack.data.message, 'string'); + assert.equal(typeof ack.data.retryable, 'boolean'); +} +const rejects = (action, expected = 'INVALID_REQUEST') => assert.throws(action, error => error.code === expected); +function received(socket, event, predicate = () => true) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { socket.off(event, listener); reject(new Error(`Missing ${event}`)); }, 2500); + const listener = value => { + if (!predicate(value)) return; + clearTimeout(timer); socket.off(event, listener); resolve(value); + }; + socket.on(event, listener); + }); +} +const ack = (socket, name, payload) => socket.timeout(2500).emitWithAck(name, payload); + +class FakeSocket extends EventEmitter { + connected = false; + packets = []; + io = {engine: {writeBuffer: []}}; + volatile = {emit: (event, value) => this.packets.push([event, value])}; + constructor(cmd, responder) { super(); this.cmd = cmd; this.responder = responder; } + connect() { queueMicrotask(() => {this.connected = true; super.emit('connect');}); } + disconnect() { if (this.connected) {this.connected = false; super.emit('disconnect', 'test');} } + timeout() { return {emit: (event, payload, callback) => { + this.packets.push([event, payload]); + const data = event === 'forward_node_ready' ? {node_ready: true} : { + ...Object.fromEntries(Object.keys(vectors.scope).map(key => [key, this.cmd[key]])), + role: 'node', node_ready: event === 'reauth', expires_at: this.cmd.expires_at, + }; + if (this.responder) this.responder(event, success(data), callback); + else queueMicrotask(() => callback(null, success(data))); + }}; } + rpc(raw) { return new Promise(resolve => super.emit('relay_request', raw, resolve)); } +} +function fake_manager(t, cmd, responder, stream_factory) { + const sockets = []; + const manager = new NodeForwardManager(cmd.node_id, cmd.node_session_id, { + socket_factory: (_url, options) => { + const socket = new FakeSocket(cmd, responder); socket.options = options; sockets.push(socket); return socket; + }, stream_factory, + }); + t.after(() => manager.stop()); + return {manager, sockets}; +} + +test('independent Node bundle has no relay or token issuer imports; fixed token stays opaque and byte-identical', async t => { + assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server'))); + assert.doesNotMatch(compiled.outputFiles[0].text, /createCipheriv|createDecipheriv|encode_token|decode_token/); + const cmd = command(); + const f = fake_manager(t, cmd); + assert.equal(validate_command(cmd).token, vectors.token); + const ready = await f.manager.ensure(cmd); + assert.deepEqual(ready, success({...vectors.scope, node_ready: true, lease_expires_at: cmd.expires_at})); + assert.equal(f.sockets[0].options.auth.token, vectors.token); + assert.equal(f.sockets[0].options.reconnection, false); + assert.deepEqual(f.sockets[0].packets, [ + ['join_room', {token: vectors.token}], ['forward_node_ready', {forward_session_id: cmd.forward_session_id}], + ]); + // The copied token has Web claims and fixed historical times. Node never interprets them. + const opaque = command({...vectors.scope, forward_session_id: randomUUID()}, {token: 'opaque-not-a-token'}); + const second = fake_manager(t, opaque); + assert.equal((await second.manager.ensure(opaque)).status, 'success'); + assert.equal(second.sockets[0].packets[0][1].token, opaque.token); +}); + +test('local command validator enforces exact wire DTO, scope IDs, URL and numeric bounds', () => { + const valid = command(); + for (const key of Object.keys(valid)) { + const missing = {...valid}; delete missing[key]; rejects(() => validate_command(missing)); + } + for (const patch of [{extra: 'SECRET'}, {node_id: 0}, {bot_id: '2'}, {forward_id: -1}, + {forward_session_id: 'session'}, {node_session_id: ' '}, {node_session_id: '\u754c'.repeat(86)}, + {token: ''}, {token: 'x'.repeat(8193)}, {deadline_at: Infinity}, {expires_at: 1.5}, + {url: 'file:///tmp/relay'}, {url: 'http://user:password@localhost'}, {url: 'http://localhost/path'}, + {url: 'http://localhost?token=SECRET'}, {url: 'http://localhost#SECRET'}, {path: 'socket.io'}, {namespace: '/bad?x'}]) { + rejects(() => validate_command({...valid, ...patch})); + } + assert.equal(validate_command({...valid, forward_id: 0, node_session_id: 'x'.repeat(256)}).forward_id, 0); + assert.equal(validate_command({...valid, forward_session_id: '11111111-1111-1111-1111-111111111111'}).forward_session_id, + '11111111-1111-1111-1111-111111111111'); +}); + +test('local Node RPC validator allows only exact public method payloads and relay correlation metadata', () => { + const valid = node_request(vectors.scope, {request_id: vectors.request_id}); + for (const [method, payload] of [['bot.snapshot', {}], ['bot.logs', {limit: 200, stream_id: randomUUID(), after_seq: 0}], + ['get_log_history', {}], ['get_context_info', {}], ['get_data_file_list', {}], + ['get_file_data', {id: randomUUID()}], ['get_file_data', {id: randomUUID(), offset: 0, length: 262144}], + ['tab_complete', {text: '/he'}], ['tab_complete', {text: ''}], + ['bot.chat', {message: 'hello'}], ['execute_function', {function: 'inventory', name: 'left_click_slot', args: [9]}]]) assert.equal(validate_rpc({...valid, method, payload}).method, method); + for (const payload of [{}, {function: '../inventory', name: 'left_click_slot', args: []}, + {function: 'inventory', name: '', args: []}, {function: 'inventory', name: 'x', args: {}}, + {function: 'inventory', name: 'x', args: [], extra: true}]) + rejects(() => validate_rpc({...valid, method: 'execute_function', payload})); + for (const payload of [{}, {text: 1}, {text: 'x'.repeat(257)}, {text: '/he\n'}, {text: '/he', extra: true}]) + rejects(() => validate_rpc({...valid, method: 'tab_complete', payload})); + rejects(() => validate_rpc({...valid, method: 'get_log_history', payload: {limit: 21}})); + rejects(() => validate_rpc({...valid, method: 'get_context_info', payload: {name: 'health'}})); + rejects(() => validate_rpc({...valid, method: 'get_data_file_list', payload: {path: '../'}})); + for (const payload of [{id: '../secret'}, {id: randomUUID(), path: '/tmp'}, + {id: randomUUID(), offset: -1}, {id: randomUUID(), offset: 0.5}, + {id: randomUUID(), length: 0}, {id: randomUUID(), length: 262145}]) + rejects(() => validate_rpc({...valid, method: 'get_file_data', payload})); + for (const key of Object.keys(valid)) { + const missing = {...valid}; delete missing[key]; rejects(() => validate_rpc(missing)); + } + for (const patch of [{token: 'SECRET'}, {request_id: 'bad'}, {correlation_id: 'bad'}, {deadline_at: NaN}, + {method: 'bot.delete'}, {method: 'bot.subscribe', payload: {events: ['log']}}, {payload: {config: 'SECRET'}}, + {method: 'bot.logs', payload: {limit: 0}}, {method: 'bot.logs', payload: {limit: 201}}, + {method: 'bot.logs', payload: {limit: 1, after_seq: 0}}, + {method: 'bot.chat', payload: {message: 'hello', password: 'SECRET'}}, + ...['', ' ', 'x'.repeat(257), 'hello\nworld', 'hello\rworld', 'hello\0world'].map(message => ({method: 'bot.chat', payload: {message}}))]) { + rejects(() => validate_rpc({...valid, ...patch})); + } +}); + +test('local join/ACK validation binds every scope field, lease, role and readiness without exposing errors', () => { + const cmd = command(); + const data = {...vectors.scope, role: 'node', node_ready: false, expires_at: cmd.expires_at}; + assert.deepEqual(validate_join(success(data), cmd, true), data); + for (const patch of [{forward_id: 9}, {forward_session_id: randomUUID()}, {node_session_id: 'old'}, {node_id: 9}, + {bot_id: 9}, {role: 'web'}, {node_ready: true}, {expires_at: cmd.expires_at + 1}, {secret: 'SECRET'}]) { + rejects(() => validate_join(success({...data, ...patch}), cmd, true), 'SESSION_STALE'); + } + for (const value of [null, {}, {status: 'success'}, {status: 'success', data, extra: true}, + {status: 'error', data: {code: 'SECRET', message: 'SECRET', retryable: false}}]) { + rejects(() => success_data(value), 'RELAY_UNAVAILABLE'); + } + rejects(() => success_data(error_ack('TIMEOUT')), 'TIMEOUT'); +}); + +test('manager rejects stale scopes and expired commands before creating a transport', async t => { + const cmd = command(); const f = fake_manager(t, cmd); + for (const [patch, expected] of [[{node_session_id: 'old'}, 'SESSION_STALE'], [{node_id: 99}, 'FORBIDDEN'], + [{deadline_at: Date.now() - 1}, 'TIMEOUT'], [{expires_at: Date.now() - 1}, 'TOKEN_EXPIRED']]) { + code(await f.manager.ensure({...cmd, ...patch}), expected); + } + assert.equal(f.sockets.length, 0); + f.manager.stop(); code(await f.manager.ensure(cmd), 'SESSION_STALE'); +}); + +test('manager deadline rejects late join ACK and prevents readiness or later RPC side effects', async t => { + const cmd = command(vectors.scope, {deadline_at: Date.now() + 40}); + let late; + const f = fake_manager(t, cmd, (_event, response, callback) => {late = () => callback(null, response);}); + code(await f.manager.ensure(cmd), 'TIMEOUT'); + late(); await delay(10); + assert.equal(f.sockets[0].connected, false); + assert.equal(f.sockets[0].packets.some(([name]) => name === 'forward_node_ready'), false); + assert.equal(f.sockets[0].listenerCount('relay_request'), 0); +}); + +test('manager independently enforces RPC deadlines, session binding, correlation replay and safe error ACKs', async t => { + const cmd = command(); let calls = 0; + const stream = {subscribe: () => () => {}, resync() {}, dispose() {}, snapshot: () => ({seq: ++calls}), + chat() {calls++; throw new Error('SECRET password/stack');}}; + const f = fake_manager(t, cmd, undefined, () => stream); + assert.equal((await f.manager.ensure(cmd)).status, 'success'); + const socket = f.sockets[0]; const rpc = node_request(cmd); + code(await socket.rpc({...rpc, deadline_at: Date.now() - 1}), 'TIMEOUT'); + code(await socket.rpc({...rpc, forward_session_id: randomUUID()}), 'SESSION_STALE'); + code(await socket.rpc({...rpc, method: 'bot.chat', payload: {message: 'hello', token: 'SECRET'}}), 'INVALID_REQUEST'); + assert.equal(calls, 0); + assert.deepEqual(await socket.rpc(rpc), success({seq: 1})); + code(await socket.rpc(rpc), 'CANCELLED'); + const failed = await socket.rpc(node_request(cmd, {method: 'bot.chat', payload: {message: 'hello'}})); + code(failed, 'INTERNAL_ERROR'); assert.equal(JSON.stringify(failed).includes('SECRET'), false); + assert.equal(calls, 2); +}); + +// The integration layer alone loads relay code. Issuing credentials is test-only. +test('disk history RPC discards responses after deadline or transport replacement', async t => { + const cmd = command(); + let release; + const stream = {subscribe: () => () => {}, resync() {}, dispose() {}, + get_log_history: () => new Promise(resolve => {release = resolve;})}; + const f = fake_manager(t, cmd, undefined, () => stream); + assert.equal((await f.manager.ensure(cmd)).status, 'success'); + const socket = f.sockets[0]; + const pending = socket.rpc(node_request(cmd, {method: 'get_log_history', payload: {}, deadline_at: Date.now() + 15})); + await delay(25); + release({records: []}); + code(await pending, 'TIMEOUT'); + const stale = socket.rpc(node_request(cmd, {method: 'get_log_history', payload: {}})); + socket.disconnect(); + release({records: []}); + code(await stale, 'SESSION_STALE'); +}); +async function real_fixture(t, limits = {}) { + const [{start_forward_server}, {encode_token}] = await Promise.all([ + tsImport('../forward_server/index.ts', import.meta.url), + tsImport('../forward_server/utils/token_utils.ts', import.meta.url), + ]); + const key = 'node-forward-integration-test-secret-only'; + const control = new EventEmitter(); const statuses = []; + control.on('forward_session_status', value => statuses.push(value)); + const handle = await start_forward_server({forward_id: 1, key, host: '127.0.0.1', port: 0, + path: '/node-test/socket_io', namespace: '/node-test', allowed_origins: [], allow_no_origin: true, control, + limits: {rpc_timeout_ms: 250, unused_timeout_ms: 10_000, idle_timeout_ms: 10_000, clock_skew_ms: 0, ...limits}}); + const url = `http://127.0.0.1:${handle.http_server.address().port}`; + const clients = []; const node_sockets = []; const managers = []; const sources = new Map(); const streams = new Map(); + const packets = []; + handle.io.of(handle.namespace).on('connection', socket => socket.onAny((event, value) => packets.push({id: socket.id, event, value}))); + t.after(async () => {for (const manager of managers) manager.stop(); for (const socket of clients) socket.disconnect(); await handle.stop();}); + const control_ack = (event, value) => new Promise(resolve => control.emit(event, value, resolve)); + const permissions = ['bot.snapshot', 'bot.logs', 'bot.subscribe', 'bot.unsubscribe', 'bot.chat', 'execute_function', 'bot.files']; + function token(s, role, expires_at, grant_id, granted = permissions) { + const now = Date.now(); + return encode_token(key, {...s, v: 1, role, permissions: granted, issued_at: now, + join_before: Math.min(now + 1000, expires_at), expires_at, jti: randomUUID(), + ...(role === 'web' ? {grant_id, user_id: 4} : {})}); + } + function source(bot_id) { + if (!sources.has(bot_id)) { + let current; const listeners = new Set(); + sources.set(bot_id, {get: () => current, observe(_id, listener) {listeners.add(listener); listener(current); return () => listeners.delete(listener);}, + set(value) {current = value; for (const listener of listeners) listener(value);}, get count() {return listeners.size;}}); + } + return sources.get(bot_id); + } + function manager(s, file_factory) { + const result = new NodeForwardManager(s.node_id, s.node_session_id, { + socket_factory: (...args) => {const socket = io(...args); node_sockets.push(socket); return socket;}, + stream_factory: bot_id => {const stream = new BotStream(bot_id, {source: source(bot_id), capacity: 30}); streams.set(bot_id, stream); return stream;}, + file_factory, + }); managers.push(result); return result; + } + async function open(s, expires_at = Date.now() + 20_000) { + assert.equal((await control_ack('forward_open_session', {...s, lease_expires_at: expires_at})).status, 'success'); + return expires_at; + } + const cmd = (s, expires_at = Date.now() + 10_000) => command(s, {url, path: handle.path, namespace: handle.namespace, + expires_at, token: token(s, 'node', expires_at), deadline_at: Date.now() + 2000}); + async function web(s, granted = permissions, grant_id = randomUUID(), expires_at = Date.now() + 10_000) { + assert.equal((await control_ack('forward_authorize_grant', {forward_session_id: s.forward_session_id, + grant_id, user_id: 4, permissions: granted, expires_at})).status, 'success'); + const socket = io(`${url}${handle.namespace}`, {path: handle.path, autoConnect: false, reconnection: false, forceNew: true, transports: ['websocket']}); + clients.push(socket); const connected = received(socket, 'connect'); socket.connect(); await connected; + const joined = await ack(socket, 'join_room', {token: token(s, 'web', expires_at, grant_id, granted)}); + assert.equal(joined.status, 'success'); + return {socket, grant_id, joined}; + } + return {handle, control_ack, statuses, permissions, token, source, streams, manager, open, cmd, web, packets, node_sockets}; +} +function online_context() { + const sent = []; + return Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat: message => sent.push(message)}, + sent, config: {password: 'SECRET'}, logger: {get_recent_log() {throw new Error('Disk access forbidden');}}}); +} + +test('file tree and binary downloads use scoped IDs without requiring an online Bot', async t => { + const directory = await mkdtemp(path.join(tmpdir(), 'forward-files-')); + t.after(() => rm(directory, {recursive: true, force: true})); + await mkdir(path.join(directory, 'log')); + await mkdir(path.join(directory, 'empty')); + const bytes = Buffer.alloc(600_123); + for (let index = 0; index < bytes.length; index++) bytes[index] = index % 256; + await writeFile(path.join(directory, 'binary.dat'), bytes); + await writeFile(path.join(directory, 'log', 'sample.log'), 'a log\n'); + const f = await real_fixture(t, {rpc_timeout_ms: 1500}); + const s = scope(); await f.open(s); + const manager = f.manager(s, bot_id => new BotFileService(bot_id, {root: directory})); + assert.equal((await manager.ensure(f.cmd(s))).status, 'success'); + const web = await f.web(s, ['bot.files']); + const denied = await f.web(s, ['bot.snapshot']); + const listing = success_data(await ack(web.socket, 'relay_request', request('get_data_file_list'))); + assert.equal(listing.partial, false); + assert.deepEqual(listing.children.map(entry => entry.name), ['binary.dat', 'empty', 'log']); + assert.equal(listing.children[1].type, 'directory'); + assert.deepEqual(listing.children[1].children, []); + assert.equal(JSON.stringify(listing).includes(directory), false); + const file = listing.children[0]; + assert.equal(file.size, bytes.length); + assert.match(file.id, /^[0-9a-f-]{36}$/); + const chunks = []; + let offset = 0; + while (true) { + const part = success_data(await ack(web.socket, 'relay_request', request('get_file_data', {id: file.id, offset}))); + assert.equal(part.offset, offset); + assert.equal(part.total_bytes, bytes.length); + assert.equal(part.encoding, 'base64'); + const chunk = Buffer.from(part.data, 'base64'); + assert.equal(chunk.length, part.next_offset - offset); + assert.ok(chunk.length <= 262144); + chunks.push(chunk); offset = part.next_offset; + if (part.eof) break; + } + assert.deepEqual(Buffer.concat(chunks), bytes); + code(await ack(denied.socket, 'relay_request', request('get_data_file_list')), 'FORBIDDEN'); + code(await ack(denied.socket, 'relay_request', request('get_file_data', {id: file.id})), 'FORBIDDEN'); + code(await ack(web.socket, 'relay_request', request('get_file_data', {id: file.id, length: 262145})), 'INVALID_REQUEST'); + code(await ack(web.socket, 'relay_request', request('get_file_data', {id: '../binary.dat'})), 'INVALID_REQUEST'); + const another_scope = {...s, bot_id: s.bot_id + 1, forward_session_id: randomUUID()}; + await f.open(another_scope); + assert.equal((await manager.ensure(f.cmd(another_scope))).status, 'success'); + const another = await f.web(another_scope, ['bot.files']); + code(await ack(another.socket, 'relay_request', request('get_file_data', {id: file.id})), 'INVALID_REQUEST'); + // Reusing a request ID must re-read disk instead of replaying a cached file body. + const repeated = request('get_file_data', {id: file.id, length: 10}); + assert.equal(success_data(await ack(web.socket, 'relay_request', repeated)).data, bytes.subarray(0, 10).toString('base64')); + await unlink(path.join(directory, 'binary.dat')); + const removed = await ack(web.socket, 'relay_request', repeated); + assert.equal(removed.status, 'error'); + assert.ok(['RESYNC_REQUIRED', 'INVALID_REQUEST'].includes(removed.data.code)); + const expired = received(web.socket, 'relay_status', value => !value.node_ready); + manager.stop(); await expired; + code(await ack(web.socket, 'relay_request', request('get_data_file_list')), 'NODE_OFFLINE'); +}); + +test('context info RPC and updates travel through actual relay with online and permission checks', async t => { + const f = await real_fixture(t); const s = scope(); await f.open(s); + const bot = online_context(); + let records = [{name: 'health', i18n: {lang: 'en', name: 'Health', description: ''}, value: 20}]; + bot.info_manager = {get_all_info: () => records}; + f.source(s.bot_id).set(bot); + const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success'); + const web = await f.web(s, ['bot.snapshot', 'bot.logs']); + const denied = await f.web(s, ['bot.logs']); + const snapshot_only = await f.web(s, ['bot.snapshot']); + const no_info = []; denied.socket.on('info_update', value => no_info.push(value)); + const rpc = request('get_context_info'); + assert.deepEqual(success_data(await ack(web.socket, 'relay_request', rpc)), records); + assert.deepEqual(success_data(await ack(snapshot_only.socket, 'relay_request', request('get_context_info'))), records); + code(await ack(denied.socket, 'relay_request', request('get_context_info')), 'FORBIDDEN'); + code(await ack(web.socket, 'relay_request', request('get_context_info', {name: 'health'})), 'INVALID_REQUEST'); + const before = received(web.socket, 'bot_log'); + bot.emit('log', [{message: 'before info', font_color: '#ffffff', font_style: ['normal']}]); + const first_log = await before; + const pushed = received(web.socket, 'info_update'); + const snapshot_pushed = received(snapshot_only.socket, 'info_update'); + records[0].value = 18; + bot.emit('info_update', {name: 'health', value: 18}); + const update = await pushed; + assert.deepEqual((await snapshot_pushed).data, {name: 'health', value: 18}); + assert.deepEqual(Object.keys(update).sort(), ['bot_id', 'context_generation', 'data', 'emitted_at', 'forward_session_id', 'seq', 'stream_id']); + assert.deepEqual(update.data, {name: 'health', value: 18}); + assert.equal(update.bot_id, s.bot_id); + assert.equal(update.forward_session_id, s.forward_session_id); + assert.equal(update.stream_id, first_log.stream_id); + assert.ok(update.seq > first_log.seq); + const after = received(web.socket, 'bot_log'); + bot.emit('log', [{message: 'after info', font_color: '#ffffff', font_style: ['normal']}]); + assert.ok((await after).seq > update.seq); + // A completed request ID must re-read the Node rather than replay a stale success. + bot.bot_status = 'offline'; + code(await ack(web.socket, 'relay_request', rpc), 'BOT_NOT_ONLINE'); + const updates_before = f.packets.filter(packet => packet.event === 'info_update').length; + bot.emit('info_update', {name: 'health', value: 0}); + await ack(web.socket, 'relay_request', request()); + assert.equal(f.packets.filter(packet => packet.event === 'info_update').length, updates_before); + assert.equal(no_info.length, 0); + const stale = bot.listeners('info_update')[0]; + f.source(s.bot_id).set(undefined); + code(await ack(web.socket, 'relay_request', rpc), 'NO_CONTEXT'); + stale({name: 'health', value: 1}); + const replacement = online_context(); + replacement.info_manager = {get_all_info: () => [{...records[0], value: 15}]}; + f.source(s.bot_id).set(replacement); + assert.equal(success_data(await ack(web.socket, 'relay_request', rpc))[0].value, 15); + assert.equal(bot.listenerCount('info_update'), 0); + records = [{...records[0], value: NaN}]; + replacement.info_manager = {get_all_info: () => records}; + code(await ack(web.socket, 'relay_request', request('get_context_info')), 'INTERNAL_ERROR'); + manager.stop(); + assert.equal(replacement.listenerCount('info_update'), 0); +}); + +test('complex inventory from the real module survives Node query and push through the relay', async t => { + const {init: InventoryInfo} = await tsImport('../bot/info/bot/inventory.ts', import.meta.url); + const mf = createRequire(createRequire(import.meta.url).resolve('@wp2508/mineflayer')); + const registry = mf('prismarine-registry')('1.21.4'); + const windows = mf('prismarine-windows')(registry); + const instance = new EventEmitter(); + instance._client = new EventEmitter(); + instance.inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory'); + instance.currentWindow = windows.createWindow(1, 'minecraft:generic_9x6', 'Chest'); + instance.quickBarSlot = 0; + let nbt = {type: 'string', value: 'deep content'}; + for (let i = 0; i < 15; i++) nbt = {type: 'compound', value: {nested: nbt}}; + instance.currentWindow.slots = Array.from({length: 90}, () => ({ + type: 1, name: 'stone', displayName: 'Stone', count: 64, metadata: 0, nbt, + components: [ + {type: 'custom_data', data: {description: 'x'.repeat(4096)}}, + {type: 'custom_name', data: {type: 'compound', value: {text: {type: 'string', value: '菜单'}, color: {type: 'string', value: 'gold'}}}}, + {type: 'lore', data: [{type: 'string', value: '说明'}]}, + ], enchants: [], + })); + const inventory = new InventoryInfo(instance); + t.after(() => inventory.dispose()); + const f = await real_fixture(t); const s = scope(); await f.open(s); + const bot = online_context(); + bot.info_manager = {get_all_info: () => [{name: inventory.name, i18n: inventory.i18n, value: inventory.get_info()}]}; + const unsubscribe = inventory.on_update(value => bot.emit('info_update', {name: 'inventory', value})); + t.after(unsubscribe); + f.source(s.bot_id).set(bot); + const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success'); + const web = await f.web(s, ['bot.snapshot']); + const records = success_data(await ack(web.socket, 'relay_request', request('get_context_info'))); + assert.ok(Buffer.byteLength(JSON.stringify(records)) > 196_608); + assert.deepEqual(records[0], bot.info_manager.get_all_info()[0]); + const item = records[0].value.window.slots[0]; + assert.equal(item.displayName[0].message, '菜单'); + assert.equal(item.displayName[0].font_color, '#FFAA00'); + assert.equal(item.lore[0][0].message, '说明'); + assert.equal('components' in item, false); + assert.equal('nbt' in item, false); + const pushed = received(web.socket, 'info_update'); + instance.currentWindow.slots[0].count = 32; + instance.currentWindow.emit('updateSlot'); + const update = await pushed; + assert.equal(update.data.name, 'inventory'); + assert.equal(update.data.value.window.slots[0].count, 32); + assert.equal(update.data.value.window.slots.length, 90); + assert.deepEqual(update.data.value, inventory.get_info()); +}); + +test('execute_function traverses the relay with boolean results, permission checks and no replay', async t => { + const f = await real_fixture(t); const s = scope(); await f.open(s); + const manager = f.manager(s); + await manager.ensure(f.cmd(s)); + const web = await f.web(s); + const reader = await f.web(s, ['bot.snapshot']); + const bot = online_context(); + const calls = []; + bot.function_manager = {async run(path, name, args, before_run) { + before_run(); + calls.push({path, name, args}); + if (name === 'missing') throw new Error('Unknown function'); + return name === 'left_click_slot'; + }}; + f.source(s.bot_id).set(bot); + const payload = {function: 'inventory', name: 'left_click_slot', args: [9]}; + const rpc = request('execute_function', payload); + assert.equal(success_data(await ack(web.socket, 'relay_request', rpc)), true); + code(await ack(web.socket, 'relay_request', rpc), 'CANCELLED'); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], {path: 'inventory', name: 'left_click_slot', args: [9]}); + code(await ack(reader.socket, 'relay_request', request('execute_function', payload)), 'FORBIDDEN'); + assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', {...payload, name: 'missing'}))), false); + assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', {...payload, name: 'right_click_slot'}))), false); + bot.bot_status = 'offline'; + assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', payload))), false); +}); + +test('actual manager serves snapshots, logs and chat without forwarding bot events', async t => { + const f = await real_fixture(t); const s = scope(); await f.open(s); + const manager = f.manager(s); const cmd = f.cmd(s); + assert.deepEqual(await manager.ensure(cmd), success({...s, node_ready: true, lease_expires_at: cmd.expires_at})); + const node_id = f.node_sockets[0].id; + assert.deepEqual(f.packets.filter(p => p.id === node_id && ['join_room', 'forward_node_ready'].includes(p.event)).map(p => p.event), + ['join_room', 'forward_node_ready']); + assert.ok(f.statuses.some(value => value.state === 'ready' && value.forward_session_id === s.forward_session_id)); + const web = await f.web(s); const other = await f.web(s, ['bot.snapshot']); + const absent = success_data(await ack(web.socket, 'relay_request', request())); + assert.deepEqual(Object.keys(absent).sort(), ['bot_id', 'bot_status', 'captured_at', 'context_exists', 'context_generation', 'seq', 'stream_id']); + assert.equal(absent.context_exists, false); assert.equal(absent.context_generation, null); assert.equal(absent.bot_status, 'offline'); + code(await ack(web.socket, 'relay_request', request('bot.chat', {message: 'hello'})), 'NO_CONTEXT'); + let leaks = 0; other.socket.on('relay_request', () => leaks++); other.socket.on('bot_event', () => leaks++); + assert.equal((await ack(web.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log', 'message', 'bot_error']}))).status, 'success'); + const events = []; web.socket.on('bot_event', value => events.push(value)); + const bot = online_context(); + f.source(s.bot_id).set(bot); + const snapshot = success_data(await ack(web.socket, 'relay_request', request())); + assert.equal(snapshot.context_exists, true); assert.equal(snapshot.bot_status, 'online'); + assert.equal(snapshot.stream_id, absent.stream_id); assert.ok(snapshot.context_generation); + for (const state of ['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']) { + bot.bot_status = state; bot.emit('status_update', state); + const current = success_data(await ack(web.socket, 'relay_request', request())); + assert.equal(current.context_exists, true); + assert.equal(current.bot_status, state); assert.equal(current.context_generation, snapshot.context_generation); + } + bot.bot_status = 'online'; bot.emit('status_update', 'online'); + const log_packet = received(web.socket, 'bot_log'); + const metadata = {hoverEvent: {action: 'show_text', value: '悬停说明'}, + clickEvent: {action: 'suggest_command', value: '/help'}, insertion: 'Alex'}; + bot.emit('log', [{message: 'safe log', font_color: '#abcdef', font_style: ['normal'], ...metadata, password: 'SECRET'}]); + const live_log = await log_packet; + assert.equal(live_log.forward_session_id, s.forward_session_id); + assert.equal(live_log.payload.records[0].segments[0].message, 'safe log'); + for (const [key, value] of Object.entries(metadata)) assert.deepEqual(live_log.payload.records[0].segments[0][key], value); + assert.equal(JSON.stringify(live_log).includes('SECRET'), false); + const logs = success_data(await ack(web.socket, 'relay_request', request('bot.logs', {limit: 3}))); + assert.equal(logs.records[0].segments[0].message, 'safe log'); + assert.deepEqual(logs.records[0].segments[0], live_log.payload.records[0].segments[0]); + for (let i = 0; i < 25; i++) bot.emit('log', [{message: `history ${i}`, font_color: '#ffffff', font_style: ['normal']}]); + bot.logger.read_recent_log = async count => Array.from({length: 25}, (_, i) => ({ + id: randomUUID(), timestamp: Date.now(), log: [{message: `history ${i}`, font_color: '#ffffff', font_style: ['normal']}], + })).slice(-count); + const history = success_data(await ack(web.socket, 'relay_request', request('get_log_history', {}))); + assert.equal(history.records.length, 20); + assert.deepEqual(history.records.map(record => record.segments[0].message), + Array.from({length: 20}, (_, i) => `history ${i + 5}`)); + code(await ack(other.socket, 'relay_request', request('get_log_history', {})), 'FORBIDDEN'); + code(await ack(web.socket, 'relay_request', request('get_log_history', {limit: 21})), 'INVALID_REQUEST'); + assert.equal((await ack(web.socket, 'relay_request', request('bot.chat', {message: 'hello'}))).data.accepted, true); + assert.deepEqual(bot.sent, ['hello']); + bot.bot_instance.tabComplete = async text => { + assert.equal(text, '/he'); + return ['help', {match: 'help'}, {match: 'hello'}]; + }; + assert.deepEqual(success_data(await ack(web.socket, 'relay_request', request('tab_complete', {text: '/he'}))), + {matches: ['help', 'hello'], start: 1, length: 2}); + bot.bot_instance.tabComplete = async () => ['Alex', 'Alice', 'Alex']; + const input = '/tell Al'; + const completion = success_data(await ack(web.socket, 'relay_request', request('tab_complete', {text: input}))); + assert.deepEqual(completion, {matches: ['Alex', 'Alice'], start: 6, length: 2}); + assert.equal(input.slice(0, completion.start) + completion.matches[0] + + input.slice(completion.start + completion.length), '/tell Alex'); + code(await ack(other.socket, 'relay_request', request('tab_complete', {text: '/he'})), 'FORBIDDEN'); + bot.bot_status = 'offline'; code(await ack(web.socket, 'relay_request', request('bot.chat', {message: 'blocked'})), 'BOT_NOT_ONLINE'); + assert.deepEqual(bot.sent, ['hello']); + bot.emit('message', {json: {text: 'public text', password: 'SECRET'}}, 'chat'); + bot.emit('error', new Error('SECRET stack')); + assert.equal(JSON.stringify({snapshot, logs}).includes('SECRET'), false); + await ack(other.socket, 'relay_request', request()); assert.equal(leaks, 0); + assert.deepEqual(events, []); + assert.equal(f.packets.some(packet => packet.event === 'bot_event'), false); + manager.stop(); assert.equal(f.source(s.bot_id).count, 0); assert.equal(bot.listenerCount('log'), 0); +}); + +test('actual manager reauth renews same transport; disconnect requires fresh token and preserves stream identity', async t => { + const f = await real_fixture(t); const s = scope(); await f.open(s); + const manager = f.manager(s); const first = f.cmd(s, Date.now() + 3000); + assert.equal((await manager.ensure(first)).status, 'success'); + const web = await f.web(s); const before = success_data(await ack(web.socket, 'relay_request', request())); + const renewed = f.cmd(s, Date.now() + 8000); + assert.equal((await manager.ensure(renewed)).data.lease_expires_at, renewed.expires_at); + assert.equal(f.node_sockets.length, 1); assert.ok(f.packets.some(p => p.event === 'reauth' && p.value.token === renewed.token)); + const offline = received(web.socket, 'relay_status', value => !value.node_ready); + f.node_sockets[0].disconnect(); await offline; + code(await ack(web.socket, 'relay_request', request()), 'NODE_OFFLINE'); + code(await manager.ensure({...renewed, deadline_at: Date.now() + 2000}), 'TOKEN_EXPIRED'); + assert.equal(f.node_sockets.length, 1); + const fresh = f.cmd(s, Date.now() + 9000); + assert.equal((await manager.ensure(fresh)).status, 'success'); assert.equal(f.node_sockets.length, 2); + const after = success_data(await ack(web.socket, 'relay_request', request())); + assert.equal(after.stream_id, before.stream_id); assert.ok(after.seq > before.seq); +}); + +test('actual multi-grant request_id reuse is private, read-deduped and chat executes once per grant', async t => { + const f = await real_fixture(t); const s = scope(); await f.open(s); + const bot = online_context(); f.source(s.bot_id).set(bot); + const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success'); + const a = await f.web(s); const b = await f.web(s); + const id = randomUUID(); const reads = await Promise.all([a, b].map(web => ack(web.socket, 'relay_request', request('bot.snapshot', {}, id)))); + assert.ok(reads.every(value => value.status === 'success')); + assert.deepEqual(await ack(a.socket, 'relay_request', request('bot.snapshot', {}, id)), reads[0]); + code(await ack(a.socket, 'relay_request', request('bot.logs', {limit: 1}, id)), 'INVALID_REQUEST'); + const chat_id = randomUUID(); + const chats = await Promise.all([a, b].map(web => ack(web.socket, 'relay_request', request('bot.chat', {message: web.grant_id}, chat_id)))); + assert.ok(chats.every(value => value.status === 'success')); assert.deepEqual(new Set(bot.sent), new Set([a.grant_id, b.grant_id])); + code(await ack(a.socket, 'relay_request', request('bot.chat', {message: a.grant_id}, chat_id)), 'CANCELLED'); + assert.equal(bot.sent.length, 2); + const closed = received(a.socket, 'forward_session_closed'); + assert.equal((await f.control_ack('forward_revoke_grant', {forward_session_id: s.forward_session_id, grant_id: a.grant_id, reason: 'permission_removed'})).status, 'success'); + await closed; assert.equal((await ack(b.socket, 'relay_request', request())).status, 'success'); + code(await f.control_ack('forward_authorize_grant', {forward_session_id: s.forward_session_id, grant_id: a.grant_id, + user_id: 4, permissions: f.permissions, expires_at: Date.now() + 5000}), 'FORBIDDEN'); +}); + +test('one Node manager isolates multiple Bot sessions and observes stop/start without closing Web transport', async t => { + const f = await real_fixture(t); + const a = scope(); + const b = {...a, bot_id: a.bot_id + 1, forward_session_id: randomUUID()}; + await f.open(a); await f.open(b); + const manager = f.manager(a); + const bot_a = online_context(); const bot_b = online_context(); + f.source(a.bot_id).set(bot_a); f.source(b.bot_id).set(bot_b); + assert.equal((await manager.ensure(f.cmd(a))).status, 'success'); + assert.equal((await manager.ensure(f.cmd(b))).status, 'success'); + const web_a = await f.web(a); const web_b = await f.web(b); + const before = success_data(await ack(web_a.socket, 'relay_request', request())); + const other = success_data(await ack(web_b.socket, 'relay_request', request())); + assert.notEqual(before.stream_id, other.stream_id); + assert.equal(before.bot_id, a.bot_id); assert.equal(other.bot_id, b.bot_id); + await ack(web_a.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']})); + await ack(web_b.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']})); + const events_b = []; web_b.socket.on('bot_event', event => events_b.push(event)); + const events_a = []; web_a.socket.on('bot_event', event => events_a.push(event)); + bot_a.emit('log', [{message: 'Bot A only', font_color: '#ffffff', font_style: ['normal']}]); + assert.equal(success_data(await ack(web_a.socket, 'relay_request', request('bot.logs', {limit: 10}))).records[0].segments[0].message, 'Bot A only'); + assert.equal(success_data(await ack(web_b.socket, 'relay_request', request('bot.logs', {limit: 10}))).records.length, 0); + assert.equal(events_b.length, 0); + await ack(web_a.socket, 'relay_request', request('bot.chat', {message: 'a'})); + await ack(web_b.socket, 'relay_request', request('bot.chat', {message: 'b'})); + assert.deepEqual(bot_a.sent, ['a']); assert.deepEqual(bot_b.sent, ['b']); + f.source(a.bot_id).set(undefined); + const absent = success_data(await ack(web_a.socket, 'relay_request', request())); + assert.equal(absent.bot_status, 'offline'); assert.equal(absent.context_generation, null); + const replacement = online_context(); + f.source(a.bot_id).set(replacement); + const after = success_data(await ack(web_a.socket, 'relay_request', request())); + assert.equal(after.stream_id, before.stream_id); assert.notEqual(after.context_generation, before.context_generation); + assert.equal(web_a.socket.connected, true); assert.equal(f.node_sockets.length, 2); + assert.equal(events_b.length, 0); + assert.equal(events_a.length, 0); + assert.equal(bot_a.listenerCount('log'), 0); +}); + +test('actual session revocation retires manager, closes Web and cannot revive stale scope after a new session opens', async t => { + const f = await real_fixture(t); const s = scope(); await f.open(s); + const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success'); + const web = await f.web(s); const closed = received(web.socket, 'forward_session_closed'); + const node_closed = received(f.node_sockets[0], 'forward_session_closed'); + assert.equal((await f.control_ack('forward_revoke_session', {forward_session_id: s.forward_session_id, reason: 'ownership_changed'})).status, 'success'); + assert.equal((await closed).code, 'SESSION_STALE'); await node_closed; + code(await manager.ensure(f.cmd(s)), 'SESSION_STALE'); + code(await f.control_ack('forward_open_session', {...s, lease_expires_at: Date.now() + 5000}), 'SESSION_STALE'); + const next = {...s, forward_session_id: randomUUID(), node_session_id: 'new-main-socket-session'}; await f.open(next); + code(await manager.ensure(f.cmd(next)), 'SESSION_STALE'); + const replacement = f.manager(next); assert.equal((await replacement.ensure(f.cmd(next))).status, 'success'); + const new_web = await f.web(next); assert.equal((await ack(new_web.socket, 'relay_request', request())).status, 'success'); + code(await manager.ensure(f.cmd(s)), 'SESSION_STALE'); +}); + +test('actual relay rejects wrong-scope token and manager lease expiry prevents session revival', async t => { + const f = await real_fixture(t); const s = scope(); await f.open(s); + const manager = f.manager(s); const wrong = f.cmd(s); + wrong.token = f.token({...s, bot_id: s.bot_id + 1}, 'node', wrong.expires_at); + code(await manager.ensure(wrong), 'SESSION_STALE'); + const short = f.cmd(s, Date.now() + 200); + assert.equal((await manager.ensure(short)).status, 'success'); + const web = await f.web(s); + await delay(Math.max(0, short.expires_at - Date.now()) + 25); + code(await manager.ensure(f.cmd(s)), 'SESSION_STALE'); + code(await ack(web.socket, 'relay_request', request()), 'NODE_OFFLINE'); + assert.equal(f.source(s.bot_id).count, 0); +}); + +test('actual relay deadline times out held chat; late manager delivery cannot send chat or replace the ACK', async t => { + const f = await real_fixture(t, {rpc_timeout_ms: 60}); const s = scope(); await f.open(s); + const bot = online_context(); f.source(s.bot_id).set(bot); + const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success'); + const web = await f.web(s); const node = f.node_sockets[0]; + const [handler] = node.listeners('relay_request'); + let late; let arrived; + const held = new Promise(resolve => {arrived = resolve;}); + node.off('relay_request', handler); + node.on('relay_request', (raw, callback) => { + late = () => new Promise(resolve => handler(raw, response => {callback(response); resolve(response);})); + arrived(raw); + }); + const chat = request('bot.chat', {message: 'must never send'}); + const response = ack(web.socket, 'relay_request', chat); + const delivered = await held; + assert.equal(delivered.forward_session_id, s.forward_session_id); + assert.notEqual(delivered.correlation_id, chat.request_id); + code(await response, 'TIMEOUT'); + code(await late(), 'TIMEOUT'); assert.deepEqual(bot.sent, []); + code(await ack(web.socket, 'relay_request', chat), 'CANCELLED'); + assert.deepEqual(bot.sent, []); + node.removeAllListeners('relay_request'); node.on('relay_request', handler); + assert.equal((await ack(web.socket, 'relay_request', request())).status, 'success'); +}); + +test('public relay restart closes old scope; a fresh session and replacement manager restore Web RPC', async t => { + const first = await real_fixture(t); const s = scope(); await first.open(s); + const manager = first.manager(s); assert.equal((await manager.ensure(first.cmd(s))).status, 'success'); + const web = await first.web(s); + const node_closed = received(first.node_sockets[0], 'forward_session_closed'); + const web_closed = received(web.socket, 'forward_session_closed'); + await first.handle.stop(); + assert.equal((await node_closed).reason, 'relay_closed'); + assert.equal((await web_closed).code, 'RELAY_UNAVAILABLE'); + const restarted = await real_fixture(t); + code(await manager.ensure(restarted.cmd(s)), 'SESSION_STALE'); + const next = {...s, forward_session_id: randomUUID()}; await restarted.open(next); + assert.equal((await manager.ensure(restarted.cmd(next))).status, 'success'); + const current = await restarted.web(next); + const before = success_data(await ack(current.socket, 'relay_request', request())); + const offline = received(current.socket, 'relay_status', value => !value.node_ready); + manager.stop(); await offline; + const replacement = restarted.manager(next); + assert.equal((await replacement.ensure(restarted.cmd(next))).status, 'success'); + const after = success_data(await ack(current.socket, 'relay_request', request())); + assert.notEqual(after.stream_id, before.stream_id); + assert.equal(after.context_exists, false); +}); diff --git a/tests/forward_manager.test.mjs b/tests/forward_manager.test.mjs new file mode 100644 index 0000000..20cfd11 --- /dev/null +++ b/tests/forward_manager.test.mjs @@ -0,0 +1,412 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {randomUUID} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import {readFile} from 'node:fs/promises'; +import vm from 'node:vm'; +import test from 'node:test'; +import {build, transformSync} from 'esbuild'; + +const require = createRequire(import.meta.url); +const root = fileURLToPath(new URL('../', import.meta.url)); +const built = await build({ + stdin: {contents: `export * from './bot/forward/manager.ts'; + export {handler} from './event_handler/bot/forward_bot.ts';`, resolveDir: root, loader: 'ts'}, + bundle: true, platform: 'node', format: 'cjs', + packages: 'external', write: false, + plugins: [{name: 'stream-stub', setup(build) { + build.onResolve({filter: /^\.\/stream\.ts$/}, () => ({path: 'stream', namespace: 'mock'})); + build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({contents: 'export class BotStream {}', loader: 'js'})); + }}], +}); +const module = {exports: {}}; +vm.runInNewContext(built.outputFiles[0].text, {module, exports: module.exports, require, + Buffer, URL, setTimeout, clearTimeout, console}); +const {NodeForwardManager} = module.exports; + +function command(overrides = {}) { + return {forward_id: 0, forward_session_id: randomUUID(), node_session_id: 'node-session', node_id: 1, bot_id: 2, + url: 'http://127.0.0.1:1234', path: '/api/forward_server', namespace: '/', token: randomUUID(), + expires_at: Date.now() + 30_000, deadline_at: Date.now() + 500, ...overrides}; +} + +function setup() { + const sockets = []; + const streams = []; + const files = []; + const hooks = new Map(); + const initial = command(); + let active = initial; + const manager = new NodeForwardManager(1, 'node-session', { + socket_factory(url, options) { + const socket = new EventEmitter(); + socket.sent = []; + socket.url = url; + socket.options = options; + socket.auth = options.auth; + socket.connected = false; + socket.volatile = {emit: (...args) => socket.sent.push(args)}; + socket.io = {engine: {writeBuffer: []}}; + socket.connect = () => { + socket.connected = true; + queueMicrotask(() => socket.emit('connect')); + }; + socket.disconnect = () => { + socket.connected = false; + socket.emit('disconnect'); + }; + socket.timeout = () => ({emit(event, payload, ack) { + socket.sent.push([event, payload]); + const respond = data => ack(null, {status: 'success', data}); + if (hooks.has(event)) return hooks.get(event)({socket, payload, respond, ack}); + if (event === 'forward_node_ready') return queueMicrotask(() => respond({node_ready: true})); + const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = active; + queueMicrotask(() => respond({forward_id, forward_session_id, node_session_id, node_id, bot_id, + role: 'node', node_ready: event === 'reauth', expires_at})); + }}); + sockets.push(socket); + return socket; + }, + stream_factory(bot_id) { + const listeners = new Set(); + const stream = {bot_id, disposed: false, chats: 0, + snapshot: () => ({bot_id}), logs: () => ({records: []}), + get_context_info: () => [{name: 'health', i18n: {lang: 'en', name: 'Health', description: ''}, value: 20}], + chat: () => { stream.chats++; return {accepted: true}; }, + subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); }, + resync() {}, dispose() { stream.disposed = true; }, + event: event => { for (const listener of listeners) listener(event); }, + get listeners() { return listeners.size; }, + }; + streams.push(stream); + return stream; + }, + file_factory(bot_id) { + const service = {bot_id, disposed: false, + list: async () => ({listing_id: randomUUID(), expires_at: Date.now() + 300_000, partial: false, children: []}), + read: async ({id, offset = 0}) => ({id, name: 'empty', encoding: 'base64', offset, + next_offset: offset, total_bytes: offset, eof: true, data: ''}), + dispose() { service.disposed = true; }, + }; + files.push(service); + return service; + }, + }); + return {manager, sockets, streams, files, hooks, command: active, + ensure(value = initial) { active = value; return manager.ensure(value); }}; +} + +const tick = () => new Promise(resolve => setImmediate(resolve)); +const code = response => response.data.code; + +test('file RPCs work without a bot, clamp ID expiry and invalidate services on transport replacement', async t => { + const app = setup(); t.after(() => app.manager.stop()); + await app.ensure(); + const rpc = (socket, method, payload = {}, deadline_at = Date.now() + 1000) => new Promise(resolve => + socket.emit('relay_request', {request_id: randomUUID(), correlation_id: randomUUID(), method, payload, + forward_session_id: app.command.forward_session_id, deadline_at}, resolve)); + const first = app.sockets[0]; + const listed = await rpc(first, 'get_data_file_list'); + assert.equal(listed.status, 'success'); + assert.equal(listed.data.expires_at, app.command.expires_at); + assert.equal(app.files.length, 1); + assert.equal(app.files[0].bot_id, app.command.bot_id); + const id = randomUUID(); + assert.equal((await rpc(first, 'get_file_data', {id})).data.id, id); + assert.equal(code(await rpc(first, 'get_file_data', {id, length: 262145})), 'INVALID_REQUEST'); + assert.equal(code(await rpc(first, 'get_file_data', {id, offset: -1})), 'INVALID_REQUEST'); + let release; + app.files[0].read = () => new Promise(resolve => {release = resolve;}); + const pending = rpc(first, 'get_file_data', {id}); + first.disconnect(); + assert.equal(app.files[0].disposed, true); + release({id}); + assert.equal(code(await pending), 'SESSION_STALE'); + assert.equal((await app.ensure({...app.command, token: randomUUID()})).status, 'success'); + assert.equal((await rpc(app.sockets[1], 'get_data_file_list')).status, 'success'); + assert.equal(app.files.length, 2); + let late; + app.files[1].list = () => new Promise(resolve => {late = resolve;}); + const timed = rpc(app.sockets[1], 'get_data_file_list', {}, Date.now() + 10); + await new Promise(resolve => setTimeout(resolve, 15)); + late({listing_id: randomUUID(), expires_at: Date.now() + 1000, partial: false, children: []}); + assert.equal(code(await timed), 'TIMEOUT'); + app.manager.stop(); + assert.equal(app.files[1].disposed, true); +}); + +test('ready ACK waits for ready confirmation, with one RPC handler and log observer across renewals', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + let release; + app.hooks.set('forward_node_ready', ({respond}) => { release = respond; }); + let completed = false; + const result = app.ensure().then(value => { completed = true; return value; }); + await tick(); + assert.equal(completed, false); + assert.equal(app.sockets[0].listenerCount('relay_request'), 1); + assert.equal(app.streams[0].listeners, 1); + release({node_ready: true}); + assert.equal((await result).status, 'success'); + app.hooks.delete('forward_node_ready'); + const next = {...app.command, token: randomUUID()}; + assert.equal((await app.ensure(next)).status, 'success'); + assert.equal(app.sockets.length, 1); + assert.equal(app.streams[0].listeners, 1); + assert.equal(app.sockets[0].sent.filter(([event]) => event === 'reauth').length, 1); +}); + +test('absolute deadline cancels stalled readiness, closes channel and ignores late ready ACK', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + let release; + app.hooks.set('forward_node_ready', ({respond}) => { release = respond; }); + const request = {...app.command, deadline_at: Date.now() + 35}; + const response = await app.ensure(request); + assert.equal(code(response), 'TIMEOUT'); + assert.equal(app.sockets[0].connected, false); + assert.equal(app.sockets[0].listenerCount('relay_request'), 0); + assert.equal(app.streams[0].disposed, true); + release({node_ready: true}); + await tick(); + assert.equal(app.sockets[0].connected, false); +}); + +test('session scope and whitelist validation reject stale node, wrong bot scope, and extra fields', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + assert.equal(code(await app.ensure({...app.command, node_session_id: 'old'})), 'SESSION_STALE'); + assert.equal(code(await app.ensure({...app.command, node_id: 3})), 'FORBIDDEN'); + assert.equal(code(await app.ensure({...app.command, extra: true})), 'INVALID_REQUEST'); + assert.equal((await app.ensure()).status, 'success'); + assert.equal(code(await app.ensure({...app.command, bot_id: 3})), 'SESSION_STALE'); + assert.equal(app.sockets.length, 1); +}); + +test('serial ensure ignores expired queued work without closing a healthy current operation', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + let release; + app.hooks.set('forward_node_ready', ({respond}) => { release = respond; }); + const first = app.ensure(); + await tick(); + const second = app.ensure({...app.command, token: randomUUID(), deadline_at: Date.now() + 20}); + assert.equal(code(await second), 'TIMEOUT'); + assert.equal(app.sockets[0].connected, true); + release({node_ready: true}); + assert.equal((await first).status, 'success'); + await tick(); + assert.equal(app.sockets.length, 1); + assert.equal(app.sockets[0].connected, true); +}); + +test('closed session cannot revive, old callbacks cannot execute chat, and manager stop removes observers', async () => { + const app = setup(); + assert.equal((await app.ensure()).status, 'success'); + const socket = app.sockets[0]; + const stale_rpc = socket.listeners('relay_request')[0]; + socket.emit('forward_session_closed', {forward_session_id: app.command.forward_session_id, + reason: 'revoked', code: 'CANCELLED'}); + assert.equal(app.streams[0].disposed, true); + assert.equal(socket.connected, false); + assert.equal(code(await app.ensure({...app.command, token: randomUUID()})), 'SESSION_STALE'); + let response; + stale_rpc({request_id: randomUUID(), correlation_id: randomUUID(), method: 'bot.chat', payload: {message: 'hello'}, + forward_session_id: app.command.forward_session_id, deadline_at: Date.now() + 1000}, value => { response = value; }); + assert.equal(code(response), 'SESSION_STALE'); + assert.equal(app.streams[0].chats, 0); + app.manager.stop(); + assert.equal(code(await app.ensure(command())), 'SESSION_STALE'); +}); + +test('RPC checks deadlines and deduplicates correlation IDs, while allowing grant request ID reuse', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + await app.ensure(); + const base = {request_id: randomUUID(), correlation_id: randomUUID(), method: 'bot.chat', payload: {message: 'hello'}, + forward_session_id: app.command.forward_session_id, deadline_at: Date.now() + 1000}; + const rpc = request => new Promise(resolve => app.sockets[0].emit('relay_request', request, resolve)); + assert.equal((await rpc(base)).status, 'success'); + assert.equal(code(await rpc(base)), 'CANCELLED'); + assert.equal((await rpc({...base, correlation_id: randomUUID()})).status, 'success'); + assert.equal(app.streams[0].chats, 2); + assert.equal(code(await rpc({...base, correlation_id: randomUUID(), deadline_at: Date.now() - 1})), 'TIMEOUT'); + assert.equal(app.streams[0].chats, 2); + assert.equal(code(await rpc({...base, method: 'bot.subscribe', payload: {events: []}})), 'INVALID_REQUEST'); + const info = await rpc({...base, correlation_id: randomUUID(), method: 'get_context_info', payload: {}}); + assert.equal(info.status, 'success'); + assert.equal(info.data[0].value, 20); +}); + +test('transport recovery requires a fresh token; ordinary disconnect never reconnects automatically', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + await app.ensure(); + app.sockets[0].disconnect(); + assert.equal(app.sockets[0].options.reconnection, false); + assert.equal(code(await app.ensure(app.command)), 'TOKEN_EXPIRED'); + assert.equal(app.sockets.length, 1); + assert.equal((await app.ensure({...app.command, token: randomUUID()})).status, 'success'); + assert.equal(app.sockets.length, 2); + assert.equal(app.streams.length, 1); + assert.equal(app.streams[0].listeners, 1); +}); + +test('wrong-scope join response closes the channel and sanitized ACK never exposes raw relay details', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + app.hooks.set('join_room', ({respond}) => respond({node_id: 999, token: 'secret', stack: 'private'})); + const response = await app.ensure(); + assert.equal(code(response), 'SESSION_STALE'); + assert.equal(app.sockets[0].connected, false); + assert.doesNotMatch(JSON.stringify(response), /secret|private|stack/); +}); + +test('many historical closed sessions do not consume the active-session budget', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + for (let index = 0; index < 505; index++) { + const request = command({deadline_at: Date.now() + 2000}); + assert.equal((await app.ensure(request)).status, 'success'); + app.sockets.at(-1).emit('forward_session_closed', {forward_session_id: request.forward_session_id, + reason: 'revoked', code: 'CANCELLED'}); + } + assert.equal(app.streams.filter(stream => !stream.disposed).length, 0); + assert.equal((await app.ensure(command())).status, 'success'); +}); + +test('lease expiry retires data channel and queue capacity stays bounded under stalled work', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + const request = {...app.command, expires_at: Date.now() + 40}; + assert.equal((await app.ensure(request)).status, 'success'); + await new Promise(resolve => setTimeout(resolve, 50)); + assert.equal(app.sockets[0].connected, false); + assert.equal(app.streams[0].disposed, true); + assert.equal(code(await app.ensure({...request, expires_at: Date.now() + 1000, token: randomUUID()})), 'SESSION_STALE'); + + const stalled = setup(); + t.after(() => stalled.manager.stop()); + stalled.hooks.set('forward_node_ready', () => {}); + const first = stalled.ensure(); + await tick(); + const queued = Array.from({length: 15}, () => stalled.ensure({...stalled.command, token: randomUUID()})); + assert.equal(code(await stalled.ensure({...stalled.command, token: randomUUID()})), 'RATE_LIMITED'); + stalled.manager.stop(); + const replies = await Promise.all([first, ...queued]); + assert.ok(replies.every(reply => reply.status === 'error')); + assert.equal(stalled.sockets.length, 1); +}); + +test('bot events are never forwarded while connected, congested or disconnected', async t => { + const app = setup(); + t.after(() => app.manager.stop()); + await app.ensure(); + const socket = app.sockets[0]; + const stream = app.streams[0]; + let resyncs = 0; + stream.resync = () => { resyncs++; }; + const event = {name: 'status_update', seq: 1, payload: {status: 'offline', context_exists: false}}; + stream.event(event); + assert.equal(socket.sent.filter(([name]) => name === 'bot_event').length, 0); + assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 0); + stream.event({name: 'log', seq: 2, payload: {records: []}}); + assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 1); + socket.io.engine.writeBuffer.length = 64; + stream.event({...event, seq: 2}); + stream.event({name: 'log', seq: 3, payload: {records: []}}); + assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 1); + assert.equal(socket.sent.filter(([name]) => name === 'bot_event').length, 0); + socket.emit('relay_status', {forward_session_id: app.command.forward_session_id, node_ready: true, reason: 'resync_required'}); + assert.equal(resyncs, 1); + socket.disconnect(); + stream.event({...event, seq: 3}); + stream.event({name: 'log', seq: 4, payload: {records: []}}); + assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 1); + assert.equal(socket.sent.filter(([name]) => name === 'bot_event').length, 0); + assert.equal(stream.listeners, 0); +}); + +test('info updates use the data envelope and share push congestion and rate controls with logs', async t => { + const app = setup(); t.after(() => app.manager.stop()); + let ready; + app.hooks.set('forward_node_ready', ({respond}) => {ready = respond;}); + const pending = app.ensure(); await tick(); + const stream = app.streams[0]; const socket = app.sockets[0]; + const event = {bot_id: 2, context_generation: randomUUID(), stream_id: randomUUID(), emitted_at: Date.now(), + seq: 1, name: 'info_update', payload: {name: 'health', value: 18}}; + stream.event(event); + assert.equal(socket.sent.some(([name]) => name === 'info_update'), false); + ready({node_ready: true}); await pending; + stream.event(event); + const packet = socket.sent.find(([name]) => name === 'info_update')[1]; + assert.deepEqual(Object.keys(packet).sort(), ['bot_id', 'context_generation', 'data', 'emitted_at', 'forward_session_id', 'seq', 'stream_id']); + assert.equal(packet.data.value, 18); + assert.equal(packet.forward_session_id, app.command.forward_session_id); + socket.io.engine.writeBuffer.length = 64; stream.event({...event, seq: 2}); + assert.equal(socket.sent.filter(([name]) => name === 'info_update').length, 1); + socket.io.engine.writeBuffer.length = 0; + for (let seq = 3; seq < 203; seq++) stream.event({...event, seq, name: seq % 2 ? 'log' : 'info_update'}); + assert.equal(socket.sent.filter(([name]) => ['info_update', 'bot_log'].includes(name)).length, 149); + socket.disconnect(); stream.event({...event, seq: 204}); + assert.equal(stream.listeners, 0); + assert.equal(socket.sent.filter(([name]) => ['info_update', 'bot_log'].includes(name)).length, 149); +}); + +test('plugin lifecycle updates survive a temporarily unwritable transport and retain congestion bounds', async t => { + const app = setup(); t.after(() => app.manager.stop()); + await app.ensure(); + const socket = app.sockets[0], stream = app.streams[0]; + const received = []; + socket.on('info_update', update => received.push(update)); + // Match Socket.IO volatile behavior during an outstanding WebSocket write. + socket.volatile = {emit() {}}; + const event = {bot_id: 2, context_generation: randomUUID(), stream_id: randomUUID(), emitted_at: Date.now(), + name: 'info_update', payload: {name: 'plugin_states', value: {available: true, plugins: []}}}; + for (const [index, lifecycle] of ['initializing', 'enabled'].entries()) { + stream.event({...event, seq: index + 1, payload: {...event.payload, value: {available: true, + plugins: [{name: 'auto_reconnect', path: 'runtime', lifecycle, enabled: lifecycle === 'enabled', states: {attempts: 0}}]}}}); + } + assert.deepEqual(received.map(update => update.data.value.plugins[0].lifecycle), ['initializing', 'enabled']); + assert.deepEqual(received.map(update => update.seq), [1, 2]); + assert.ok(received.every(update => update.forward_session_id === app.command.forward_session_id)); + assert.ok(received.every(update => !('name' in update) && !('payload' in update))); + stream.event({...event, seq: 3, payload: {name: 'health', value: 20}}); + assert.equal(received.length, 2); + socket.io.engine.writeBuffer.length = 64; + stream.event({...event, seq: 4}); + assert.equal(received.length, 2); + socket.io.engine.writeBuffer.length = 0; + for (let seq = 5; seq <= 155; seq++) stream.event({...event, seq}); + assert.equal(received.length, 148); + socket.disconnect(); stream.event({...event, seq: 156}); + assert.equal(received.length, 148); + assert.equal(stream.listeners, 0); +}); + +test('forward_bot handler requires an ACK and returns structured authorization errors', async () => { + const {handler} = module.exports; + const socket = {connected: true, emit() { assert.fail('Unexpected non-ACK response'); }}; + await handler(socket, command(), undefined); + const replies = []; + await handler(socket, command(), reply => replies.push(reply)); + assert.equal(replies.length, 1); + assert.equal(code(replies[0]), 'UNAUTHORIZED'); + socket.connected = false; + await handler(socket, command(), reply => replies.push(reply)); + assert.equal(code(replies[1]), 'NODE_OFFLINE'); +}); + +test('dispatcher delivers forward_bot while hosted relay control events remain on their own handlers', async () => { + const source = await readFile(new URL('../event_handler/init.ts', import.meta.url), 'utf8'); + const dispatch = transformSync(source.slice(source.indexOf('export async function event_handler')) + .replace('export async function', 'async function'), {loader: 'ts'}).code; + const calls = []; + const map = new Map([['forward_bot', (...args) => calls.push(args)]]); + const context = {handler_map: map, console_print: {log() {}, warning() {}, error() {}}, socket: {}, payload: command()}; + await vm.runInNewContext(`${dispatch}\nevent_handler(socket, 'forward_bot', payload, () => {});`, context); + assert.equal(calls.length, 1); + await vm.runInNewContext("event_handler(socket, 'forward_open_session', payload, () => {});", context); + assert.equal(calls.length, 1); +}); diff --git a/tests/function_cancellation.test.mjs b/tests/function_cancellation.test.mjs new file mode 100644 index 0000000..296c2d6 --- /dev/null +++ b/tests/function_cancellation.test.mjs @@ -0,0 +1,305 @@ +import assert from 'node:assert/strict'; +import {EventEmitter, getEventListeners} from 'node:events'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {register} from 'tsx/esm/api'; + +const loader = register({namespace: 'function-cancellation-test'}); +const tsImport = loader.import; +test.after(() => loader.unregister()); +const {create_function_manager} = await tsImport('../bot/function/init.ts', import.meta.url); +const {cancel_functions, cancellation_generation, create_function_cancellation, on_function_cancel} = + await tsImport('../bot/function/cancellation.ts', import.meta.url); +const require = createRequire(import.meta.url); +const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3'); +const turn = () => new Promise(resolve => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return {promise, resolve, reject}; +}; +const listeners = signal => getEventListeners(signal, 'abort').length; + +async function fixture(t) { + const bot = new EventEmitter(); + bot.entity = {id: 1, position: new Vec3(0, 0, 0), height: 1.8, eyeHeight: 1.62, onGround: true}; + bot._client = Object.assign(new EventEmitter(), {state: 'play'}); + bot.controlState = {}; + bot.getControlState = key => bot.controlState[key] === true; + bot.setControlState = (key, enabled) => { bot.controlState[key] = enabled; }; + bot.stopDigging = () => {}; + bot.deactivateItem = () => {}; + bot.clearControlStates = () => { bot.controlState = {}; }; + const messages = []; + bot.chat = message => messages.push(message); + bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null}; + const manager = await create_function_manager({bot_instance: bot, bot_status: 'online'}); + t.after(() => manager.dispose()); + return {bot, manager, messages}; +} + +test('pre-aborted calls preserve their reason and never execute callbacks or the stop command', async t => { + const {bot, manager} = await fixture(t); + const click = deferred(); + bot.clickWindow = () => click.promise; + const running = manager.run('inventory', 'slot_to_slot', [0, 1]); + await turn(); + const queued = manager.run('bot', 'chat', ['queued']); + let before = 0; + for (const reason of [undefined, Error('cancelled'), 'custom reason', null]) { + const controller = new AbortController(); + controller.abort(reason); + for (const [name, args] of [['chat', ['ignored']], ['stop_current_function', []]]) { + await assert.rejects(manager.run('bot', name, args, () => before++, {signal: controller.signal}), + error => error === controller.signal.reason); + } + assert.equal(listeners(controller.signal), 0); + } + assert.equal(before, 0); + click.resolve(); + assert.equal(await running, true); + assert.equal(await queued, true); +}); + +test('aborting queued calls frees capacity without cancelling the current call or other queued calls', async t => { + const {bot, manager, messages} = await fixture(t); + const click = deferred(); + bot.clickWindow = () => click.promise; + const running = manager.run('inventory', 'slot_to_slot', [0, 1]); + await turn(); + const controller = new AbortController(); + let before = 0; + const cancelled = manager.run('bot', 'chat', ['cancelled'], () => before++, {signal: controller.signal}); + const rejected = assert.rejects(cancelled, {name: 'AbortError'}); + const queued = Array.from({length: 63}, (_, index) => manager.run('bot', 'chat', [`queued ${index}`])); + await assert.rejects(manager.run('bot', 'chat', ['full']), /队列已满/); + controller.abort(); + await rejected; + assert.equal(listeners(controller.signal), 0); + const replacement = manager.run('bot', 'chat', ['replacement']); + assert.deepEqual(messages, []); + click.resolve(); + assert.equal(await running, true); + assert.deepEqual(await Promise.all(queued), Array(63).fill(true)); + assert.equal(await replacement, true); + assert.equal(before, 0); + assert.equal(messages.length, 64); + assert.equal(messages.at(-1), 'replacement'); +}); + +test('executing abort notifies existing control helpers and preserves unrelated queued calls and listeners', async t => { + const {bot, manager, messages} = await fixture(t); + let globalCancels = 0; + t.after(on_function_cancel(bot, () => globalCancels++)); + const controller = new AbortController(); + let before = 0; + const running = manager.run('bot', 'use_direction_key', ['forward', 5000], () => before++, {signal: controller.signal}); + const rejected = assert.rejects(running, {name: 'AbortError'}); + await turn(); + assert.equal(bot.controlState.forward, true); + const queued = manager.run('bot', 'chat', ['survived']); + controller.abort(); + await rejected; + assert.equal(await queued, true); + assert.equal(bot.controlState.forward, false); + assert.equal(before, 1); + assert.equal(globalCancels, 0); + assert.deepEqual(messages, ['survived']); + assert.deepEqual(bot.eventNames(), []); + assert.deepEqual(bot._client.eventNames(), []); + assert.equal(listeners(controller.signal), 0); +}); + +test('executing abort drains inventory execution before rejecting and starting the next call', async t => { + for (const fails of [false, true]) { + await t.test(fails ? 'execution rejects' : 'execution resolves', async t => { + const {bot, manager, messages} = await fixture(t); + const click = deferred(); + const clicks = []; + let cancelled = 0; + bot.clickWindow = slot => { + clicks.push(slot); + const unwatch = on_function_cancel(bot, () => cancelled++); + return click.promise.finally(unwatch); + }; + const controller = new AbortController(); + const reason = Error('workflow aborted'); + let settled = false; + const running = manager.run('inventory', 'slot_to_slot', [0, 1], undefined, {signal: controller.signal}); + const rejected = assert.rejects(running, error => { settled = true; return error === reason; }); + await turn(); + const queued = manager.run('bot', 'chat', ['after drain']); + controller.abort(reason); + await turn(); + assert.equal(cancelled, 1); + assert.equal(settled, false); + assert.deepEqual(messages, []); + if (fails) click.reject(Error('late failure')); + else click.resolve(); + await rejected; + assert.equal(await queued, true); + assert.deepEqual(clicks, [0]); + assert.deepEqual(messages, ['after drain']); + assert.equal(listeners(controller.signal), 0); + }); + } +}); + +test('global stop keeps its early false result while the queue waits for actual execution settlement', async t => { + const {bot, manager, messages} = await fixture(t); + const click = deferred(); + const clicks = []; + bot.clickWindow = slot => { clicks.push(slot); return click.promise; }; + const running = manager.run('inventory', 'slot_to_slot', [0, 1]); + await turn(); + const controller = new AbortController(); + const discarded = manager.run('bot', 'chat', ['discarded'], undefined, {signal: controller.signal}); + let before = 0; + assert.equal(await manager.run('bot', 'stop_current_function', [], () => before++), true); + assert.equal(await running, false); + assert.equal(await discarded, false); + assert.equal(before, 1); + assert.equal(listeners(controller.signal), 0); + const fresh = manager.run('bot', 'chat', ['fresh']); + await turn(); + assert.deepEqual(messages, []); + click.resolve(); + assert.equal(await fresh, true); + assert.deepEqual(clicks, [0]); + assert.deepEqual(messages, ['fresh']); +}); + +test('dispose rejects queued calls immediately and notifies and drains the current owned call', async t => { + const {bot, manager, messages} = await fixture(t); + const click = deferred(); + const clicks = []; + let cancelled = 0; + bot.clickWindow = slot => { + clicks.push(slot); + const unwatch = on_function_cancel(bot, () => cancelled++); + return click.promise.finally(unwatch); + }; + const controller = new AbortController(); + let settled = false; + const running = manager.run('inventory', 'slot_to_slot', [0, 1], undefined, {signal: controller.signal}); + const rejected = assert.rejects(running, error => { settled = true; return /已销毁/.test(error.message); }); + await turn(); + const queuedController = new AbortController(); + const queued = manager.run('bot', 'chat', ['discarded'], undefined, {signal: queuedController.signal}); + const queuedRejected = assert.rejects(queued, /已销毁/); + manager.dispose(); + manager.dispose(); + await queuedRejected; + assert.equal(listeners(queuedController.signal), 0); + await turn(); + assert.equal(cancelled, 1); + assert.equal(settled, false); + click.resolve(); + await rejected; + assert.deepEqual(clicks, [0]); + assert.deepEqual(messages, []); + assert.equal(listeners(controller.signal), 0); + await assert.rejects(manager.run('bot', 'chat', ['after dispose']), /已销毁/); +}); + +test('abort or disposal inside before_run prevents execution and preserves callback failures', async t => { + const {manager, messages} = await fixture(t); + const controller = new AbortController(); + await assert.rejects(manager.run('bot', 'chat', ['aborted'], () => controller.abort(), {signal: controller.signal}), + {name: 'AbortError'}); + assert.equal(listeners(controller.signal), 0); + const failure = Error('callback failed'); + await assert.rejects(manager.run('bot', 'chat', ['failed'], () => { throw failure; }), error => error === failure); + assert.equal(await manager.run('bot', 'chat', ['next']), true); + await assert.rejects(manager.run('bot', 'chat', ['disposed'], () => manager.dispose()), /已销毁/); + assert.deepEqual(messages, ['next']); +}); + +test('successful and failed calls retain booleans and remove signal listeners before later aborts', async t => { + const {bot, manager, messages} = await fixture(t); + const controller = new AbortController(); + let before = 0; + assert.equal(await manager.run('bot', 'chat', ['success'], () => before++, {signal: controller.signal}), true); + assert.equal(before, 1); + assert.equal(listeners(controller.signal), 0); + bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null}; + bot.clickWindow = () => Promise.reject(Error('click failed')); + assert.equal(await manager.run('inventory', 'slot_to_slot', [0, 1], undefined, {signal: controller.signal}), false); + assert.equal(listeners(controller.signal), 0); + const next = manager.run('bot', 'jump', []); + await turn(); + controller.abort(); + assert.equal(bot.controlState.jump, true); + bot.emit('physicsTick'); + assert.equal(await next, true); + assert.deepEqual(messages, ['success']); +}); + +test('per-call abort and disposal cannot cancel another manager running on the same bot', async t => { + const {bot, manager} = await fixture(t); + const other = await create_function_manager({bot_instance: bot, bot_status: 'online'}); + t.after(() => other.dispose()); + const controller = new AbortController(); + const first = manager.run('bot', 'use_direction_key', ['forward', 5000], undefined, {signal: controller.signal}); + const rejected = assert.rejects(first, {name: 'AbortError'}); + const second = other.run('bot', 'jump', []); + await turn(); + controller.abort(); + await rejected; + assert.equal(bot.controlState.forward, false); + assert.equal(bot.controlState.jump, true); + const third = manager.run('bot', 'use_direction_key', ['right', 5000]); + const disposed = assert.rejects(third, /已销毁/); + await turn(); + manager.dispose(); + await disposed; + assert.equal(bot.controlState.right, false); + assert.equal(bot.controlState.jump, true); + bot.emit('physicsTick'); + assert.equal(await second, true); + assert.deepEqual(bot.eventNames(), []); + assert.deepEqual(bot._client.eventNames(), []); +}); + +test('scoped cancellation isolates listeners and generation checks across awaits while global cancel reaches all', async () => { + const bot = new EventEmitter(); + const first = create_function_cancellation(bot); + const second = create_function_cancellation(bot); + const otherBot = new EventEmitter(); + const counts = {global: 0, first: 0, second: 0, other: 0}; + const unwatch = on_function_cancel(bot, () => counts.global++); + const unwatchOther = on_function_cancel(otherBot, () => counts.other++); + const globalGeneration = cancellation_generation(bot); + const gate = deferred(); + const watch = (scope, name) => scope.run(async () => { + const generation = cancellation_generation(bot); + await turn(); + const off = on_function_cancel(bot, () => counts[name]++); + try { + await gate.promise; + return cancellation_generation(bot) - generation; + } finally { off(); } + }); + try { + const firstWatch = watch(first, 'first'); + const secondWatch = watch(second, 'second'); + await turn(); + first.cancel(); + assert.deepEqual(counts, {global: 0, first: 1, second: 0, other: 0}); + assert.equal(cancellation_generation(bot), globalGeneration); + first.run(() => cancel_functions(bot)); + assert.deepEqual(counts, {global: 1, first: 2, second: 1, other: 0}); + gate.resolve(); + assert.deepEqual(await Promise.all([firstWatch, secondWatch]), [2, 1]); + first.dispose(); + second.dispose(); + cancel_functions(bot); + assert.deepEqual(counts, {global: 2, first: 2, second: 1, other: 0}); + } finally { + gate.resolve(); + first.dispose(); + second.dispose(); + unwatch(); + unwatchOther(); + } +}); diff --git a/tests/function_defaults.test.mjs b/tests/function_defaults.test.mjs new file mode 100644 index 0000000..8779b2a --- /dev/null +++ b/tests/function_defaults.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {create_function_manager} = await tsImport('../bot/function/init.ts', import.meta.url); +const require = createRequire(import.meta.url); +const mf = createRequire(require.resolve('@wp2508/mineflayer')); +const {Vec3} = mf('vec3'); + +test('manager fills omitted need_look defaults and preserves explicit values', async () => { + const bot = new EventEmitter(); + bot.entity = {id: 1, position: new Vec3(0, 0, 0), eyeHeight: 1.62, height: 1.8}; + bot._client = {}; + bot.entities = {2: {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 1, height: 2}}; + let looks = 0; + let attacks = 0; + bot.lookAt = async () => { looks++; }; + bot.attack = () => { attacks++; }; + const block = {name: 'stone', type: 1, position: new Vec3(2, 1, 0)}; + bot.blockAt = () => block; + bot.activateBlock = async (_block, _face, _delta, forceLook) => assert.equal(forceLook, 'ignore'); + const manager = await create_function_manager({bot_instance: bot, bot_status: 'online'}); + try { + for (const category of manager.get_function_list().filter(category => ['entity', 'block'].includes(category.path))) { + for (const fn of category.function_list) { + assert.equal(fn.args.at(-1).name, 'need_look'); + assert.equal(fn.args.at(-1).default, false); + } + } + assert.equal(await manager.run('entity', 'attack_nearest_entity', ['cow', 3, true]), true); + assert.equal(looks, 0); + assert.equal(await manager.run('entity', 'attack_nearest_entity', ['cow', 3, true, false]), true); + assert.equal(looks, 0); + assert.equal(await manager.run('entity', 'attack_nearest_entity', ['cow', 3, true, true]), true); + assert.equal(looks, 1); + assert.equal(attacks, 3); + assert.equal(await manager.run('block', 'use_position_block', [2, 1, 0, 1]), true); + assert.equal(looks, 1); + assert.equal(await manager.run('block', 'use_position_block', [2, 1, 0, 1, true]), true); + assert.equal(looks, 2); + for (const args of [['cow', 3], ['cow', 3, true, null], ['cow', 3, true, 'true']]) + await assert.rejects(manager.run('entity', 'attack_nearest_entity', args)); + } finally { manager.dispose(); } +}); diff --git a/tests/function_report_session.test.mjs b/tests/function_report_session.test.mjs new file mode 100644 index 0000000..a4bb778 --- /dev/null +++ b/tests/function_report_session.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import vm from 'node:vm'; +import test from 'node:test'; +import {transformSync} from 'esbuild'; + +const source = await readFile(new URL('../service/server/update/function_list.ts', import.meta.url), 'utf8'); +const code = transformSync(source.replace(/^import .*;\n/gm, '') + .replace('export async function', 'async function'), {loader: 'ts'}).code; + +function fixture(response = {status: 'success', data: 0}) { + const reports = []; const logs = []; + const list = [{path: 'inventory', name: 'inventory', i18n: [], function_list: []}]; + const context = { + get_function_list: async () => list, + console_print: {log: value => logs.push(value), success: value => value}, + socket: {connected: true, timeout(ms) {assert.equal(ms, 5000); return this;}, + async emitWithAck(...args) {reports.push(args); return response;}}, + }; + return {context, reports, logs, list, run: () => vm.runInNewContext(`${code}\nupdate_function_list(socket, 1, is_current);`, + {...context, is_current: () => context.socket.connected && context.current !== false})}; +} + +test('function list reports grouped metadata and validates success, error and malformed ACKs', async () => { + const f = fixture(); await f.run(); + assert.equal(f.reports[0][0], 'update_function_list'); + assert.equal(f.reports[0][1].id, 1); + assert.equal(f.reports[0][1].function_list, f.list); + assert.equal(f.logs.length, 1); + for (const response of [undefined, null, {}, {status: 'unknown'}, {status: 'error', data: 403}]) { + const failed = fixture(response); + if (response === undefined) failed.context.socket.emitWithAck = async () => undefined; + await assert.rejects(failed.run(), /函数列表上报失败/); + assert.equal(failed.logs.length, 0); + } +}); + +test('function list never reports a late scan or ACK through a replaced session', async () => { + const f = fixture(); let finish; + f.context.get_function_list = () => new Promise(resolve => {finish = resolve;}); + const task = f.run(); f.context.current = false; finish(f.list); await task; + assert.equal(f.reports.length, 0); + const late = fixture(); let ack; let started; + const emitted = new Promise(resolve => {started = resolve;}); + late.context.socket.emitWithAck = () => new Promise(resolve => {ack = resolve; started();}); + const reporting = late.run(); await emitted; late.context.current = false; + ack({status: 'success'}); await reporting; + assert.equal(late.logs.length, 0); +}); diff --git a/tests/inventory_info.test.mjs b/tests/inventory_info.test.mjs new file mode 100644 index 0000000..904c0a2 --- /dev/null +++ b/tests/inventory_info.test.mjs @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {test} from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {init: InventoryInfo} = await tsImport('../bot/info/bot/inventory.ts', import.meta.url); +const {create_info_manager} = await tsImport('../bot/info/init.ts', import.meta.url); +const require = createRequire(import.meta.url); +const mf = createRequire(require.resolve('@wp2508/mineflayer')); +const registry = mf('prismarine-registry')('1.21.4'); +const windows = mf('prismarine-windows')(registry); +const Item = mf('prismarine-item')(registry); + +function createBot() { + const bot = new EventEmitter(); + bot._client = new EventEmitter(); + bot.inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory'); + bot.currentWindow = null; + bot.quickBarSlot = 2; + bot.registry = registry; + return bot; +} + +test('inventory snapshots cover all nine window kinds and special properties without operations', t => { + t.mock.timers.enable({apis: ['setTimeout']}); + const bot = createBot(); + const info = new InventoryInfo(bot); + t.after(() => info.dispose()); + const tick = () => t.mock.timers.tick(50); + const open = (type, id = 1) => { + const window = windows.createWindow(id, `minecraft:${type}`, type); + bot.currentWindow = window; + bot.emit('windowOpen', window); + tick(); + return window; + }; + const property = (id, value) => bot._client.emit('craft_progress_bar', { + windowId: bot.currentWindow.id, property: id, value, + }); + + assert.equal(info.get_info().window.kind, 'inventory'); + assert.equal(info.get_info().handSlot, 38); + assert.equal(bot.listenerCount('end'), 0); + for (const [type, kind, handSlot] of [ + ['generic_9x3', 'chest', 56], + ['generic_9x6', 'large_chest', 83], + ['crafting', 'crafting', 39], + ['enchantment', 'enchantment', 31], + ['stonecutter', 'stonecutter', 31], + ['furnace', 'furnace', 32], + ['merchant', 'merchant', 32], + ['lectern', 'lectern', null], + ]) { + const previous = bot.currentWindow; + const window = open(type); + assert.equal(info.get_info().window.kind, kind); + assert.equal(info.get_info().handSlot, handSlot); + if (previous) assert.equal(previous.listenerCount('updateSlot'), 0); + if (kind === 'lectern') { + assert.equal(info.get_info().window.hasPlayerInventory, false); + assert.equal(info.get_info().window.inventoryStart, null); + assert.equal(info.get_info().window.slots.length, 1); + } + assert.equal(window.listenerCount('updateSlot'), 1); + } + + open('furnace'); + assert.equal(info.get_info().special.furnace.fuelRatio, null); + property(0, 40); + property(1, 80); + property(2, 50); + property(3, 200); + tick(); + assert.equal(info.get_info().special.furnace.fuelRatio, 0.5); + assert.equal(info.get_info().special.furnace.progressRatio, 0.25); + open('furnace'); + assert.deepEqual(info.get_info().window.properties, {}); + + // Properties can arrive before windowOpen, while Mineflayer awaits slot contents. + bot.currentWindow = windows.createWindow(2, 'minecraft:enchantment', 'Enchant'); + property(0, 30); + property(4, 5); + property(7, 3); + bot.emit('windowOpen', bot.currentWindow); + tick(); + const option = info.get_info().special.enchantment.options[0]; + assert.deepEqual(option, {index: 0, requiredLevel: 30, levelCost: 1, lapisCost: 1, enchantmentId: 5, enchantmentLevel: 3}); + assert.equal(info.get_info().special.enchantment.options[1].requiredLevel, null); + + const merchant = open('merchant'); + assert.equal(info.get_info().special.merchant.ready, false); + merchant.trades = [{ + inputItem1: new Item(registry.itemsByName.emerald.id, 2), + outputItem: new Item(registry.itemsByName.bread.id, 1), + hasItem2: false, realPrice: 1, tradeDisabled: false, nbTradeUses: 0, maximumNbTradeUses: 12, + }]; + bot._client.emit('trade_list'); + tick(); + const offers = info.get_info().special.merchant.offers; + assert.equal(offers[0].input1.type, registry.itemsByName.emerald.id); + assert.equal(offers[0].realPrice, 1); + assert.equal(offers[0].input2, null); + + const lectern = open('lectern'); + const book = new Item(registry.itemsByName.written_book.id, 1); + book.components = [{type: 'written_book_content', data: { + rawTitle: 'Title', author: 'Author', pages: [{content: { + type: 'compound', value: {text: {type: 'string', value: 'Page'}, color: {type: 'string', value: 'red'}}, + }}], + }}]; + lectern.updateSlot(0, book); + property(0, 0); + tick(); + assert.equal(info.get_info().special.lectern.book.title, 'Title'); + assert.equal(info.get_info().special.lectern.book.pages[0][0].message, 'Page'); + assert.equal(info.get_info().special.lectern.book.pages[0][0].font_color, '#FF5555'); + assert.equal(info.get_info().special.lectern.page, 0); + const oldBook = { + type: book.type, name: 'written_book', displayName: 'Written Book', count: 1, metadata: 0, + nbt: {type: 'compound', value: { + title: {type: 'string', value: 'Legacy'}, author: {type: 'string', value: 'Writer'}, + pages: {type: 'list', value: {type: 'string', value: ['{"text":"Old page"}']}}, + long: {type: 'long', value: 123n}, bytes: Buffer.from([1, 2]), + }}, + }; + lectern.slots[0] = oldBook; + lectern.emit('updateSlot'); + tick(); + assert.equal(info.get_info().special.lectern.book.pages[0][0].message, 'Old page'); + assert.equal(info.get_info().window.slots[0].details.extra.legacy.long, '123'); + assert.deepEqual(info.get_info().window.slots[0].details.extra.legacy.bytes, [1, 2]); + assert.equal('nbt' in info.get_info().window.slots[0], false); + assert.doesNotThrow(() => JSON.stringify(info.get_info())); + assert.equal('icon' in info.get_info().window.slots[0], false); + + const cyclic = {number: 1n}; + cyclic.self = cyclic; + Object.defineProperty(cyclic, 'accessor', {enumerable: true, get() {throw new Error('must not invoke');}}); + oldBook.components = [{type: 'custom_data', data: cyclic}]; + lectern.emit('updateSlot'); + tick(); + const data = info.get_info().window.slots[0].details.extra.custom_data; + assert.deepEqual(data, {number: '1', self: null, accessor: null}); + assert.equal(info.get_info().window.slots[0].truncated, true); + + bot.currentWindow = null; + bot.emit('windowClose', lectern); + tick(); + assert.equal(info.get_info().window.kind, 'inventory'); + assert.deepEqual(info.get_info().special, {}); + assert.equal(lectern.listenerCount('updateSlot'), 0); + let updates = 0; + const unsubscribe = info.on_update(() => updates++); + bot.inventory.selectedItem = new Item(registry.itemsByName.stone.id, 3); + bot._client.emit('set_slot'); + tick(); + assert.equal(info.get_info().cursorItem.count, 3); + assert.equal(info.get_info().cursorItem.slot, null); + bot.emit('physicsTick'); + tick(); + assert.equal(updates, 1); + unsubscribe(); + bot.emit('physicsTick'); + info.dispose(); + tick(); + assert.equal(bot.eventNames().length, 0); + assert.equal(bot._client.eventNames().length, 0); + assert.equal(bot.inventory.listenerCount('updateSlot'), 0); + assert.throws(() => info.on_update(() => {}), /已销毁/); +}); + +test('the real info manager loads inventory and owns its cleanup', async () => { + const bot = createBot(); + const manager = await create_info_manager(bot); + assert.equal(bot.listenerCount('end'), 1); + assert.equal(manager.get_info('inventory').value.window.kind, 'inventory'); + assert.ok(manager.get_info('inventory').i18n.fields.window.fields.slots.fields.type); + bot.emit('end'); + manager.dispose(); + assert.equal(bot.eventNames().length, 0); + assert.equal(bot._client.eventNames().length, 0); + assert.equal(bot.inventory.listenerCount('updateSlot'), 0); +}); diff --git a/tests/inventory_item.test.mjs b/tests/inventory_item.test.mjs new file mode 100644 index 0000000..d6f5e1d --- /dev/null +++ b/tests/inventory_item.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {test} from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {parse_inventory_item: parse} = await tsImport('../utils/inventory_item.ts', import.meta.url); +const require = createRequire(import.meta.url); +const mf = createRequire(require.resolve('@wp2508/mineflayer')); +const registry = mf('prismarine-registry')('1.21.4'); +const Item = mf('prismarine-item')(registry); +const component = (type, data) => ({type, data}); +const tag = (type, value) => ({type, value}); +const plain = item => parse(item, 36, registry); + +test('modern custom names and lore become rich logs with explicit false and no executable events', () => { + const item = new Item(registry.itemsByName.clock.id, 8); + item.components = [component('custom_name', tag('compound', { + text: tag('string', ''), bold: tag('byte', 1), italic: tag('byte', 1), + extra: tag('list', {type: 'compound', value: [{ + text: tag('string', '菜单'), color: tag('string', 'gold'), + bold: tag('byte', 0), italic: tag('byte', 0), underlined: tag('byte', 0), + clickEvent: tag('compound', {action: tag('string', 'run_command'), value: tag('string', '/op me')}), + }]}), + })), component('lore', [tag('compound', {text: tag('string', '右键打开'), color: tag('string', 'gray')}), tag('string', '')])]; + const result = plain(item); + assert.equal(result.count, 8); + assert.deepEqual(result.i18n, [{lang: 'zh-CN', name: '时钟', description: ''}]); + assert.equal(result.displayName[0].message, '菜单'); + assert.equal(result.displayName[0].font_color, '#FFAA00'); + assert.deepEqual(result.displayName[0].font_style, ['normal']); + assert.equal(result.displayName[0].clickEvent, undefined); + assert.equal(result.lore[0][0].message, '右键打开'); + assert.deepEqual(result.lore[1], []); + assert.equal('components' in result, false); + assert.equal('nbt' in result, false); + assert.equal(result.truncated, false); +}); + +test('legacy NBT resolves names enchants damage and remaining unknown tags', () => { + const item = { + type: registry.itemsByName.diamond_sword.id, name: 'diamond_sword', count: 1, displayName: 'Diamond Sword', + nbt: tag('compound', { + display: tag('compound', {Name: tag('string', '{"text":"剑","color":"aqua"}'), Lore: tag('list', {type: 'string', value: ['{"text":"描述"}']})}), + Enchantments: tag('list', {type: 'compound', value: [{id: tag('string', 'minecraft:sharpness'), lvl: tag('short', 5)}]}), + Damage: tag('int', 20), Unbreakable: tag('byte', 1), HideFlags: tag('int', 1), + plugin: tag('compound', {menu: tag('string', 'main'), timestamp: tag('long', 123n)}), + }), + }; + const result = plain(item); + assert.equal(result.displayName[0].message, '剑'); + assert.equal(result.lore[0][0].message, '描述'); + assert.deepEqual(result.enchants, [{name: 'sharpness', i18n: [{lang: 'zh-CN', name: '锋利', description: ''}], level: 5, hidden: true}]); + assert.equal(result.durability.used, 20); + assert.equal(result.durability.remaining, result.durability.max - 20); + assert.equal(result.unbreakable, true); + assert.deepEqual(result.details.extra.legacy.plugin, {menu: 'main', timestamp: '123'}); + assert.equal(result.details.extra.legacy.Enchantments, undefined); +}); + +test('modern enchantments potions attributes and flags have structured values', () => { + const item = new Item(registry.itemsByName.diamond_sword.id, 1); + const id = registry.enchantmentsByName.sharpness.id; + item.components = [ + component('item_name', {text: '物品名称'}), + component('enchantments', {enchantments: [{id, level: 4}], showTooltip: false}), + component('max_damage', 100), component('damage', 12), component('unbreakable', {}), + component('enchantment_glint_override', false), + component('potion_contents', {potionId: 3, customColor: 255, customEffects: [{id: 0, amplifier: 1, duration: 200}]}), + component('attribute_modifiers', {showTooltip: false, attributes: [{typeId: 1, name: 'bonus', value: 3, operation: 'add', slot: 'main_hand'}]}), + ]; + const result = plain(item); + assert.equal(result.displayName[0].message, '物品名称'); + assert.deepEqual(result.enchants, [{id, name: 'sharpness', i18n: [{lang: 'zh-CN', name: '锋利', description: ''}], level: 4, hidden: true}]); + assert.deepEqual(result.durability, {used: 12, max: 100, remaining: 88}); + assert.equal(result.glint, false); + assert.equal(result.details.potion.effects[0].amplifier, 1); + assert.equal(result.details.potion.effects[0].duration, 200); + assert.deepEqual(result.details.attributes.modifiers[0], {type: 1, name: 'bonus', amount: 3, operation: 'add', slot: 'main_hand', hidden: false, displayName: []}); +}); + +test('nested modern containers and legacy bundles use parsed items rather than protocol slots', () => { + const item = new Item(registry.itemsByName.shulker_box.id, 1); + item.components = [component('container', {contents: [{itemCount: 4, itemId: registry.itemsByName.clock.id, + components: [component('custom_name', {text: '嵌套时钟'})]}]})]; + const result = plain(item); + assert.equal(result.details.container[0].slot, 0); + assert.equal(result.details.container[0].count, 4); + assert.equal(result.details.container[0].displayName[0].message, '嵌套时钟'); + const legacy = {type: registry.itemsByName.bundle.id, name: 'bundle', count: 1, + nbt: tag('compound', {Items: tag('list', {type: 'compound', value: [{id: tag('string', 'minecraft:stone'), Count: tag('byte', 3)}]})})}; + assert.equal(plain(legacy).details.bundle[0].name, 'stone'); + assert.equal(plain(legacy).details.bundle[0].count, 3); +}); + +test('written and writable books retain different page semantics and truncation is explicit', () => { + const written = new Item(registry.itemsByName.written_book.id, 1); + written.components = [component('written_book_content', {rawTitle: 'Book', author: 'Author', pages: [{content: tag('compound', {text: tag('string', 'Page')})}]})]; + assert.equal(plain(written).book.pages[0][0].message, 'Page'); + const writable = new Item(registry.itemsByName.writable_book.id, 1); + writable.components = [component('writable_book_content', {pages: [{content: '{"text":"literal"}'}]})]; + assert.equal(plain(writable).book.pages[0][0].message, '{"text":"literal"}'); + written.components.push(component('custom_data', {long: 'x'.repeat(100_000)})); + assert.equal(plain(written).truncated, true); + assert.doesNotThrow(() => JSON.stringify(plain(written))); + const cycle = {}; cycle.self = cycle; + written.components = [component('custom_data', cycle)]; + assert.equal(plain(written).details.extra.custom_data.self, null); + assert.equal(plain(written).truncated, true); +}); + +test('legacy section colors and current tooltip component IDs are resolved', () => { + const legacy = {type: 1, count: 1, name: 'stone', displayName: 'Stone', + nbt: tag('compound', {display: tag('compound', {Name: tag('string', '§6§l菜单§r 普通')})})}; + const result = plain(legacy); + assert.equal(result.displayName[0].message, '菜单'); + assert.equal(result.displayName[0].font_color, '#FFAA00'); + assert.deepEqual(result.displayName[0].font_style, ['bold']); + const latest = mf('prismarine-registry')('26.2'); + const mappings = latest.protocol.types.SlotComponentType[1].mappings; + const enchantComponentId = Number(Object.keys(mappings).find(id => mappings[id] === 'enchantments')); + const item = {type: latest.itemsByName.diamond_sword.id, count: 1, components: [ + component('enchantments', {enchantments: [{id: 0, level: 1}]}), + component('tooltip_display', {hideTooltip: false, hiddenComponents: [enchantComponentId]}), + component('attribute_modifiers', [{typeId: 0, name: 'test', value: 2, operation: 'add', slot: 'main_hand', display: {type: 'hidden'}}]), + ]}; + const parsed = parse(item, 0, latest); + assert.equal(parsed.enchants[0].hidden, true); + assert.equal(parsed.details.attributes.modifiers[0].amount, 2); + assert.equal(parsed.details.attributes.modifiers[0].hidden, true); + assert.deepEqual(parsed.details.tooltip.hiddenComponents, ['enchantments']); +}); + +test('item Chinese names use block keys then item keys and fall back to the identifier', () => { + assert.equal(plain({type: 1, name: 'stone', count: 1}).i18n[0].name, '石头'); + assert.equal(plain({type: 1, name: 'diamond_sword', count: 1}).i18n[0].name, '钻石剑'); + assert.equal(plain({type: 1, name: 'plugin_unknown_item', count: 1}).i18n[0].name, 'plugin_unknown_item'); + const nested = plain({type: registry.itemsByName.bundle.id, name: 'bundle', count: 1, + components: [component('bundle_contents', {contents: [{itemId: 1, itemCount: 2}]})]}); + assert.equal(nested.details.bundle[0].i18n[0].name, '石头'); +}); + +test('stored and unknown enchantments include translated names or identifier fallbacks', () => { + const parsed = plain({type: registry.itemsByName.enchanted_book.id, count: 1, components: [component('stored_enchantments', { + enchantments: [{id: 'minecraft:mending', level: 1}, {id: 'plugin_custom', level: 2}, {id: 999999, level: 3}], + })]}); + assert.equal(parsed.enchants[0].i18n[0].name, '经验修补'); + assert.equal(parsed.enchants[1].i18n[0].name, 'plugin_custom'); + assert.equal(parsed.enchants[2].i18n[0].name, '999999'); +}); diff --git a/tests/item_functions.test.mjs b/tests/item_functions.test.mjs new file mode 100644 index 0000000..ff498e4 --- /dev/null +++ b/tests/item_functions.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {init: DropAll} = await tsImport('../bot/function/item/drop_slot_all_item.ts', import.meta.url); +const {init: DropNumber} = await tsImport('../bot/function/item/drop_slot_item_number.ts', import.meta.url); +const {init: UseItem} = await tsImport('../bot/function/item/use_item.ts', import.meta.url); +const require = createRequire(import.meta.url); +const mf = createRequire(require.resolve('@wp2508/mineflayer')); +const registry = mf('prismarine-registry')('1.21.4'); +const windows = mf('prismarine-windows')(registry); +const Item = mf('prismarine-item')(registry); + +function botFixture(container = false) { + const inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory'); + const currentWindow = container ? windows.createWindow(1, 'minecraft:generic_9x3', 'Chest') : null; + const window = currentWindow ?? inventory; + window.updateSlot(9, new Item(1, 5)); + const clicks = []; + return {inventory, currentWindow, clicks, async clickWindow(slot, mouseButton, mode) { + clicks.push([slot, mouseButton, mode]); + window.acceptClick({slot, mouseButton, mode, item: window.slots[slot]}, 0); + }}; +} + +test('drop functions use native drop clicks in inventory and container without closing', async () => { + for (const container of [false, true]) { + const bot = botFixture(container); + const window = bot.currentWindow ?? bot.inventory; + assert.equal(await new DropNumber().run(bot, [9, 2]), true); + assert.equal(window.slots[9].count, 3); + assert.deepEqual(bot.clicks, [[9, 0, 4], [9, 0, 4]]); + assert.equal(await new DropAll().run(bot, [9]), true); + assert.equal(window.slots[9], null); + assert.deepEqual(bot.clicks.at(-1), [9, 1, 4]); + assert.equal(await new DropAll().run(bot, [9]), false); + } + const bot = botFixture(); + assert.equal(await new DropNumber().run(bot, [9, 100]), true); + assert.equal(bot.clicks.length, 5); + assert.equal(bot.inventory.slots[9], null); +}); + +test('drop functions reject invalid inputs, cursor items, failures and changed windows', async () => { + for (const [fn, valid, invalid] of [ + [new DropAll(), [9], [[], [-1], [46], [1.5], [NaN], [9, 1]]], + [new DropNumber(), [9, 2], [[], [9], [-1, 2], [9, 0], [9, -1], [9, 1.5], [9, Infinity]]], + ]) { + const bot = botFixture(); + for (const args of invalid) assert.equal(await fn.run(bot, args), false); + assert.equal(bot.clicks.length, 0); + bot.inventory.selectedItem = new Item(2, 1); + assert.equal(await fn.run(bot, valid), false); + bot.inventory.selectedItem = null; + bot.clickWindow = async () => { throw Error('rejected'); }; + assert.equal(await fn.run(bot, valid), false); + let calls = 0; + bot.clickWindow = async () => { calls++; bot.currentWindow = {}; }; + assert.equal(await fn.run(bot, valid), false); + assert.equal(calls, 1); + } + const bot = botFixture(); + bot.clickWindow = async () => {}; + assert.equal(await new DropNumber().run(bot, [9, 2]), false); + assert.equal(await new DropAll().run(bot, [9]), false); +}); + +test('using an item selects the requested hand and rejects empty or unsupported hands', async () => { + const bot = botFixture(); + const used = []; + bot.heldItem = new Item(1, 1); + bot.inventory.slots[45] = new Item(2, 1); + bot.supportFeature = () => false; + bot.activateItem = hand => used.push(hand); + const fn = new UseItem(); + assert.equal(await fn.run(bot, [false]), true); + assert.equal(await fn.run(bot, [true]), true); + assert.deepEqual(used, [false, true]); + for (const args of [[], [0], ['false'], [false, true]]) assert.equal(await fn.run(bot, args), false); + bot.heldItem = null; + assert.equal(await fn.run(bot, [false]), false); + bot.supportFeature = () => true; + assert.equal(await fn.run(bot, [true]), false); + bot.supportFeature = () => false; + bot.inventory.slots[45] = null; + assert.equal(await fn.run(bot, [true]), false); + bot.heldItem = new Item(1, 1); + bot.activateItem = () => { throw Error('failed'); }; + assert.equal(await fn.run(bot, [false]), false); + assert.deepEqual(used, [false, true]); +}); diff --git a/tests/logger_close.test.mjs b/tests/logger_close.test.mjs new file mode 100644 index 0000000..67b0e9d --- /dev/null +++ b/tests/logger_close.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import {mkdtemp, readdir, readFile, rm, writeFile, stat} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const compiled = await build({ + entryPoints: [fileURLToPath(new URL('../utils/logger.ts', import.meta.url))], + bundle: true, + platform: 'node', + format: 'esm', + write: false, +}); +const {Logger} = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString('base64')}`); + +test('closing logger drains pending records, closes file and ignores late logs', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-logger-')); + try { + const logger = new Logger(directory); + for (let i = 0; i < 20; i++) logger.log([logger.info(String(i))]); + const closed = logger.close(); + assert.equal(logger.close(), closed); + logger.log([logger.info('late')]); + await closed; + const [name] = await readdir(directory); + const lines = (await readFile(path.join(directory, name), 'utf8')).trim().split('\n'); + assert.deepEqual(lines.map(line => JSON.parse(line).log[0].message), + Array.from({length: 20}, (_, i) => String(i))); + } finally { + await rm(directory, {recursive: true, force: true}); + } +}); + +test('disk history sorts dates and numeric rotations and skips malformed records', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-history-')); + const record = id => ({id, timestamp: 1, log: [{message: id, font_color: '#ffffff', font_style: ['normal']}]}); + try { + await Promise.all([ + writeFile(path.join(directory, '2026-10-16_log_99.log'), JSON.stringify(record('older')) + '\n'), + writeFile(path.join(directory, '2026-10-17_log.log'), JSON.stringify(record('base')) + '\n'), + writeFile(path.join(directory, '2026-10-17_log_9.log'), JSON.stringify(record('nine')) + '\n'), + writeFile(path.join(directory, '2026-10-17_log_10.log'), [ + JSON.stringify(record('ten')), 'broken json', 'null', '{}', + '{"id":"bad","timestamp":"1","log":[]}', + JSON.stringify(record('latest')), + ].join('\n')), + writeFile(path.join(directory, 'unrelated.log'), JSON.stringify(record('ignored'))), + ]); + assert.deepEqual((await Logger.read_recent_log(directory, 10)).map(item => item.id), + ['older', 'base', 'nine', 'ten', 'latest']); + assert.deepEqual(await Logger.read_recent_log(directory, 2), [record('ten'), record('latest')]); + assert.deepEqual(await Logger.read_recent_log(directory, 0), []); + const missing = path.join(directory, 'missing'); + assert.deepEqual(await Logger.read_recent_log(missing, 10), []); + await assert.rejects(stat(missing), {code: 'ENOENT'}); + } finally { + await rm(directory, {recursive: true, force: true}); + } +}); + +test('disk history handles chunk boundaries, oversized lines and a bounded read budget', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-history-')); + const record = (id, message) => ({id, timestamp: 1, log: [{message, font_color: '#ffffff', font_style: ['normal']}]}); + try { + const first = record('first', '中文'.repeat(25000)); + const last = record('last', 'tail'); + const filename = path.join(directory, '2026-10-17_log.log'); + await writeFile(filename, [JSON.stringify(first), 'x'.repeat(1024 * 1024 + 1), JSON.stringify(last), ''].join('\n')); + assert.deepEqual(await Logger.read_recent_log(directory, 10), [first, last]); + await writeFile(filename, JSON.stringify(first) + '\n' + 'x'.repeat(17 * 1024 * 1024) + '\n' + JSON.stringify(last)); + assert.deepEqual(await Logger.read_recent_log(directory, 10), [last]); + } finally { + await rm(directory, {recursive: true, force: true}); + } +}); + +test('instance history includes immediate in-flight and queued records, deduplicates disk and survives close', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-history-')); + const logger = new Logger(directory); + try { + for (let i = 0; i < 100; i++) logger.log([logger.info(String(i))]); + const immediate = logger.read_recent_log(100); + const recent = logger.read_recent_log(3); + const closed = logger.close(); + const closing = logger.read_recent_log(100); + const records = await immediate; + assert.deepEqual(records.map(record => record.log[0].message), + Array.from({length: 100}, (_, i) => String(i))); + assert.equal(new Set(records.map(record => record.id)).size, 100); + assert.ok(records.every(record => typeof record.timestamp === 'number')); + assert.deepEqual((await recent).map(record => record.log[0].message), ['97', '98', '99']); + assert.deepEqual(await closing, records); + await closed; + assert.deepEqual(await Logger.read_recent_log(directory, 100), records); + assert.deepEqual(await logger.read_recent_log(100), records); + } finally { + await logger.close(); + await rm(directory, {recursive: true, force: true}); + } +}); diff --git a/tests/main_server_reconnect.test.mjs b/tests/main_server_reconnect.test.mjs new file mode 100644 index 0000000..5ebf0e5 --- /dev/null +++ b/tests/main_server_reconnect.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import vm from 'node:vm'; +import test from 'node:test'; +import {transformSync} from 'esbuild'; + +const source = await readFile(new URL('../index.ts', import.meta.url), 'utf8'); +const lifecycle = source.slice(source.indexOf(' let reconnect_timer:'), source.indexOf(" main_server_socket.on('connect',")); +const disconnect = source.slice(source.indexOf(" main_server_socket.on('disconnect',"), source.indexOf(' main_server_socket.onAny(')); +const code = transformSync(`${lifecycle}\n${disconnect}`, {loader: 'ts'}).code; + +function setup(enabled = true, max_attempts = 2) { + const timers = new Map(); + const handlers = new Map(); + const state = {connects: 0, disconnects: 0}; + const process = {once() {}}; + let id = 0; + vm.runInNewContext(code, { + auto_reconnect: {enabled, max_attempts, interval: 100}, + reconnect_interval: 100, + main_server_socket: { + on: (event, handler) => handlers.set(event, handler), + connect: () => state.connects++, + disconnect: () => state.disconnects++, + }, + console_print: {log() {}, info() {}, error() {}, warning() {}}, + clear_node_session() {}, + get_bot_context_status: async () => [], + stop_bot_context: async () => {}, + process, + setTimeout(callback, delay) { + if (delay === 10_000) return ++id; + assert.equal(delay, 100); + timers.set(++id, callback); + return id; + }, + clearTimeout: id => timers.delete(id), + }); + return { + state, process, timers, + disconnect: reason => handlers.get('disconnect')(reason), + tick() { + assert.equal(timers.size, 1); + const [id, callback] = timers.entries().next().value; + timers.delete(id); + callback(); + }, + }; +} + +test('server disconnect retries indefinitely without duplicate timers after the configured limit', () => { + const app = setup(); + for (let attempt = 0; attempt < 12; attempt++) { + app.disconnect('io server disconnect'); + app.disconnect('io server disconnect'); + app.tick(); + assert.equal(app.state.connects, attempt + 1); + } + app.disconnect('io server disconnect'); + assert.equal(app.state.disconnects, 0); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); +}); + +test('disabled reconnect and a zero attempt limit still retry every disconnect reason', () => { + const app = setup(false, 0); + for (const reason of ['io server disconnect', 'io client disconnect', 'transport close', 'ping timeout', 'transport error']) { + app.disconnect(reason); + app.tick(); + } + assert.equal(app.state.connects, 5); + app.disconnect('io client disconnect'); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); +}); diff --git a/tests/message_json_parser.test.mjs b/tests/message_json_parser.test.mjs new file mode 100644 index 0000000..7bfc095 --- /dev/null +++ b/tests/message_json_parser.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const compiled = await build({ + entryPoints: [fileURLToPath(new URL('../utils/message_json_parser.ts', import.meta.url))], + bundle: true, + platform: 'node', + format: 'esm', + write: false, +}); +const {MinecraftJsonParser: parser} = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString('base64')}`); + +test('log fragments inherit styles and respect explicit false / zero overrides', () => { + const input = {text: 'parent', color: 'red', bold: 1, extra: [ + {text: 'child', italic: true}, + {text: 'plain', bold: 0}, + {text: 'tail', bold: false}, + ]}; + assert.deepEqual(parser.toLog(input), [ + {message: 'parent', font_color: '#FF5555', font_style: ['bold']}, + {message: 'child', font_color: '#FF5555', font_style: ['bold', 'italic']}, + {message: 'plaintail', font_color: '#FF5555', font_style: ['normal']}, + ]); + assert.equal(input.extra[1].bold, 0); +}); + +test('translations keep parameter styles, trailing extras and chat message contents', () => { + const parsed = parser.parse({translate: 'chat.type.text', color: 'gold', with: [ + {text: 'Alex', color: 'green'}, 'hello » world', + ], extra: ['!']}); + assert.equal(parsed.plainText, ' hello » world!'); + assert.equal(parsed.username, 'Alex'); + assert.equal(parsed.message, 'hello » world!'); + assert.equal(parsed.log[1].font_color, '#55FF55'); + assert.equal(parsed.log.at(-1).font_color, '#FFAA00'); + assert.equal('html' in parsed, false); + assert.equal(parser.toHTML, undefined); +}); + +test('fallback translations support indexed parameters, literal percent and missing arguments', () => { + assert.equal(parser.extractPlainText({translate: 'missing.key', fallback: '%2$s %% %s %3$s', + with: [0, false]}), 'false % 0 %3$s'); +}); + +test('ChatMessage uses lossless raw JSON, including arrays and inherited URL style', () => { + const raw = [{text: '链接', color: '#123456', click_event: {action: 'open_url', url: 'https://example.com'}}, + '😀', {text: '<&>', underlined: 1}]; + const parsed = parser.parse({text: 'lossy', json: raw, warn() {}}); + assert.equal(parsed.plainText, '链接😀<&>'); + assert.deepEqual(parsed.log, [ + {message: '链接😀', font_color: '#123456', font_style: ['url'], clickEvent: {action: 'open_url', value: 'https://example.com'}}, + {message: '<&>', font_color: '#123456', font_style: ['underline', 'url'], clickEvent: {action: 'open_url', value: 'https://example.com'}}, + ]); +}); + +test('empty, primitive and cyclic inputs are bounded and preserve valid text', () => { + assert.deepEqual(parser.toLog(null), []); + assert.equal(parser.extractPlainText([0, false, '']), '0false'); + const cyclic = {text: 'x'}; + cyclic.extra = [cyclic]; + const text = parser.extractPlainText(cyclic); + assert.ok(text.length > 0 && text.length <= 65); + assert.deepEqual(parser.parseUsernameAndMessage('[rank] Alex » hello » world'), + {username: 'Alex', message: 'hello » world'}); +}); + +test('logs retain inherited hover, click and insertion without merging different events', () => { + const result = parser.toLog({text: '', hover_event: {action: 'show_text', contents: { + translate: 'chat.type.text', with: ['Alex', 'tooltip'], + }}, click_event: {action: 'suggest_command', command: '/msg Alex '}, insertion: 'Alex', extra: [ + {text: 'A'}, {text: 'B', hoverEvent: {action: 'show_item', contents: {id: 'minecraft:stone', count: 1}}}, + {text: 'C', click_event: {action: 'copy_to_clipboard', text: 'copied'}}, + ]}); + assert.equal(result.length, 3); + assert.deepEqual(result[0].hoverEvent, {action: 'show_text', value: ' tooltip', segments: [{message: ' tooltip', font_color: '#FFFFFF', font_style: ['normal']}]}); + assert.deepEqual(result[0].clickEvent, {action: 'suggest_command', value: '/msg Alex '}); + assert.equal(result[0].insertion, 'Alex'); + assert.deepEqual(JSON.parse(result[1].hoverEvent.value), {id: 'minecraft:stone', count: 1}); + assert.deepEqual(result[2].clickEvent, {action: 'copy_to_clipboard', value: 'copied'}); +}); + +test('NBT disconnect reasons unwrap compound, list and string tags', () => { + const reason = {type: 'compound', value: { + text: {type: 'string', value: '登录失败: '}, + color: {type: 'string', value: 'red'}, + extra: {type: 'list', value: {type: 'compound', value: [ + {text: {type: 'string', value: '连接超时'}, bold: {type: 'byte', value: 1}}, + ]}}, + }}; + assert.equal(parser.extractPlainText(reason), '登录失败: 连接超时'); + assert.equal(parser.toLog(reason)[1].font_color, '#FF5555'); + assert.deepEqual(parser.toLog(reason)[1].font_style, ['bold']); + assert.equal(parser.extractPlainText({type: 'string', value: '服务器已关闭'}), '服务器已关闭'); +}); + +test('hover text preserves inherited colors and formats without recursive events', () => { + const result = parser.toLog({text: 'player', hoverEvent: {action: 'show_text', contents: { + text: '称号 ', color: 'gold', bold: true, extra: [ + {text: 'Alex', color: 'aqua', bold: false, italic: true, hoverEvent: {action: 'show_text', value: 'nested'}, clickEvent: {action: 'run_command', value: '/nested'}}, + ], + }}}); + assert.equal(result[0].hoverEvent.value, '称号 Alex'); + assert.deepEqual(result[0].hoverEvent.segments, [ + {message: '称号 ', font_color: '#FFAA00', font_style: ['bold']}, + {message: 'Alex', font_color: '#55FFFF', font_style: ['italic']}, + ]); + const long = parser.toLog({text: 'player', hoverEvent: {action: 'show_text', contents: {text: 'x'.repeat(5000)}}}); + assert.equal(long[0].hoverEvent.segments[0].message.length, 4096); +}); diff --git a/tests/node_registration.test.mjs b/tests/node_registration.test.mjs new file mode 100644 index 0000000..03dd5e8 --- /dev/null +++ b/tests/node_registration.test.mjs @@ -0,0 +1,857 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {setImmediate} from 'node:timers/promises'; +import vm from 'node:vm'; +import test from 'node:test'; +import {transformSync} from 'esbuild'; +import {Buffer} from 'node:buffer'; + +const source = await readFile(new URL('../index.ts', import.meta.url), 'utf8'); +const code = transformSync(source.replace(/^import .*;\n/gm, ''), {loader: 'ts'}).code; +const configured = JSON.parse(await readFile(new URL('../config.json', import.meta.url), 'utf8')); +const relay_test_key = 'node-registration-test-secret-not-production'; +const success = node_session_id => ({status: 'success', data: {name: 'Node', node_session_id}}); +const deferred = () => { + let resolve, reject; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return {promise, resolve, reject}; +}; + +async function setup({forward = false, start, plugins, functions, workflow_catalog, versions, status, init, connect_socket, stop, contexts = [], stop_bot, + node = configured.node, auto_reconnect = {...configured.main_server.auto_reconnect, interval: 100}} = {}) { + const handlers = new Map(); + const signals = new Map(); + const timers = new Map(); + const timer_delays = new Map(); + const deadlines = new Map(); + const exits = []; + const stopped_bots = []; + const requests = []; + const bindings = []; + const logs = []; + const updates = []; + const workflow_reports = []; + const controls = []; + const relay_options = []; + const order = []; + let timer_id = 0; + let any; + let shutting_down = false; + const state = {sockets: 0, connects: 0, disconnects: 0, clears: 0, stops: 0, releases: 0, events: 0}; + const socket = { + connected: false, + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event).add(handler); + }, + off(event, handler) { handlers.get(event)?.delete(handler); }, + onAny(handler) { any = handler; }, + connect() { state.connects++; connect_socket?.(); }, + disconnect() { + state.disconnects++; + if (!socket.connected) return; + socket.connected = false; + dispatch('disconnect', 'io client disconnect'); + }, + timeout(ms) { assert.equal(ms, 5000); return socket; }, + emitWithAck(event, payload) { + const pending = deferred(); + requests.push({event, payload, ...pending}); + order.push(event); + return pending.promise; + }, + emit() {}, + }; + function dispatch(event, ...args) { + for (const handler of [...(handlers.get(event) ?? [])]) handler(...args); + } + let release; + const handle = { + bind_control(control) { + release?.(); + controls.push(control); + order.push('bind_control'); + const listener = (...args) => { state.events++; args.at(-1)?.(); }; + control.on('forward_open_session', listener); + let active = true; + release = () => { + if (!active) return; + active = false; + state.releases++; + control.off('forward_open_session', listener); + }; + return release; + }, + async stop() { state.stops++; release?.(); await stop?.(); order.push('relay_stopped'); }, + }; + const process = { + once: (name, handler) => signals.set(name, handler), + exit: code => { exits.push(code); order.push('exit'); }, + }; + const config = { + ...configured, node: {...node}, + main_server: {...configured.main_server, auto_reconnect}, + forward_server: {...configured.forward_server, key: relay_test_key, status: forward}, + }; + vm.runInNewContext(code, { + Buffer, + config, + io: (_url, options) => { + assert.equal(options.reconnection, false); + state.sockets++; + return socket; + }, + init_event_handler_list: async () => { await init?.(); }, + get_bot_context_status: async () => contexts, + stop_bot_context: async bot_id => { + stopped_bots.push(bot_id); + await stop_bot?.(bot_id); + order.push(`bot_stopped:${bot_id}`); + }, + bind_node_session: (bound_socket, node_id, session) => { + assert.equal(bound_socket, socket); + bindings.push({node_id, session}); + }, + clear_node_session: bound_socket => { + assert.equal(bound_socket, socket); + state.clears++; + }, + update_plugins: async () => { updates.push('plugins'); order.push('plugins'); await plugins?.(); }, + update_function_list: async () => { updates.push('functions'); order.push('functions'); await functions?.(); }, + update_workflow_catalog: async (bound_socket, node_id, is_current) => { + assert.equal(bound_socket, socket); + assert.equal(node_id, config.node.id); + assert.equal(is_current(), true); + workflow_reports.push({is_current}); + updates.push('workflow_catalog'); + order.push('workflow_catalog'); + await workflow_catalog?.(); + }, + update_version_list: async () => { updates.push('versions'); order.push('versions'); await versions?.(); }, + update_all_bot_status: async () => { updates.push('bots'); order.push('bots'); await status?.(); }, + event_handler: async () => { state.events++; }, + start_forward_server: async options => { + relay_options.push(options); + order.push('start'); + const ready = start ? await start(handle) : handle; + ready.bind_control(options.control); + return ready; + }, + console_print: { + log: message => logs.push(message), + info: message => message, error: message => message, + success: message => message, warning: message => message, + }, + process, + setTimeout: (callback, delay) => { + if (shutting_down) { + assert.equal(delay, 10_000); + deadlines.set(++timer_id, callback); + return timer_id; + } + const interval = auto_reconnect?.interval; + assert.equal(delay, Number.isInteger(interval) && interval > 0 && interval <= 2_147_483_647 ? interval : 10_000); + timers.set(++timer_id, callback); + timer_delays.set(timer_id, delay); + return timer_id; + }, + clearTimeout: id => { timers.delete(id); timer_delays.delete(id); deadlines.delete(id); }, + }); + await setImmediate(); + return { + state, socket, process, config, timers, timer_delays, requests, bindings, logs, updates, workflow_reports, controls, + relay_options, order, handle, dispatch, deadlines, exits, stopped_bots, + connect() { socket.connected = true; dispatch('connect'); }, + disconnect() { socket.connected = false; dispatch('disconnect', 'transport close'); }, + event(event, ...args) { any(event, ...args); }, + signal(name) { shutting_down = true; signals.get(name)(); }, + tick() { + assert.equal(timers.size, 1); + const [id, callback] = timers.entries().next().value; + timers.delete(id); + timer_delays.delete(id); + callback(); + }, + }; +} + +test('registration binds the exact structured ACK and replaces sessions on reconnect and re-authentication', async () => { + const app = await setup(); + app.event('bot_start', {}); + assert.equal(app.state.events, 0); + app.connect(); + assert.equal(app.state.clears, 1); + assert.equal(app.requests[0].event, 'register_node'); + app.requests[0].resolve(success('first-session')); + await setImmediate(); + assert.deepEqual(app.bindings, [{node_id: configured.node.id, session: 'first-session'}]); + const stages = ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots']; + assert.deepEqual(app.updates, stages); + assert.deepEqual(app.order, ['register_node', ...stages]); + assert.equal(app.workflow_reports.length, 1); + assert.equal(app.workflow_reports[0].is_current(), true); + app.event('bot_start', {}); + assert.equal(app.state.events, 1); + app.disconnect(); + assert.equal(app.state.clears, 2); + assert.equal(app.workflow_reports[0].is_current(), false); + app.event('bot_start', {}); + assert.equal(app.state.events, 1); + app.tick(); + app.connect(); + app.requests[1].resolve(success('second-session')); + await setImmediate(); + assert.deepEqual(app.updates, [...stages, ...stages]); + assert.equal(app.workflow_reports.length, 2); + assert.equal(app.workflow_reports[1].is_current(), true); + app.connect(); + assert.equal(app.state.clears, 4); + app.requests[2].resolve(success('third-session')); + await setImmediate(); + assert.deepEqual(app.bindings.map(binding => binding.session), ['first-session', 'second-session', 'third-session']); + assert.deepEqual(app.updates, [...stages, ...stages, ...stages]); + assert.equal(app.workflow_reports.length, 3); + assert.equal(app.workflow_reports[1].is_current(), false); + assert.equal(app.workflow_reports[2].is_current(), true); +}); + +test('workflow catalog reporting is awaited before versions, Bot status, and relay startup', async () => { + const pending = deferred(); + const app = await setup({forward: true, workflow_catalog: () => pending.promise}); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog']); + assert.equal(app.workflow_reports.length, 1); + assert.equal(app.relay_options.length, 0); + assert.equal(app.requests.length, 1); + pending.resolve(); + await setImmediate(); + assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots']); + assert.deepEqual(app.order, ['register_node', ...app.updates, 'start', 'bind_control', 'bind_control', 'register_forward']); + assert.equal(app.workflow_reports.length, 1); + app.requests[1].resolve({status: 'success', data: 'Relay'}); + await setImmediate(); + assert.equal(app.timers.size, 0); +}); + +test('workflow catalog rejection and ACK timeout retry registration and report again after recovery', async () => { + for (const failure of ['report rejected secret-token', 'ACK timeout secret-token']) { + const pending = deferred(); + let calls = 0; + const app = await setup({forward: true, contexts: [{bot_id: 1}], + workflow_catalog: () => ++calls === 1 ? pending.promise : undefined}); + app.connect(); + app.requests[0].resolve(success('failed-session')); + await setImmediate(); + pending.reject(new Error(failure)); + await setImmediate(); + assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog']); + assert.equal(app.relay_options.length, 0); + assert.equal(app.socket.connected, false); + assert.equal(app.state.disconnects, 1); + assert.equal(app.timers.size, 1); + assert.equal(app.workflow_reports[0].is_current(), false); + assert.ok(app.logs.some(message => message.includes('阶段: 工作流目录上报'))); + assert.ok(!app.logs.join('\n').includes('secret-token')); + assert.equal(app.state.stops, 0); + assert.deepEqual(app.stopped_bots, []); + assert.deepEqual(app.exits, []); + assert.equal(app.process.exitCode, undefined); + app.tick(); + app.connect(); + assert.equal(app.requests[1].event, 'register_node'); + app.requests[1].resolve(success('recovered-session')); + await setImmediate(); + assert.equal(calls, 2); + assert.equal(app.workflow_reports.length, 2); + assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog', + 'plugins', 'functions', 'workflow_catalog', 'versions', 'bots']); + assert.equal(app.requests[2].event, 'register_forward'); + app.requests[2].resolve({status: 'success', data: 'Relay'}); + await setImmediate(); + assert.equal(app.socket.connected, true); + assert.equal(app.timers.size, 0); + assert.equal(app.state.stops, 0); + assert.deepEqual(app.stopped_bots, []); + assert.deepEqual(app.exits, []); + } +}); + +test('stale workflow catalog completion or failure cannot continue stages or disconnect a newer session', async () => { + for (const rejected of [false, true]) { + const pending = deferred(); + let calls = 0; + const app = await setup({forward: true, + workflow_catalog: () => ++calls === 1 ? pending.promise : undefined}); + app.connect(); + app.requests[0].resolve(success('old-session')); + await setImmediate(); + assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog']); + app.disconnect(); + app.tick(); + app.connect(); + app.requests[1].resolve(success('new-session')); + await setImmediate(); + app.requests[2].resolve({status: 'success', data: 'Relay'}); + await setImmediate(); + assert.equal(app.workflow_reports[0].is_current(), false); + assert.equal(app.workflow_reports[1].is_current(), true); + const order = [...app.order]; + if (rejected) pending.reject(new Error('ACK timeout secret-token')); + else pending.resolve(); + await setImmediate(); + assert.deepEqual(app.order, order); + assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog', + 'plugins', 'functions', 'workflow_catalog', 'versions', 'bots']); + assert.equal(app.workflow_reports.length, 2); + assert.equal(app.socket.connected, true); + assert.equal(app.state.disconnects, 0); + assert.equal(app.timers.size, 0); + assert.deepEqual(app.bindings.map(binding => binding.session), ['old-session', 'new-session']); + assert.equal(app.relay_options.length, 1); + assert.equal(app.requests.length, 3); + assert.equal(app.process.exitCode, undefined); + assert.ok(!app.logs.join('\n').includes('阶段: 工作流目录上报')); + assert.ok(!app.logs.join('\n').includes('secret-token')); + } +}); + +test('failed relay startup reports a safe reason, retries startup, and registers after recovery', async () => { + let attempts = 0; + const app = await setup({forward: true, contexts: [{bot_id: 1}], start: async handle => { + if (++attempts === 1) throw new Error('A relay secret of at least 16 characters is required'); + return handle; + }}); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + assert.ok(app.logs.some(message => message.includes('阶段: 转发服务器启动') && message.includes('至少需要 16 个字符'))); + assert.ok(!app.logs.some(message => message.includes('节点关闭失败或超时'))); + assert.equal(app.state.stops, 0); + assert.equal(app.deadlines.size, 0); + assert.equal(app.process.exitCode, undefined); + assert.equal(app.timers.size, 1); + assert.deepEqual(app.stopped_bots, []); + app.tick(); + app.connect(); + app.requests[1].resolve(success('recovered-session')); + await setImmediate(); + assert.equal(attempts, 2); + assert.equal(app.relay_options.length, 2); + assert.equal(app.requests[2].event, 'register_forward'); + app.requests[2].resolve({status: 'success', data: 'Relay'}); + await setImmediate(); + assert.equal(app.timers.size, 0); + assert.equal(app.state.stops, 0); + assert.deepEqual(app.stopped_bots, []); + assert.deepEqual(app.exits, []); +}); + +test('relay listen errors disclose only safe error category and initialization stage', async () => { + for (const code of ['EADDRINUSE', 'EACCES']) { + const app = await setup({forward: true, start: async () => { + throw Object.assign(new Error('SECRET config/token/stack'), {code}); + }}); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + assert.ok(app.logs.some(message => message.includes('阶段: 转发服务器启动') + && message.includes(code === 'EADDRINUSE' ? '端口已被占用' : '没有权限'))); + assert.ok(!app.logs.join('\n').includes('SECRET')); + assert.ok(!app.logs.join('\n').includes('节点关闭失败或超时')); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); + } +}); + +test('legacy, missing and extra registration fields never authenticate and retry safely', async () => { + for (const response of [ + {status: 'success', data: 'legacy name'}, + {status: 'success', data: {name: 'Node'}}, + {status: 'success', data: {name: 42, node_session_id: 'session'}}, + {status: 'success', data: {name: 'Node', node_session_id: ' '}}, + success('x'.repeat(257)), + success('\u00e9'.repeat(129)), + {status: 'success', data: {name: 'Node', node_session_id: 'session', token: 'secret-token'}}, + {...success('session'), token: 'secret-token'}, + ]) { + const app = await setup(); + app.connect(); + app.requests[0].resolve(response); + await setImmediate(); + assert.equal(app.bindings.length, 0); + assert.equal(app.updates.length, 0); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); + assert.ok(!app.logs.join('\n').includes('secret-token')); + } +}); + +test('registration accepts a 256-byte UTF-8 session ID and rejects unsafe configured Node IDs', async () => { + const app = await setup(); + app.connect(); + app.requests[0].resolve(success('\u00e9'.repeat(128))); + await setImmediate(); + assert.equal(app.bindings[0].session, '\u00e9'.repeat(128)); + const invalid = await setup({node: {...configured.node, id: Number.MAX_SAFE_INTEGER + 1}}); + assert.equal(invalid.state.connects, 0); + assert.equal(invalid.state.sockets, 0); + assert.equal(invalid.requests.length, 0); + assert.equal(invalid.timers.size, 1); + assert.equal(invalid.process.exitCode, undefined); + invalid.config.node.id = configured.node.id; + invalid.tick(); + await setImmediate(); + assert.equal(invalid.state.sockets, 1); + assert.equal(invalid.state.connects, 1); +}); + +test('handler loading failures retry bootstrap without creating a socket and recover after success', async () => { + let attempts = 0; + const app = await setup({init: async () => { + if (++attempts <= 3) throw new Error('secret-token'); + }}); + for (let attempt = 1; attempt <= 3; attempt++) { + assert.equal(attempts, attempt); + assert.equal(app.state.sockets, 0); + assert.equal(app.state.connects, 0); + assert.equal(app.requests.length, 0); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); + app.tick(); + await setImmediate(); + } + assert.equal(attempts, 4); + assert.equal(app.state.sockets, 1); + assert.equal(app.state.connects, 1); + assert.equal(app.timers.size, 0); + assert.deepEqual(app.exits, []); + assert.ok(!app.logs.join('\n').includes('secret-token')); +}); + +test('missing or invalid reconnect settings use the default interval and keep retrying', async () => { + for (const auto_reconnect of [null, {}, {interval: 0}, {interval: -1}, {interval: 1.5}, + {interval: '100'}, {interval: 2_147_483_648}, {enabled: false, max_attempts: -1, interval: 250}]) { + const app = await setup({auto_reconnect}); + assert.equal(app.state.sockets, 1); + assert.equal(app.state.connects, 1); + app.dispatch('connect_error', new Error('secret-token')); + assert.deepEqual([...app.timer_delays.values()], [auto_reconnect?.interval === 250 ? 250 : 10_000]); + app.tick(); + app.dispatch('connect_error', new Error('secret-token')); + assert.equal(app.state.connects, 2); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); + assert.deepEqual(app.exits, []); + } +}); + +test('synchronous initial and recurring connect failures keep retrying until connection succeeds', async () => { + let attempts = 0; + const app = await setup({auto_reconnect: {enabled: false, max_attempts: 0, interval: 100}, + connect_socket: () => { + if (++attempts <= 4) throw new Error('secret-token'); + }}); + for (let attempt = 1; attempt <= 4; attempt++) { + assert.equal(app.state.connects, attempt); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); + app.tick(); + } + assert.equal(app.state.connects, 5); + assert.equal(app.state.sockets, 1); + assert.equal(app.timers.size, 0); + app.connect(); + app.requests[0].resolve(success('recovered-session')); + await setImmediate(); + assert.deepEqual(app.bindings.map(binding => binding.session), ['recovered-session']); + assert.deepEqual(app.exits, []); + assert.ok(!app.logs.join('\n').includes('secret-token')); +}); + +test('plugin, function, version, and Bot status failures retry initialization and recover without stopping Bots', async () => { + for (const stage of ['plugins', 'functions', 'versions', 'status']) { + let calls = 0; + const app = await setup({contexts: [{bot_id: 1}], [stage]: async () => { + if (++calls === 1) throw new Error('secret-token'); + }}); + app.connect(); + app.requests[0].resolve(success('failed-session')); + await setImmediate(); + assert.equal(app.socket.connected, false); + assert.equal(app.timers.size, 1); + assert.equal(app.state.stops, 0); + assert.deepEqual(app.stopped_bots, []); + assert.equal(app.process.exitCode, undefined); + assert.ok(!app.logs.join('\n').includes('secret-token')); + app.event('bot_start', {}); + assert.equal(app.state.events, 0); + app.tick(); + app.connect(); + app.requests[1].resolve(success('recovered-session')); + await setImmediate(); + assert.equal(calls, 2); + assert.deepEqual(app.updates.slice(-5), ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots']); + assert.equal(app.timers.size, 0); + assert.equal(app.socket.connected, true); + app.event('bot_start', {}); + assert.equal(app.state.events, 1); + assert.deepEqual(app.stopped_bots, []); + assert.deepEqual(app.exits, []); + } +}); + +test('node registration refusals and ACK timeouts continue retrying beyond disabled and exhausted settings', async () => { + const app = await setup({contexts: [{bot_id: 1}], + auto_reconnect: {enabled: false, max_attempts: 1, interval: 100}}); + for (let attempt = 0; attempt < 6; attempt++) { + app.connect(); + const request = app.requests[attempt]; + assert.equal(request.event, 'register_node'); + if (attempt % 2) request.reject(new Error('ACK timeout secret-token')); + else request.resolve({status: 'error', data: 401}); + await setImmediate(); + assert.equal(app.bindings.length, 0); + assert.equal(app.updates.length, 0); + assert.equal(app.timers.size, 1); + assert.equal(app.process.exitCode, undefined); + assert.deepEqual(app.stopped_bots, []); + app.tick(); + } + app.connect(); + app.requests[6].resolve(success('recovered-session')); + await setImmediate(); + assert.deepEqual(app.bindings.map(binding => binding.session), ['recovered-session']); + assert.equal(app.timers.size, 0); + assert.deepEqual(app.exits, []); + assert.ok(!app.logs.join('\n').includes('secret-token')); +}); + +test('stale registration ACKs and failures cannot replace a newer session or terminate it', async () => { + for (const rejected of [false, true]) { + const app = await setup(); + app.connect(); + app.disconnect(); + app.tick(); + app.connect(); + app.requests[1].resolve(success('current-session')); + await setImmediate(); + if (rejected) app.requests[0].reject(new Error('secret-token')); + else app.requests[0].resolve(success('obsolete-session')); + await setImmediate(); + assert.deepEqual(app.bindings.map(binding => binding.session), ['current-session']); + assert.equal(app.state.disconnects, 0); + assert.equal(app.process.exitCode, undefined); + assert.ok(!app.logs.join('\n').includes('secret-token')); + } +}); + +test('registration refusal and transport errors do not log response objects, URLs, or tokens', async () => { + const app = await setup(); + const secret = 'https://private.example/?token=secret-token'; + app.dispatch('connect_error', new Error(secret)); + app.dispatch('error', {error: secret}); + app.connect(); + app.requests[0].resolve({status: 'error', data: {token: secret}}); + await setImmediate(); + assert.equal(app.process.exitCode, undefined); + assert.equal(app.timers.size, 1); + assert.equal(app.bindings.length, 0); + assert.ok(!app.logs.join('\n').includes(secret)); + assert.ok(!app.logs.join('\n').includes(configured.main_server.host)); +}); + +test('hosted relay starts once, registers only when ready, rebinds control, and accepts other target Nodes', async () => { + const ready = deferred(); + const app = await setup({forward: true, start: () => ready.promise}); + app.connect(); + app.requests[0].resolve(success('host-session')); + await setImmediate(); + assert.equal(app.requests.length, 1); + assert.equal(app.relay_options.length, 1); + const options = app.relay_options[0]; + assert.equal(options.forward_id, configured.forward_server.id); + assert.equal(options.key, relay_test_key); + assert.ok(options.key.length >= 32); + assert.deepEqual(options.allowed_origins, configured.forward_server.allowed_origins); + assert.equal(options.path, configured.forward_server.path); + assert.equal(options.namespace, configured.forward_server.namespace); + assert.equal(options.http_server, undefined); + ready.resolve(app.handle); + await setImmediate(); + assert.equal(app.requests[1].event, 'register_forward'); + assert.ok(app.order.indexOf('bind_control') < app.order.indexOf('register_forward')); + const stale_control_callback = app.controls.at(-1); + const listener = () => app.state.events++; + stale_control_callback.on('test_control', listener); + app.dispatch('forward_open_session', {node_id: 900, node_session_id: 'other-node-session'}, () => {}); + assert.equal(app.state.events, 1); + app.requests[1].resolve({status: 'success', data: 'Relay'}); + await setImmediate(); + app.disconnect(); + app.dispatch('test_control'); + assert.equal(app.state.events, 1); + app.tick(); + app.connect(); + app.requests[2].resolve(success('host-session-2')); + await setImmediate(); + app.dispatch('test_control'); + assert.equal(app.state.events, 1); + assert.equal(app.relay_options.length, 1); + assert.equal(app.requests[3].event, 'register_forward'); + app.dispatch('forward_open_session', {node_id: 901, node_session_id: 'third-node-session'}, () => {}); + assert.equal(app.state.events, 2); + stale_control_callback.off('test_control', listener); + app.signal('SIGINT'); + await setImmediate(); + assert.equal(app.state.stops, 1); + assert.equal(app.process.exitCode, 0); +}); + +test('disconnect during relay startup does not duplicate startup or register the stale connection', async () => { + const ready = deferred(); + const app = await setup({forward: true, start: () => ready.promise}); + app.connect(); + app.requests[0].resolve(success('old-session')); + await setImmediate(); + app.disconnect(); + app.tick(); + app.connect(); + app.requests[1].resolve(success('new-session')); + await setImmediate(); + ready.resolve(app.handle); + await setImmediate(); + assert.equal(app.relay_options.length, 1); + assert.equal(app.requests.filter(request => request.event === 'register_forward').length, 1); + assert.equal(app.state.stops, 0); +}); + +test('SIGTERM waits for pending relay startup, stops its handle, and never registers', async () => { + const ready = deferred(); + const app = await setup({forward: true, start: () => ready.promise}); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + app.signal('SIGTERM'); + assert.deepEqual(app.exits, []); + ready.resolve(app.handle); + await setImmediate(); + assert.equal(app.state.stops, 1); + assert.equal(app.requests.length, 1); + assert.equal(app.process.exitCode, 0); + assert.equal(app.timers.size, 0); + assert.deepEqual(app.exits, [0]); + app.signal('SIGINT'); + await setImmediate(); + assert.equal(app.state.stops, 1); + assert.deepEqual(app.exits, [0]); +}); + +test('explicit signals await relay and all BotContext cleanup before one controlled exit', async () => { + for (const signal of ['SIGINT', 'SIGTERM']) { + const relay_stopped = deferred(); + const bots_stopped = deferred(); + const app = await setup({ + forward: true, stop: () => relay_stopped.promise, + contexts: [{bot_id: 1}, {bot_id: 2}], stop_bot: () => bots_stopped.promise, + }); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + app.signal(signal); + await setImmediate(); + assert.deepEqual(app.stopped_bots, [1, 2]); + assert.equal(app.state.stops, 1); + assert.deepEqual(app.exits, []); + bots_stopped.resolve(); + await setImmediate(); + assert.deepEqual(app.exits, []); + relay_stopped.resolve(); + await setImmediate(); + assert.deepEqual(app.exits, [0]); + assert.ok(app.order.indexOf('relay_stopped') < app.order.indexOf('exit')); + assert.equal(app.deadlines.size, 0); + } +}); + +test('signal shutdown has a bounded deadline even if relay startup or Bot cleanup never settles', async () => { + const never = deferred(); + const app = await setup({ + forward: true, start: () => never.promise, + contexts: [{bot_id: 1}], stop_bot: () => never.promise, + }); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + app.signal('SIGTERM'); + await setImmediate(); + assert.deepEqual(app.exits, []); + assert.deepEqual(app.stopped_bots, [1]); + assert.equal(app.deadlines.size, 1); + [...app.deadlines.values()][0](); + await setImmediate(); + assert.deepEqual(app.exits, [1]); + assert.equal(app.deadlines.size, 0); + assert.equal(app.requests.length, 1); +}); + +test('signal cleanup failures wait for remaining cleanup and exit without leaking error contents', async () => { + const bots_stopped = deferred(); + const app = await setup({ + forward: true, stop: async () => { throw new Error('secret-token'); }, + contexts: [{bot_id: 1}], stop_bot: () => bots_stopped.promise, + }); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + app.signal('SIGINT'); + await setImmediate(); + assert.deepEqual(app.exits, []); + bots_stopped.resolve(); + await setImmediate(); + assert.deepEqual(app.exits, [1]); + assert.ok(!app.logs.join('\n').includes('secret-token')); +}); + +test('relay registration errors and ACK timeouts retry full registration with the same relay and Bots', async () => { + for (const failure of [486, {token: 'secret-token'}, 'timeout']) { + const app = await setup({forward: true, contexts: [{bot_id: 1}, {bot_id: 2}]}); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + if (failure === 'timeout') app.requests[1].reject(new Error('ACK timeout secret-token')); + else app.requests[1].resolve({status: 'error', data: failure}); + await setImmediate(); + assert.equal(app.state.stops, 0); + assert.equal(app.socket.connected, false); + assert.equal(app.state.disconnects, 1); + assert.ok(app.state.clears >= 2); + assert.equal(app.process.exitCode, undefined); + assert.equal(app.timers.size, 1); + assert.deepEqual(app.stopped_bots, []); + assert.deepEqual(app.exits, []); + assert.ok(!app.logs.join('\n').includes('secret-token')); + app.tick(); + app.connect(); + assert.equal(app.requests[2].event, 'register_node'); + app.requests[2].resolve(success('recovered-session')); + await setImmediate(); + assert.equal(app.requests[3].event, 'register_forward'); + assert.equal(app.relay_options.length, 1); + app.requests[3].resolve({status: 'success', data: 'Relay'}); + await setImmediate(); + assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots', + 'plugins', 'functions', 'workflow_catalog', 'versions', 'bots']); + assert.equal(app.timers.size, 0); + assert.equal(app.state.stops, 0); + assert.deepEqual(app.stopped_bots, []); + assert.equal(app.process.exitCode, undefined); + } +}); + +test('recurring errors keep retrying past disabled and exhausted settings without stopping relay or Bots', async () => { + const app = await setup({forward: true, contexts: [{bot_id: 5}, {bot_id: 6}], + auto_reconnect: {enabled: false, max_attempts: 1, interval: 100}}); + app.connect(); + app.requests[0].resolve(success('session')); + await setImmediate(); + app.requests[1].resolve({status: 'success', data: 'Relay'}); + await setImmediate(); + app.disconnect(); + for (let attempt = 0; attempt < 12; attempt++) { + app.tick(); + app.dispatch('connect_error', new Error('secret-token')); + assert.equal(app.timers.size, 1); + } + await setImmediate(); + assert.equal(app.state.stops, 0); + assert.equal(app.process.exitCode, undefined); + assert.equal(app.timers.size, 1); + assert.deepEqual(app.stopped_bots, []); + assert.deepEqual(app.exits, []); + assert.ok(!app.logs.join('\n').includes('secret-token')); +}); + +test('retry attempt count resets only after initialization and relay registration fully succeed', async () => { + const app = await setup({forward: true, auto_reconnect: {enabled: false, max_attempts: 1, interval: 100}}); + for (let attempt = 0; attempt < 4; attempt++) { + app.connect(); + app.requests[attempt * 2].resolve(success(`session-${attempt}`)); + await setImmediate(); + app.requests[attempt * 2 + 1].resolve(attempt < 3 + ? {status: 'error', data: 486} + : {status: 'success', data: 'Relay'}); + await setImmediate(); + if (attempt < 3) app.tick(); + } + app.disconnect(); + app.tick(); + const attempts = app.logs.flatMap(message => { + const match = message.match(/正在尝试重连主服务器,第 (\d+) 次/); + return match ? [Number(match[1])] : []; + }); + assert.deepEqual(attempts, [1, 2, 3, 1]); + assert.equal(app.relay_options.length, 1); + assert.equal(app.state.stops, 0); + assert.equal(app.process.exitCode, undefined); +}); + +test('signals cancel a pending retry and later connection callbacks cannot restart the Node', async () => { + for (const signal of ['SIGINT', 'SIGTERM']) { + const app = await setup({contexts: [{bot_id: 1}]}); + app.dispatch('connect_error', new Error('secret-token')); + assert.equal(app.timers.size, 1); + app.signal(signal); + await setImmediate(); + assert.equal(app.timers.size, 0); + assert.deepEqual(app.stopped_bots, [1]); + assert.deepEqual(app.exits, [0]); + app.dispatch('connect_error', new Error('secret-token')); + app.dispatch('error', new Error('secret-token')); + app.dispatch('disconnect', 'io client disconnect'); + app.connect(); + await setImmediate(); + assert.equal(app.timers.size, 0); + assert.equal(app.requests.length, 0); + assert.equal(app.state.connects, 1); + assert.deepEqual(app.exits, [0]); + } +}); + +test('stale relay registration failures cannot stop a newly authenticated connection', async () => { + const app = await setup({forward: true}); + app.connect(); + app.requests[0].resolve(success('old-session')); + await setImmediate(); + app.disconnect(); + app.tick(); + app.connect(); + app.requests[2].resolve(success('new-session')); + await setImmediate(); + app.requests[1].reject(new Error('secret-token')); + await setImmediate(); + assert.equal(app.state.stops, 0); + assert.equal(app.process.exitCode, undefined); + assert.equal(app.relay_options.length, 1); + assert.ok(!app.logs.join('\n').includes('secret-token')); +}); + +test('obsolete initialization failures cannot stop a re-authenticated connection', async () => { + const pending = deferred(); + let calls = 0; + const app = await setup({plugins: () => ++calls === 1 ? pending.promise : undefined}); + app.connect(); + app.requests[0].resolve(success('old-session')); + await setImmediate(); + app.disconnect(); + app.tick(); + app.connect(); + app.requests[1].resolve(success('new-session')); + await setImmediate(); + pending.reject(new Error('secret-token')); + await setImmediate(); + assert.equal(app.state.disconnects, 0); + assert.equal(app.process.exitCode, undefined); + assert.ok(!app.logs.join('\n').includes('secret-token')); +}); diff --git a/tests/plugin_handlers.test.mjs b/tests/plugin_handlers.test.mjs new file mode 100644 index 0000000..e49b375 --- /dev/null +++ b/tests/plugin_handlers.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; +import {tsImport} from 'tsx/esm/api'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const compiled = await build({ + stdin: {contents: `export {handler as inject} from './event_handler/bot/inject_plugin.ts'; + export {handler as uninject} from './event_handler/bot/uninject_plugin.ts'; + export {handler as update} from './event_handler/bot/update_plugin_config.ts'; + export {PluginManager, PluginError} from './bot/plugins/init.ts'; + export {validate_code_map as codes} from './utils/validate_code_map.ts'; + export * from './event_handler/init.ts';`, resolveDir: root, loader: 'ts'}, + bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, + define: {'import.meta.url': JSON.stringify(new URL('../event_handler/init.ts', import.meta.url).href)}, + plugins: [{name: 'context', setup(build) { + build.onResolve({filter: /bot\/instance(?:\/index\.ts)?$/}, () => ({path: 'context', namespace: 'mock'})); + build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({contents: 'export const get_bot_context = id => globalThis.lookup(id);'})); + }}], +}); + +function setup() { + let context; + const calls = [], emitted = []; + const module = {exports: {}}; + vm.runInNewContext(compiled.outputFiles[0].text, { + module, exports: module.exports, require: createRequire(import.meta.url), URL, process, structuredClone, + lookup(id) { calls.push(['lookup', id]); return context; }, + console: {log() {}, error() { throw new Error('Unexpected logging'); }}, + }); + const api = module.exports; + const manager = Object.fromEntries(['inject', 'uninject', 'update_config'].map(name => [name, async (...args) => { + calls.push([name, ...args]); + }])); + context = {bot_status: 'offline', plugin_manager: manager}; + return {...api, calls, manager, socket: {emit: (...args) => emitted.push(args)}, emitted, + context(value) { context = value; }, + async run(name, data) { + const acks = []; + await api[name](this.socket, data, result => acks.push(structuredClone(result))); + assert.equal(acks.length, 1); return acks[0]; + }}; +} +const dto = operation => operation === 'inject' ? {bot_id: 1, path: 'runtime', name: 'auto_reconnect', config: {}} + : operation === 'update' ? {bot_id: 1, name: 'auto_reconnect', config: {}} : {bot_id: 1, name: 'auto_reconnect'}; + +test('missing ACK emits method_not_allowed and never inspects DTO or context', async () => { + const s = setup(); + const data = new Proxy({}, {get() { throw new Error('must not read'); }}); + for (const name of ['inject', 'uninject', 'update']) { + for (const ack of [undefined, null, {}, 1]) await s[name](s.socket, data, ack); + } + assert.equal(s.calls.length, 0); + assert.equal(s.emitted.length, 12); + for (const [event, payload] of s.emitted) { + assert.equal(event, 'error'); assert.equal(payload.error, s.codes.web.method_not_allowed); + } +}); + +test('exact ordinary DTO validation rejects malformed requests without getters or side effects', async () => { + const s = setup(); + for (const name of ['inject', 'uninject', 'update']) { + const good = dto(name); + const getter = Object.defineProperty({...good}, 'bot_id', {enumerable: true, get() { throw new Error('getter secret'); }}); + const malformed = [null, [], {}, {...good, extra: true}, {...good, bot_id: 0}, {...good, bot_id: 1.5}, + {...good, bot_id: Number.MAX_SAFE_INTEGER + 1}, {...good, name: '../bad'}, + Object.assign(Object.create({inherited: true}), good), Object.assign(Object.create(null), good), getter, + new Proxy(good, {}), {...good, [Symbol('extra')]: 1}, + Object.assign(Object.create(Object.assign(Object.create(null), {constructor: Object})), good), + Object.assign(Object.create(new Proxy({}, {getOwnPropertyDescriptor() { throw new Error('prototype trap'); }})), good)]; + if (name !== 'uninject') for (const config of [null, [], new Date(), {bad: undefined}, {bad: Infinity}, + Object.defineProperty({}, 'secret', {enumerable: true, get() { throw new Error('secret'); }}), + {bad: Object.create({})}, {bad: Array(2)}, {bad: new Proxy([], {})}]) malformed.push({...good, config}); + if (name === 'inject') for (const path of ['', '../runtime', '/runtime', 'a//b', 'a\\b', 'a.ts', 'a/%2e']) malformed.push({...good, path}); + for (const data of malformed) assert.deepEqual(await s.run(name, data), {status: 'error', data: s.codes.web.invalid_parameter}); + } + assert.deepEqual(s.calls, []); +}); + +test('offline operations route correct arguments and callback throws never retry', async () => { + const s = setup(); + for (const name of ['inject', 'uninject', 'update']) { + assert.deepEqual(await s.run(name, dto(name)), {status: 'success', data: 0}); + let count = 0; + await s[name](s.socket, dto(name), () => { count++; throw new Error('callback secret'); }); + assert.equal(count, 1); + } + assert.deepEqual(structuredClone(s.calls[1]), ['inject', {path: 'runtime', name: 'auto_reconnect', config: {}}]); + assert.deepEqual(structuredClone(s.calls[5]), ['uninject', 'auto_reconnect']); + assert.deepEqual(structuredClone(s.calls[9]), ['update_config', 'auto_reconnect', {}]); +}); + +test('missing context, manager and typed or unexpected failures produce safe numeric errors', async () => { + const s = setup(); + s.context(undefined); + assert.equal((await s.run('inject', dto('inject'))).data, s.codes.plugin.context_missing); + s.context({}); + assert.equal((await s.run('inject', dto('inject'))).data, s.codes.plugin.manager_missing); + s.context({plugin_manager: s.manager}); + for (const reason of ['INVALID_CONFIG', 'ALREADY_INSTALLED', 'NOT_INSTALLED', 'MANAGER_DISPOSED', 'LOAD_FAILED', 'INIT_FAILED', 'DISPOSE_FAILED']) { + for (const [name, method] of [['inject', 'inject'], ['uninject', 'uninject'], ['update', 'update_config']]) { + s.manager[method] = async () => { throw new s.PluginError(reason); }; + assert.deepEqual(await s.run(name, dto(name)), {status: 'error', data: s.codes.plugin[reason.toLowerCase()]}); + } + } + s.manager.inject = async () => { throw new Error('password=secret'); }; + assert.deepEqual(await s.run('inject', dto('inject')), {status: 'error', data: s.codes.plugin.error}); +}); + +test('handlers replace real offline plugin config using defaults and allow idempotent removal', async () => { + const s = setup(); + const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url); + const listeners = new Map(); + const context = {bot_status: 'offline', on(event, fn) { listeners.set(event, fn); }, + off(event) { listeners.delete(event); }, cancel_reconnect() {}}; + context.plugin_manager = new PluginManager(context); + s.context(context); + assert.equal((await s.run('inject', {...dto('inject'), config: {reconnect_delay: 20, reconnect_max_attempts: 2}})).status, 'success'); + assert.equal((await s.run('update', {...dto('update'), config: {reconnect_delay: 50}})).status, 'success'); + assert.deepEqual(context.plugin_manager.get_plugin('auto_reconnect').config, {reconnect_delay: 50, reconnect_max_attempts: 10}); + assert.equal(listeners.size, 4); + for (let i = 0; i < 2; i++) assert.equal((await s.run('uninject', dto('uninject'))).status, 'success'); + assert.equal(listeners.size, 0); + await context.plugin_manager.dispose(); +}); + +test('dynamic dispatcher loads and invokes all three plugin handler exports', async () => { + const {init_event_handler_list, event_handler} = await tsImport('../event_handler/init.ts', import.meta.url); + await init_event_handler_list(); + const emitted = []; + for (const name of ['inject_plugin', 'uninject_plugin', 'update_plugin_config']) { + await event_handler({emit: (...args) => emitted.push(args)}, name, null); + } + assert.equal(emitted.length, 3); + assert.deepEqual(emitted, Array.from({length: 3}, () => ['error', {error: 481}])); +}); diff --git a/tests/plugin_manager.test.mjs b/tests/plugin_manager.test.mjs new file mode 100644 index 0000000..b220a64 --- /dev/null +++ b/tests/plugin_manager.test.mjs @@ -0,0 +1,387 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import {createRequire} from 'node:module'; +import {readFile} from 'node:fs/promises'; +import {build} from 'esbuild'; +import {tsImport} from 'tsx/esm/api'; + +const url = new URL('../bot/plugins/init.ts', import.meta.url); +const compiled = await build({ + stdin: {contents: (await readFile(url, 'utf8')).replace('return import(url.href);', 'return globalThis.load(url);'), loader: 'ts', resolveDir: new URL('../bot/plugins/', import.meta.url).pathname}, + bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, + define: {'import.meta.url': JSON.stringify(url.href)}, +}); +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; }; +const i18n = [{lang: 'en', name: 'Test', description: ''}]; +const item = (name = 'a', config = {}) => ({path: 'runtime', name, config}); + +function setup() { + const events = []; + const modules = new Map(); + const options = {}; + const module = {exports: {}}; + const load = async url => { + const name = url.pathname.split('/').at(-1).slice(0, -3); + if (options.load_gate) await options.load_gate; + if (name === 'define' && options.directories) return {define: {path: url.pathname.split('/').at(-2), i18n}}; + return modules.get(name) ?? {init: plugin(name)}; + }; + function plugin(name, fields = []) { + return class { + name = name; + config_template = {name, i18n, version: '1', config: fields}; + status = {count: 0}; + listeners = new Set(); + on_states_update(callback) { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + async init(_context, config) { + events.push(`init:${name}`); + this.status.config = config; + if (options.init_gate) await options.init_gate; + if (options.fail === name) throw new Error('install failed'); + } + get_status() { return this.status; } + async dispose() { + events.push(`dispose:${name}`); + if (options.dispose_fail === name) throw new Error('cleanup failed'); + } + }; + } + vm.runInNewContext(compiled.outputFiles[0].text, { + module, exports: module.exports, require: name => name === 'node:fs/promises' + ? {readdir: async (url, config) => options.directories + ? options.directories[url.pathname.split('/').filter(Boolean).at(-1)] ?? [] + : createRequire(import.meta.url)(name).readdir(url, config)} + : createRequire(import.meta.url)(name), load, URL, process, structuredClone, + }); + return {...module.exports, events, modules, options, plugin, manager: new module.exports.PluginManager({})}; +} +const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; + +test('update config looks up installed path inside queue and replaces with template defaults', async () => { + const {manager, modules, plugin, events, options} = setup(); + const fields = ['x', 'y'].map(name => ({name, type: 'number', default: 1, required: true, i18n})); + modules.set('a', {init: plugin('a', fields)}); + const gate = deferred(); options.init_gate = gate.promise; + const injection = manager.inject({...item('a', {x: 9, y: 8}), path: 'nested/category'}); + const update = manager.update_config('a', {x: 3}); + await flush(); assert.deepEqual(events, ['init:a']); + gate.resolve(); await injection; await update; + assert.deepEqual(manager.get_plugin('a').config, {x: 3, y: 1}); + assert.equal(manager.get_plugin('a').path, 'nested/category'); + assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a']); + await assert.rejects(manager.update_config('a', {unknown: 'secret'}), {reason: 'INVALID_CONFIG'}); + assert.deepEqual(manager.get_plugin('a').config, {x: 3, y: 1}); + assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a']); + await manager.uninject('a'); + await assert.rejects(manager.update_config('a', {}), {reason: 'NOT_INSTALLED'}); + await manager.uninject('a'); + await manager.dispose(); + await assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'}); +}); + +test('queued update follows reinjection path and cannot resurrect a disposed context', async () => { + const {manager, options, events} = setup(); + await manager.inject(item()); + const gate = deferred(); options.load_gate = gate.promise; + const replacement = manager.re_inject({...item(), path: 'new/category'}); + const update = manager.update_config('a', {}); + gate.resolve(); await replacement; await update; + assert.equal(manager.get_plugin('a').path, 'new/category'); + const blocked = deferred(); options.load_gate = blocked.promise; + const pending = assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'}); + const queued = assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'}); + await flush(); const disposal = manager.dispose(); + blocked.resolve(); await pending; await queued; await disposal; + assert.equal(manager.get_plugin('a').lifecycle, 'disabled'); + assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a', 'dispose:a', 'init:a', 'dispose:a']); +}); + +test('initialization and concurrent mutations share a queue; snapshots are detached', async () => { + const {manager, events, options} = setup(); + const gate = deferred(); options.init_gate = gate.promise; + const initial = manager.init([item()]); + const second = manager.inject(item('b')); + await flush(); assert.deepEqual(events, ['init:a']); + gate.resolve(); await initial; await second; + const snapshot = manager.get_plugin('a'); snapshot.states.count = 99; snapshot.config.extra = true; + assert.equal(manager.get_plugin('a').states.count, 0); + assert.equal(manager.get_plugin('a').config.extra, undefined); + await assert.rejects(manager.inject(item()), /Duplicate/); + await manager.dispose(); + assert.deepEqual(events, ['init:a', 'init:b', 'dispose:b', 'dispose:a']); +}); + +test('creation failure cleans partial candidate then rolls installed plugins back in reverse', async () => { + const {create_plugin_manager, options, events} = setup(); options.fail = 'c'; + await assert.rejects(create_plugin_manager({}, [item('a'), item('b'), item('c')]), {reason: 'INIT_FAILED'}); + assert.deepEqual(events, ['init:a', 'init:b', 'init:c', 'dispose:c', 'dispose:b', 'dispose:a']); +}); + +test('duplicate initial names fail before installation', async () => { + const {manager, events} = setup(); + await assert.rejects(manager.init([item(), {...item(), path: 'other'}]), /Duplicate/); + assert.deepEqual(events, []); +}); + +test('replacement validates before old disposal; install failure retains error record', async () => { + const {manager, events, options, modules} = setup(); + await manager.inject(item()); + await assert.rejects(manager.re_inject(item('a', {unknown: true})), {reason: 'INVALID_CONFIG'}); + assert.ok(manager.get_plugin('a')); assert.deepEqual(events, ['init:a']); + modules.set('a', {init: 1}); + await assert.rejects(manager.re_inject(item()), {reason: 'LOAD_FAILED'}); + assert.ok(manager.get_plugin('a')); + modules.clear(); options.fail = 'a'; + await assert.rejects(manager.re_inject(item()), {reason: 'INIT_FAILED'}); + assert.equal(manager.get_plugin('a').lifecycle, 'error'); + assert.equal(manager.get_plugin('a').enabled, false); + assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a', 'dispose:a']); +}); + +test('dispose during module load blocks initial, queued and new work', async () => { + const {manager, options, events} = setup(); + const gate = deferred(); options.load_gate = gate.promise; + const first = assert.rejects(manager.inject(item()), /disposed/); + const queued = assert.rejects(manager.inject(item('b')), /disposed/); + await flush(); const disposal = manager.dispose(); + assert.equal(manager.dispose(), disposal); + await assert.rejects(manager.inject(item('c')), /disposed/); + gate.resolve(); await first; await queued; await disposal; + assert.deepEqual(events, []); +}); + +test('dispose waits for in-flight init and cleans candidate; cleanup errors do not skip earlier plugins', async () => { + const {manager, options, events} = setup(); + await manager.init([item('a'), item('b')]); + const gate = deferred(); options.init_gate = gate.promise; options.dispose_fail = 'b'; + const pending = assert.rejects(manager.inject(item('c')), /disposed/); + await flush(); const disposal = manager.dispose(); + const rejected = assert.rejects(disposal, /disposal failed/); + gate.resolve(); await pending; await rejected; + assert.deepEqual(events, ['init:a', 'init:b', 'init:c', 'dispose:c', 'dispose:b', 'dispose:a']); + assert.equal(manager.get_all_plugins().length, 3); + assert.ok(manager.get_all_plugins().every(record => !record.enabled)); +}); + +test('validates all data types, defaults, optional nulls, required values and metadata', async () => { + const {manager, modules, plugin} = setup(); + const values = {string: 'x', boolean: false, number: 3, datetime: '2026-10-20T12:00:00Z', + 'string-select': 'x', 'number-select': 1, 'string-multi-select': ['x'], 'number-multi-select': [1], + 'string-array': ['x'], 'number-array': [1]}; + const fields = Object.entries(values).map(([type, value], index) => ({name: `f${index}`, type, default: value, required: true, i18n})); + fields.push({name: 'optional', type: 'string', default: null, required: false, i18n}); + modules.set('a', {init: plugin('a', fields)}); + await manager.inject(item()); + assert.deepEqual(Object.values(manager.get_plugin('a').config), [...Object.values(values), null]); + for (const field of fields.slice(0, -1)) { + for (const bad of [null, {}, undefined]) await assert.rejects(manager.re_inject(item('a', {[field.name]: bad})), /Invalid plugin config/); + } + for (const bad of [NaN, Infinity, '1', [1]]) await assert.rejects(manager.re_inject(item('a', {f2: bad})), /Invalid plugin config/); + for (const bad of [[1], ['x', 1], Array(1)]) await assert.rejects(manager.re_inject(item('a', {f8: bad})), /Invalid plugin config/); + assert.ok(manager.get_plugin('a')); + fields[0].default = 1; + await assert.rejects(manager.re_inject(item()), {reason: 'LOAD_FAILED'}); + await manager.dispose(); +}); + +test('standalone scanner preserves wire metadata and auto reconnect defaults', async () => { + const {get_plugin_list, create_plugin_manager} = await tsImport('../bot/plugins/init.ts', import.meta.url); + const list = await get_plugin_list(); + const reconnect = list.find(item => item.name === 'auto_reconnect'); + assert.deepEqual(Object.keys(reconnect).sort(), ['name', 'path', 'path_config', 'plugin_config_template']); + const listeners = new Map(); + const context = {on(event, fn) { listeners.set(event, fn); }, off(event) { listeners.delete(event); }, cancel_reconnect() {}}; + const manager = await create_plugin_manager(context, [item('auto_reconnect')]); + assert.deepEqual(manager.get_plugin('auto_reconnect').config, {reconnect_delay: 1000, reconnect_max_attempts: 10}); + await manager.dispose(); assert.equal(listeners.size, 0); +}); + +test('scanner skips missing init and rejects invalid exports, metadata and duplicate names', async () => { + const {get_plugin_list, options, modules, plugin, events} = setup(); + const entry = (name, directory = false) => ({name, isDirectory: () => directory, isFile: () => !directory}); + options.directories = {plugins: [entry('runtime', true)], runtime: [entry('define.ts'), entry('empty.ts'), entry('a.ts')]}; + modules.set('define', {define: {path: 'runtime', i18n}}); + modules.set('empty', {}); + assert.equal((await get_plugin_list()).length, 1); + assert.deepEqual(events, []); + modules.set('empty', {init: undefined}); + await assert.rejects(get_plugin_list(), /init class/); + modules.set('empty', {}); + modules.set('a', {init: plugin('wrong')}); + await assert.rejects(get_plugin_list(), /metadata/); + modules.delete('a'); + options.directories.plugins.push(entry('other', true)); + options.directories.other = [entry('define.ts'), entry('a.ts')]; + await assert.rejects(get_plugin_list(), /Duplicate/); +}); + +test('lifecycle updates precede disposal, retain final states and support disabled/error retries', async () => { + const {manager, modules, plugin, options} = setup(); + const Base = plugin('a'); + const instances = []; + modules.set('a', {init: class extends Base { + constructor() { super(); instances.push(this); } + async dispose() { + assert.equal(manager.get_plugin('a').enabled, false); + assert.equal(manager.get_plugin('a').lifecycle, 'disabling'); + this.status.count = 7; + await super.dispose(); + } + }}); + const updates = []; + manager.on_update(() => { throw new Error('listener failure'); }); + manager.on_update(event => { event.states.count = 999; }); + const unsubscribe = manager.on_update(event => updates.push(event)); + await manager.inject(item()); + assert.deepEqual(updates.map(event => [event.lifecycle, event.enabled]), [['initializing', false], ['enabled', true]]); + const count = updates.length; + manager.get_plugin('a'); manager.get_all_plugins(); + assert.equal(updates.length, count); + await manager.uninject('a'); + assert.deepEqual(updates.slice(-2).map(event => event.lifecycle), ['disabling', 'disabled']); + assert.equal(manager.get_plugin('a').states.count, 7); + assert.equal(instances[0].listeners.size, 0); + await manager.uninject('a'); + assert.equal(updates.length, count + 2); + await manager.inject(item()); + assert.notEqual(instances[0], instances[1]); + options.fail = 'a'; + await assert.rejects(manager.update_config('a', {}), {reason: 'INIT_FAILED'}); + assert.equal(manager.get_plugin('a').lifecycle, 'error'); + assert.equal(manager.get_plugin('a').enabled, false); + assert.ok(instances.every(instance => instance.listeners.size === 0)); + assert.equal(JSON.stringify(manager.get_plugin('a')).includes('install failed'), false); + options.fail = undefined; + unsubscribe(); unsubscribe(); + const before = updates.length; + await manager.inject(item()); + assert.equal(updates.length, before); + await manager.dispose(); + assert.equal(manager.get_plugin('a').lifecycle, 'disabled'); + assert.throws(() => manager.on_update(() => {}), /disposed/); +}); + +test('auto reconnect publishes live internal states, isolates clones and stops after unsubscribe/disposal', async () => { + const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url); + const {EventEmitter} = await import('node:events'); + const context = new EventEmitter(); + context.cancel_reconnect = () => {}; + const manager = new PluginManager(context); + const updates = []; + const unsubscribe = manager.on_update(event => updates.push(event)); + await manager.inject(item('auto_reconnect')); + assert.equal(updates.length, 2); + context.emit('status_update', 'online'); + assert.equal(updates.length, 2); + context.emit('reconnect_attempt'); + assert.equal(updates.at(-1).states.attempts, 1); + assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1); + updates.at(-1).states.attempts = 99; + assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1); + context.emit('connection_requested'); + assert.equal(updates.at(-1).states.attempts, 0); + unsubscribe(); unsubscribe(); + const before = updates.length; + context.emit('reconnect_attempt'); + assert.equal(updates.length, before); + assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1); + await manager.uninject('auto_reconnect'); + context.emit('reconnect_attempt'); + assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1); + assert.equal(context.listenerCount('reconnect_attempt'), 0); + await manager.inject(item('auto_reconnect')); + assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 0); + await manager.dispose(); +}); + +test('load failures leave sanitized retryable records while invalid input leaves records unchanged', async () => { + const {manager, modules, plugin} = setup(); + modules.set('a', {init: 1}); + await assert.rejects(manager.inject(item()), {reason: 'LOAD_FAILED'}); + assert.deepEqual(manager.get_plugin('a'), { + name: 'a', path: 'runtime', config: {}, enabled: false, lifecycle: 'error', states: null, + }); + await assert.rejects(manager.inject(item('a', {unknown: () => {}})), {reason: 'INVALID_CONFIG'}); + assert.equal(manager.get_plugin('a').lifecycle, 'error'); + modules.set('a', {init: plugin('a')}); + await manager.inject(item()); + assert.equal(manager.get_plugin('a').enabled, true); + await manager.dispose(); +}); + +test('full snapshot reconcile preserves unchanged instances and resolves omitted defaults', async () => { + const {manager, modules, plugin, events} = setup(); + modules.set('a', {init: plugin('a', [{name: 'x', type: 'number', default: 1, required: true, i18n}])}); + await manager.init([item('a'), item('b')]); + const previous = manager.entries.get('a'); + await manager.apply_desired([item('a', {x: 1}), item('c')]); + assert.equal(manager.entries.get('a'), previous); + assert.deepEqual(events, ['init:a', 'init:b', 'dispose:b', 'init:c']); + await manager.apply_desired([item('a', {x: 2}), item('c')]); + assert.deepEqual(events.slice(-2), ['dispose:a', 'init:a']); + assert.equal(manager.get_plugin('a').config.x, 2); + await manager.apply_desired([]); + assert.ok(manager.get_all_plugins().every(entry => entry.lifecycle === 'disabled')); + await manager.dispose(); +}); + +test('reconcile validates every desired name, path, module and config before removals', async () => { + const {manager, modules, events} = setup(); + await manager.init([item('a'), item('b')]); + for (const desired of [[item('a'), {...item('a'), path: 'other'}], [{...item('a'), path: 'other'}], + [item('a'), item('c', {unknown: true})], [item('a'), item('bad')]]) { + modules.set('bad', {init: 1}); + await assert.rejects(manager.apply_desired(desired)); + assert.deepEqual(events, ['init:a', 'init:b']); + assert.equal(manager.get_plugin('a').enabled, true); + assert.equal(manager.get_plugin('b').enabled, true); + } + await manager.dispose(); +}); + +test('failed removal quarantines retained entry and blocks every reinstallation route', async () => { + const {manager, modules, plugin, options, events} = setup(); + modules.set('a', {init: plugin('a', [{name: 'x', type: 'number', default: 1, required: true, i18n}])}); + await manager.init([item('a'), item('b')]); + options.dispose_fail = 'a'; + await assert.rejects(manager.apply_desired([item('a', {x: 2})]), {reason: 'DISPOSE_FAILED'}); + assert.equal(manager.entries.get('a').quarantined, true); + assert.equal(manager.get_plugin('a').lifecycle, 'error'); + assert.equal(manager.get_plugin('a').error.code, 'DISPOSE_FAILED'); + assert.equal(manager.get_plugin('a').enabled, false); + for (const operation of [() => manager.apply_desired([item('a')]), () => manager.apply_desired([]), + () => manager.inject(item('a')), () => manager.re_inject(item('a')), () => manager.uninject('a')]) { + await assert.rejects(operation()); + } + assert.deepEqual(events, ['init:a', 'init:b', 'dispose:a']); + const before = events.length; + await assert.rejects(manager.dispose(), {reason: 'DISPOSE_FAILED'}); + assert.deepEqual(events.slice(before), ['dispose:b']); +}); + +test('failed candidate init cleanup quarantines and cannot be retried in same context', async () => { + const {manager, options, events} = setup(); + options.fail = 'a'; options.dispose_fail = 'a'; + await assert.rejects(manager.apply_desired([item('a')]), {reason: 'DISPOSE_FAILED'}); + options.fail = undefined; options.dispose_fail = undefined; + await assert.rejects(manager.apply_desired([item('a')]), {reason: 'DISPOSE_FAILED'}); + await assert.rejects(manager.inject(item('a'))); + assert.deepEqual(events, ['init:a', 'dispose:a']); + await assert.rejects(manager.dispose(), {reason: 'DISPOSE_FAILED'}); +}); + +test('reconcile serialization observes committed defaults and disposal interrupts preflight', async () => { + const {manager, options, events} = setup(); + await manager.inject(item('a')); + const gate = deferred(); options.load_gate = gate.promise; + const first = assert.rejects(manager.apply_desired([item('b')]), {reason: 'MANAGER_DISPOSED'}); + const queued = assert.rejects(manager.apply_desired([]), {reason: 'MANAGER_DISPOSED'}); + await flush(); + const disposing = manager.dispose(); + gate.resolve(); await first; await queued; await disposing; + assert.deepEqual(events, ['init:a', 'dispose:a']); +}); diff --git a/tests/plugin_report_session.test.mjs b/tests/plugin_report_session.test.mjs new file mode 100644 index 0000000..05e2444 --- /dev/null +++ b/tests/plugin_report_session.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import vm from 'node:vm'; +import test from 'node:test'; +import {transformSync} from 'esbuild'; + +const source = await readFile(new URL('../service/server/update/plugins.ts', import.meta.url), 'utf8'); +const code = transformSync(source.replace(/^import .*;\n/gm, '') + .replace('export async function', 'async function'), {loader: 'ts'}).code; + +test('late plugin scan never reports through a replacement authenticated session', async () => { + let finish_scan; + let current = true; + const reports = []; + const socket = {connected: true, timeout() { return this; }, async emitWithAck(...args) { + reports.push(args); return {status: 'success', data: 0}; + }}; + const context = {socket, is_current: () => current, + get_plugin_list: () => new Promise(resolve => { finish_scan = resolve; }), + console_print: {log() {}, success() {}}}; + const task = vm.runInNewContext(`${code}\nupdate_plugins(socket, 1, is_current);`, context); + current = false; + finish_scan([]); + await task; + assert.equal(reports.length, 0); +}); diff --git a/tests/plugin_states_info.test.mjs b/tests/plugin_states_info.test.mjs new file mode 100644 index 0000000..cb20b38 --- /dev/null +++ b/tests/plugin_states_info.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {init: PluginStates} = await tsImport('../bot/info/bot/plugin_states.ts', import.meta.url); +const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url); +const {create_info_manager} = await tsImport('../bot/info/init.ts', import.meta.url); +const item = {name: 'auto_reconnect', path: 'runtime', config: {}}; + +function setup() { + const context = new EventEmitter(); + context.cancel_reconnect = () => {}; + const manager = new PluginManager(context); + let subscriptions = 0; + const source = { + get_all_plugins: () => manager.get_all_plugins(), + on_update(callback) { + subscriptions++; + const unsubscribe = manager.on_update(callback); + let active = true; + return () => { + if (!active) return; + active = false; subscriptions--; unsubscribe(); + }; + }, + }; + return {context, manager, source, subscriptions: () => subscriptions}; +} + +test('plugin info initially reads source, streams states without config and owns only its subscription', async () => { + const {context, manager, source, subscriptions} = setup(); + await manager.inject(item); + const bot = new EventEmitter(); + const info = new PluginStates(bot, {plugin_source: source}); + assert.equal(bot.listenerCount('end'), 0); + assert.equal(subscriptions(), 1); + assert.deepEqual(Object.keys(info.get_info().plugins[0]).sort(), ['enabled', 'lifecycle', 'name', 'path', 'states']); + const updates = []; + info.on_update(() => { throw new Error('listener failed'); }); + info.on_update(value => { value.plugins[0].states.attempts = 99; }); + const unsubscribe = info.on_update(value => updates.push(value)); + context.emit('reconnect_attempt'); + assert.equal(updates[0].plugins[0].states.attempts, 1); + assert.equal(info.get_info().plugins[0].states.attempts, 1); + const detached = info.get_info(); detached.plugins.length = 0; + assert.equal(info.get_info().plugins.length, 1); + context.emit('status_update', 'connecting'); + assert.equal(updates.length, 1); + await manager.uninject(item.name); + assert.equal(updates.at(-1).plugins[0].lifecycle, 'disabled'); + assert.equal(updates.at(-1).plugins[0].states.attempts, 1); + unsubscribe(); unsubscribe(); + const before = updates.length; + await manager.inject(item); + assert.equal(updates.length, before); + info.dispose(); info.dispose(); + assert.equal(subscriptions(), 0); + context.emit('reconnect_attempt'); + assert.equal(info.get_info().plugins[0].states.attempts, 0); + assert.throws(() => info.on_update(() => {}), /disposed/); + await manager.dispose(); +}); + +test('missing source is explicitly unavailable and empty manager is available', async () => { + const missing = new PluginStates(new EventEmitter()); + assert.deepEqual(missing.get_info(), {available: false, plugins: []}); + const {manager, source} = setup(); + const empty = new PluginStates(new EventEmitter(), {plugin_source: source}); + assert.deepEqual(empty.get_info(), {available: true, plugins: []}); + missing.dispose(); empty.dispose(); await manager.dispose(); +}); + +test('InfoManager owns end cleanup and reconnect info uses the same plugin manager', async () => { + const {context, manager, source, subscriptions} = setup(); + await manager.inject(item); + const bot = new EventEmitter(); + bot._client = new EventEmitter(); + const infos = await create_info_manager(bot, {plugin_source: source}); + assert.equal(subscriptions(), 1); + const updates = []; + infos.on_update(event => { + if (event.name === 'plugin_states') event.value.plugins[0].states.attempts = 999; + }); + infos.on_update(event => { if (event.name === 'plugin_states') updates.push(event.value); }); + context.emit('reconnect_attempt'); + assert.equal(updates.at(-1).plugins[0].states.attempts, 1); + assert.equal('config' in infos.get_info('plugin_states').value.plugins[0], false); + bot.emit('end'); + assert.equal(subscriptions(), 0); + assert.equal(bot.listenerCount('end'), 0); + const before = updates.length; + context.emit('reconnect_attempt'); + assert.equal(updates.length, before); + const next = new EventEmitter(); next._client = new EventEmitter(); + const reconnected = await create_info_manager(next, {plugin_source: source}); + assert.equal(reconnected.get_info('plugin_states').value.plugins[0].states.attempts, 2); + assert.equal(subscriptions(), 1); + reconnected.dispose(); reconnected.dispose(); + assert.equal(subscriptions(), 0); + await manager.dispose(); +}); diff --git a/tests/resource_metrics.test.mjs b/tests/resource_metrics.test.mjs new file mode 100644 index 0000000..b5acfae --- /dev/null +++ b/tests/resource_metrics.test.mjs @@ -0,0 +1,347 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {mkdtemp, mkdir, writeFile, symlink, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {performance} from 'node:perf_hooks'; +import {test} from 'node:test'; +import {register} from 'tsx/esm/api'; + +// One module graph is essential: separate tsImport namespaces would isolate the shared WeakMap meter. +register(); +const {instrument_bot, measure_sync, ResourceInfo} = await import('../bot/info/resource_metrics.ts'); +const {init: CpuInfo} = await import('../bot/info/instance/used_cpu.ts'); +const {NetworkCounter, init: NetworkInfo} = await import('../bot/info/instance/used_network.ts'); +const {estimate_memory, init: MemoryInfo} = await import('../bot/info/instance/used_memory.ts'); +const {scan_storage, init: StorageInfo} = await import('../bot/info/instance/used_storage.ts'); +const {create_info_manager} = await import('../bot/info/init.ts'); +const {create_function_manager} = await import('../bot/function/init.ts'); + +function bot() { + const bot = new EventEmitter(); + bot._client = new EventEmitter(); + return bot; +} + +test('sync meter deduplicates nested bot/client events and preserves invocation semantics', t => { + const clock = t.mock.method(performance, 'now'); + let now = 0; + clock.mock.mockImplementation(() => now); + const b = bot(); + const original = b.emit; + const clientOriginal = b._client.emit; + const meter = instrument_bot(b); + b._client.on('packet', function(a, c) { + assert.equal(this, b._client); + assert.deepEqual([a, c], [1, 2]); + now += 3; + b.emit('physicsTick'); + now += 2; + }); + b.on('physicsTick', () => { now += 5; }); + assert.equal(b._client.emit('packet', 1, 2), true); + assert.equal(meter.total(), 10); + assert.equal(b.emit('absent'), false); + const error = new Error('same error'); + b.on('failure', () => { now += 7; throw error; }); + assert.throws(() => b.emit('failure'), e => e === error); + assert.equal(meter.total(), 17); + const other = bot(); + other.on('x', () => { now += 100; }); + other.emit('x'); + assert.equal(meter.total(), 17); + meter.dispose(); meter.dispose(); + assert.equal(b.emit, original); + assert.equal(b._client.emit, clientOriginal); + assert.equal(Object.hasOwn(b, 'emit'), false); +}); + +test('sync invocation never awaits promises and disposal respects replacement hooks', async t => { + let now = 0; + t.mock.method(performance, 'now', () => now); + const b = bot(); + const meter = instrument_bot(b); + let release; + const pending = new Promise(resolve => { release = resolve; }); + assert.equal(measure_sync(b, () => { now += 4; return pending; }), pending); + now += 500; + release(true); + await pending; + assert.equal(meter.total(), 4); + const replacement = () => 'replacement'; + b.emit = replacement; + meter.dispose(); + assert.equal(b.emit, replacement); +}); + +test('CPU samples actual elapsed deltas and cancels timer on disposal', async t => { + t.mock.timers.enable({apis: ['setInterval']}); + let now = 0; + t.mock.method(performance, 'now', () => now); + const b = bot(); + const info = new CpuInfo(b); + assert.equal(info.get_info().usage_percent, null); + b.on('physicsTick', () => { now += 20; }); + b.emit('physicsTick'); + now = 2000; + t.mock.timers.tick(1000); + assert.equal(info.get_info().usage_percent, 1); + assert.equal(info.get_info().sampled_time_ms, 20); + assert.equal(info.get_info().interval_ms, 2000); + assert.match(info.get_info().coverage, /协议解析/); + let updates = 0; + info.on_update(() => updates++); + info.dispose(); + await Promise.resolve(); + t.mock.timers.tick(5000); + assert.equal(updates, 0); + assert.equal(b.listenerCount('end'), 0); + assert.throws(() => info.on_update(() => {}), /disposed/); +}); + +test('function manager meters synchronous run invocation, with no promise wait', async t => { + const b = bot(); + b.entity = {isValid: true}; + b._client.state = 'play'; + const manager = await create_function_manager({bot_instance: b, bot_status: 'online'}); + const meter = instrument_bot(b); + t.after(() => { manager.dispose(); meter.dispose(); }); + let now = 0; + t.mock.method(performance, 'now', () => now); + b.chat = () => { now += 12; }; + assert.equal(await manager.run('bot', 'chat', ['hello']), true); + assert.equal(meter.total(), 12); +}); + +test('socket counters use elapsed intervals and retain observed totals across resets and gaps', () => { + const counter = new NetworkCounter(); + const first = {bytesRead: 100, bytesWritten: 50}; + assert.equal(counter.sample(undefined, 0).available, false); + assert.equal(counter.sample(first, 100).received_bytes_per_second, null); + first.bytesRead += 300; first.bytesWritten += 100; + const sample = counter.sample(first, 1600); + assert.equal(sample.received_bytes_per_second, 200); + assert.equal(sample.sent_bytes_per_second, 100 / 1.5); + assert.equal(sample.received_bytes, 400); + const second = {bytesRead: 20, bytesWritten: 10}; + assert.equal(counter.sample(second, 2000).received_bytes_per_second, null); + assert.equal(counter.sample(undefined, 2500).received_bytes, 420); + second.bytesRead = 30; + assert.equal(counter.sample(second, 3000).received_bytes, 430); + second.bytesRead = 2; + assert.equal(counter.sample(second, 4000).received_bytes_per_second, null); + assert.equal(counter.sample(second, 5000).received_bytes, 432); +}); + +test('memory scans own descriptors, real columns, cycles and shared backing buffers without getters', async () => { + const backing = new ArrayBuffer(1024); + const column = {a: new Uint8Array(backing), b: new DataView(backing)}; + Object.defineProperty(column.a, 'buffer', {get() { throw new Error('no getter'); }}); + const b = bot(); + b.registry = {large: new ArrayBuffer(100000)}; + b.world = {getColumns: () => [{chunkX: 0, chunkZ: 0, column}], external: b.registry}; + b.inventory = {slots: [column.a], registry: b.registry}; + b.currentWindow = b.inventory; + b.entities = {one: {name: 'one'}}; + b.players = {one: b.entities.one}; + b.entities.one.self = b.entities.one; + const original = await estimate_memory(b); + assert.equal(original.partial, false); + assert.ok(original.total_bytes >= 1024 && original.total_bytes < 3000); + const duplicated = await estimate_memory({...b, currentWindow: {...b.inventory}}); + assert.ok(duplicated.total_bytes > original.total_bytes); + Object.defineProperty(b.entities.one, 'danger', {get() { throw new Error('getter executed'); }}); + assert.equal((await estimate_memory(b)).partial, true); + assert.equal((await estimate_memory(b, () => true)).partial, true); + assert.equal((await estimate_memory(b, () => false, {nodes: 1, properties: 10, milliseconds: 1000})).partial, true); + b.world.getColumns = () => { throw new Error('unavailable'); }; + assert.equal((await estimate_memory(b)).partial, true); +}); + +for (const hugeCategory of ['chunks', 'entities']) { + for (const cap of ['nodes', 'properties']) { + test(`memory reserves category budgets with huge ${hugeCategory} and a ${cap} cap`, async () => { + const b = bot(); + // Wide objects exercise ownKeys; nested objects exercise independent node limits. + const huge = Object.fromEntries(Array.from({length: 10000}, (_, i) => [`key${i}`, {value: i}])); + b.world = {getColumns: () => [{column: hugeCategory === 'chunks' ? huge : {data: new Uint8Array(64)}}]}; + b.entities = hugeCategory === 'entities' ? huge : {one: {name: 'entity'}}; + b.inventory = {slots: [{name: 'stone'}]}; + b.time = {age: 100n}; + const limits = {nodes: 100000, properties: 100000, milliseconds: 10000, [cap]: 80}; + const result = await estimate_memory(b, () => false, limits); + for (const category of ['chunks', 'entities', 'inventory', 'other']) { + assert.ok(result[`${category}_bytes`] > 0, category); + assert.equal(result.categories_complete[category], category !== hugeCategory, category); + } + assert.equal(result.partial, true); + assert.equal(result.total_bytes, result.chunks_bytes + result.entities_bytes + + result.inventory_bytes + result.other_bytes); + }); + } +} + +test('memory deduplicates shared objects and backing stores across categories deterministically', async () => { + const sample = async size => { + const b = bot(); + const backing = new ArrayBuffer(size); + const shared = {view: new Uint8Array(backing)}; + shared.self = shared; + b.world = {getColumns: () => [{column: {shared, view: new DataView(backing)}}]}; + b.entities = {shared, view: new Uint8Array(backing)}; + b.inventory = {shared, view: new DataView(backing)}; + b.game = {shared, backing}; + return estimate_memory(b, () => false, {nodes: 1000, properties: 1000, milliseconds: 10000}); + }; + const small = await sample(100); + const large = await sample(1100); + assert.equal(large.total_bytes - small.total_bytes, 1000); + assert.deepEqual(large, await sample(1100)); + assert.deepEqual(large.categories_complete, {chunks: true, entities: true, inventory: true, other: true}); + assert.equal(large.partial, false); +}); + +test('memory scans only allowlisted other roots and reports omissions without calling getters', async () => { + const b = bot(); + const roots = ['game', 'time', 'settings', 'scoreboards', 'teams', 'tablist']; + for (const key of roots) { + const result = await estimate_memory(Object.assign(bot(), {[key]: {data: 'owned'}})); + assert.ok(result.other_bytes > 0, key); + assert.equal(result.categories_complete.other, true); + } + b.unlisted = {data: new ArrayBuffer(1000000)}; + assert.equal((await estimate_memory(b)).other_bytes, 0); + let getters = 0; + Object.defineProperty(b, 'game', {get() { getters++; return {}; }}); + b.world = {}; + Object.defineProperty(b.world, 'getColumns', {get() { getters++; return () => []; }}); + b.entities = {one: {name: 'one'}}; + Object.defineProperty(b.entities.one, 'danger', {get() { getters++; return 123; }}); + const result = await estimate_memory(b); + assert.equal(getters, 0); + assert.deepEqual(result.categories_complete, {chunks: false, entities: false, inventory: true, other: false}); + assert.equal(result.partial, true); + for (const limits of [{nodes: 0, properties: 100, milliseconds: 1000}, + {nodes: 100, properties: 0, milliseconds: 1000}, + {nodes: 100, properties: 100, milliseconds: 0}]) { + const capped = await estimate_memory(b, () => false, limits); + assert.equal(capped.categories_complete.entities, false); + assert.equal(capped.entities_bytes, 0); + assert.equal(capped.partial, true); + } + const cancelled = await estimate_memory(b, () => true); + assert.deepEqual(cancelled.categories_complete, {chunks: false, entities: false, inventory: false, other: false}); +}); + +test('memory marks column and unsupported collection truncation per category', async () => { + const b = bot(); + b.world = {getColumns: () => Array.from({length: 4097}, () => ({column: {}}))}; + b.game = new Map([['data', 'omitted']]); + const result = await estimate_memory(b, () => false, {nodes: 50000, properties: 50000, milliseconds: 10000}); + assert.deepEqual(result.categories_complete, {chunks: false, entities: true, inventory: true, other: false}); + assert.equal(result.partial, true); +}); + +test('storage scans nested files without following symlinks and reports missing, error and bounds', async t => { + const directory = await mkdtemp(path.join(tmpdir(), 'mf-resource-')); + t.after(() => rm(directory, {recursive: true, force: true})); + await mkdir(path.join(directory, 'nested')); + await writeFile(path.join(directory, 'a'), 'abc'); + await writeFile(path.join(directory, 'nested', 'b'), '12345'); + await symlink(directory, path.join(directory, 'nested', 'loop')); + await symlink(path.join(directory, 'a'), path.join(directory, 'alias')); + assert.deepEqual(await scan_storage(directory), {available: true, total_bytes: 8, file_count: 2, partial: false}); + assert.deepEqual(await scan_storage(path.join(directory, 'missing')), + {available: true, total_bytes: 0, file_count: 0, partial: false}); + assert.equal((await scan_storage(path.join(directory, 'a'))).available, false); + assert.equal((await scan_storage(directory, () => true)).partial, true); + assert.equal((await scan_storage(directory, () => false, {entries: 2, depth: 32, milliseconds: 1000})).partial, true); + assert.equal((await scan_storage(directory, () => false, {entries: 100, depth: 1, milliseconds: 1000})).partial, true); +}); + +test('resource modules have complete field metadata, no end listeners and safe async disposal', async t => { + t.mock.timers.enable({apis: ['setInterval']}); + const b = bot(); + const infos = [new CpuInfo(b), new NetworkInfo(b), new MemoryInfo(b), new StorageInfo(b)]; + assert.deepEqual(infos[2].get_info().categories_complete, + {chunks: false, entities: false, inventory: false, other: false}); + assert.equal(infos[2].get_info().partial, true); + let updates = 0; + for (const info of infos) { + assert.equal(info.i18n.lang, 'zh-CN'); + assert.match(info.i18n.name, /[\u4e00-\u9fff]/u); + assert.match(info.i18n.description, /[\u4e00-\u9fff]/u); + for (const key of Object.keys(info.get_info())) { + const field = info.i18n.fields[key]; + assert.ok(field, key); + assert.equal(field.lang, 'zh-CN'); + assert.notEqual(field.name, key); + assert.match(field.name, /[\u4e00-\u9fff]/u); + assert.match(field.description, /[\u4e00-\u9fff]/u); + } + const checkFields = (value, metadata) => { + for (const [key, entry] of Object.entries(value)) { + const field = metadata.fields[key]; + assert.ok(field, key); + assert.equal(field.lang, 'zh-CN'); + assert.match(field.name, /[\u4e00-\u9fff]/u); + assert.match(field.description, /[\u4e00-\u9fff]/u); + if (entry && typeof entry === 'object') checkFields(entry, field); + } + }; + checkFields(info.get_info(), info.i18n); + info.on_update(() => updates++); + } + assert.equal(b.listenerCount('end'), 0); + for (const info of infos) { info.dispose(); info.dispose(); } + await Promise.resolve(); + t.mock.timers.tick(60000); + assert.equal(updates, 0); + assert.equal(infos[3].get_info().available, false); +}); + +test('async resource sampling does not overlap or publish after disposal', async t => { + t.mock.timers.enable({apis: ['setInterval']}); + let release; + let calls = 0; + class Sample extends ResourceInfo { + name = 'sample'; + i18n = {lang: 'en', name: 'Sample', description: ''}; + constructor() { + super(0); + this.start(1000, async () => { + calls++; + await new Promise(resolve => { release = resolve; }); + this.publish(1); + }, true); + } + } + const sample = new Sample(); + t.mock.timers.tick(30000); + assert.equal(calls, 1); + sample.dispose(); + release(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(sample.get_info(), 0); + t.mock.timers.tick(30000); + assert.equal(calls, 1); +}); + +test('InfoManager discovers all resource modules and owns wrappers through end/recreation', async () => { + const b = bot(); + const original = b.emit; + const manager = await create_info_manager(b); + for (const name of ['used_cpu', 'used_memory', 'used_network', 'used_storage']) { + assert.ok(manager.get_info(name), name); + } + assert.equal(b.listenerCount('end'), 1); + assert.notEqual(b.emit, original); + b.emit('end'); + assert.equal(b.emit, original); + assert.equal(b.listenerCount('end'), 0); + assert.equal(manager.get_all_info().length, 0); + const next = await create_info_manager(b); + next.dispose(); + assert.equal(b.emit, original); +}); diff --git a/tests/stop_current_function.test.mjs b/tests/stop_current_function.test.mjs new file mode 100644 index 0000000..be8914c --- /dev/null +++ b/tests/stop_current_function.test.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {create_function_manager} = await tsImport('../bot/function/init.ts', import.meta.url); +const {init: Stop} = await tsImport('../bot/function/bot/stop_current_function.ts', import.meta.url); +const require = createRequire(import.meta.url); +const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3'); +const turn = () => new Promise(resolve => setImmediate(resolve)); + +async function fixture(t) { + const bot = new EventEmitter(); + bot.entity = {id: 1, position: new Vec3(0, 0, 0), height: 1.8, eyeHeight: 1.62, onGround: true}; + bot._client = Object.assign(new EventEmitter(), {state: 'play'}); + bot.controlState = {}; + bot.getControlState = key => bot.controlState[key] === true; + bot.setControlState = (key, value) => { bot.controlState[key] = value; }; + bot.stopDigging = () => { bot.targetDigBlock = null; }; + bot.deactivateItem = () => {}; + bot.clearControlStates = () => { for (const key of Object.keys(bot.controlState)) bot.setControlState(key, false); }; + const manager = await create_function_manager({bot_instance: bot, bot_status: 'online'}); + t.after(() => manager.dispose()); + return {bot, manager}; +} + +function clean(bot) { + assert.deepEqual(bot.eventNames(), []); + assert.deepEqual(bot._client.eventNames(), []); +} + +test('stop bypasses a full queue, releases direction and persistent sneak, and never restarts pending keys', async t => { + const {bot, manager} = await fixture(t); + await manager.run('bot', 'squat_continue_switch', [true]); + const running = manager.run('bot', 'use_direction_key', ['forward', 5000]); + await turn(); + assert.equal(bot.controlState.forward, true); + const queued = Array.from({length: 64}, () => manager.run('bot', 'use_direction_key', ['right', 5000])); + let queuedChecks = 0; + // Queue capacity applies to normal calls, never to the stop command. + await assert.rejects(manager.run('bot', 'jump', [], () => queuedChecks++), /队列已满/); + assert.equal(await manager.run('bot', 'stop_current_function', []), true); + assert.equal(await running, false); + assert.deepEqual(await Promise.all(queued), Array(64).fill(false)); + await turn(); + assert.equal(queuedChecks, 0); + assert.deepEqual(bot.controlState, {sneak: false, forward: false}); + clean(bot); + const fresh = manager.run('bot', 'jump', []); + await turn(); + bot.emit('physicsTick'); + assert.equal(await fresh, true); + clean(bot); +}); + +test('stop validates no arguments and attempts every stop API even when earlier calls throw', async () => { + const stop = new Stop(); + assert.deepEqual(stop.args, []); + for (const failing of ['stopDigging', 'deactivateItem', 'clearControlStates', null]) { + const calls = []; + const bot = {}; + for (const name of ['stopDigging', 'deactivateItem', 'clearControlStates']) bot[name] = () => { + calls.push(name); + if (name === failing) throw Error(name); + }; + assert.equal(await stop.run(bot, [1]), false); + assert.deepEqual(calls, []); + assert.equal(await stop.run(bot, []), failing === null); + assert.deepEqual(calls, ['stopDigging', 'deactivateItem', 'clearControlStates']); + } +}); + +test('stop settles pending look immediately and late entity look cannot attack or restart queued work', async t => { + const {bot, manager} = await fixture(t); + const target = {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 1, height: 2}; + bot.entities = {2: target}; + let finishLook; + bot.lookAt = () => new Promise(resolve => { finishLook = resolve; }); + let attacks = 0; + bot.attack = () => { attacks++; }; + const running = manager.run('entity', 'attack_nearest_entity', ['cow', 4, false, true]); + await turn(); + let before = 0; + const queued = manager.run('bot', 'jump', [], () => before++); + assert.equal(await manager.run('bot', 'stop_current_function', []), true); + assert.equal(await running, false); + assert.equal(await queued, false); + clean(bot); + finishLook(); + await turn(); + assert.equal(attacks, 0); + assert.equal(before, 0); + clean(bot); +}); + +test('stop cancels container waits and block groups without fake disconnects or later group actions', async t => { + const {bot, manager} = await fixture(t); + let ends = 0; + bot.on('end', () => ends++); + bot.entities = {2: {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 1, height: 2}}; + bot.interactEntity = () => {}; + const open = manager.run('entity', 'open_nearest_entity_container', ['cow', 4, false]); + await turn(); + assert.equal(bot.listenerCount('windowOpen'), 1); + await manager.run('bot', 'stop_current_function', []); + assert.equal(await open, false); + assert.equal(bot.listenerCount('windowOpen'), 0); + const block = {name: 'stone', type: 1, stateId: 1, position: new Vec3(2, 1, 0)}; + bot.blockAt = () => block; + let finishUse; + let uses = 0; + bot.activateBlock = () => { uses++; return new Promise(resolve => { finishUse = resolve; }); }; + const group = manager.run('block', 'use_position_group_block', [[2, 1, 0, 1, 2, 1, 0, 1]]); + await turn(); + await manager.run('bot', 'stop_current_function', []); + assert.equal(await group, false); + finishUse(); + await turn(); + assert.equal(uses, 1); + assert.equal(ends, 0); + bot.removeAllListeners('end'); + clean(bot); +}); + +test('stop prevents remaining inventory clicks after an already submitted click settles', async t => { + const {bot, manager} = await fixture(t); + for (const name of ['slot_to_slot', 'slot_switch_slot']) { + bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null}; + let finishClick; + const clicks = []; + bot.clickWindow = slot => { clicks.push(slot); return new Promise(resolve => { finishClick = resolve; }); }; + const running = manager.run('inventory', name, [0, 1]); + await turn(); + assert.deepEqual(clicks, [0]); + await manager.run('bot', 'stop_current_function', []); + assert.equal(await running, false); + finishClick(); + await turn(); + assert.deepEqual(clicks, [0]); + } + clean(bot); +}); + +test('stop releases dig ownership during pending aim and stops an already active dig', async t => { + const {bot, manager} = await fixture(t); + const block = {name: 'stone', type: 1, stateId: 1, position: new Vec3(2, 1, 0)}; + bot.blockAt = () => block; + bot.canDigBlock = () => true; + let finishLook; + bot.lookAt = () => new Promise(resolve => { finishLook = resolve; }); + let digs = 0; + let finishDig; + bot.dig = () => { + digs++; + bot.targetDigBlock = block; + return new Promise(resolve => { finishDig = resolve; }); + }; + const aiming = manager.run('block', 'destroy_position_block', [2, 1, 0, 1, true]); + await turn(); + await manager.run('bot', 'stop_current_function', []); + assert.equal(await aiming, false); + clean(bot); + finishLook(); + await turn(); + assert.equal(digs, 0); + const digging = manager.run('block', 'destroy_position_block', [2, 1, 0, 1]); + await turn(); + assert.equal(digs, 1); + assert.equal(bot.targetDigBlock, block); + await manager.run('bot', 'stop_current_function', []); + assert.equal(await digging, false); + assert.equal(bot.targetDigBlock, null); + finishDig(); + await turn(); + clean(bot); +}); diff --git a/tests/workflow.test.mjs b/tests/workflow.test.mjs new file mode 100644 index 0000000..4a48fa6 --- /dev/null +++ b/tests/workflow.test.mjs @@ -0,0 +1,856 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {setImmediate, setTimeout as delay} from 'node:timers/promises'; +import {tsImport} from 'tsx/esm/api'; + +const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url); +const {create_workflow_run} = await tsImport('../bot/workflow/runtime/machine.ts', import.meta.url); +const {assert_json, resolve_value} = await tsImport('../bot/workflow/runtime/value.ts', import.meta.url); + +const literal = value => ({kind: 'literal', value}); +const ref = (root, ...path) => ({kind: 'ref', root, path}); +const edge = (id, source, target, port = 'success') => ({id, source, port, target}); +const query = (id, args = {}, extra = {}) => ({id, type: 'query', config: {source: id, args, ...extra}}); +const fn = (id, args = []) => ({id, type: 'function', config: {path: 'test', name: id, args}}); +const merge = (id, mode = 'all') => ({id, type: 'merge', config: {mode}}); +const condition = (id, left, right) => ({id, type: 'condition', config: {condition: {op: 'lt', left, right}}}); +const deps = overrides => ({ + run_function: async () => true, + query: async (_source, args) => args, + sleep: async () => {}, + log() {}, + ...overrides, +}); +function definition(nodes, edges = [], extra = {}) { + return { + schema_version: 1, id: 'test_workflow', name: 'Workflow test', enabled: true, + start: {type: 'common', auto_start: 'never'}, entry: nodes[0].id, nodes, edges, + initial_vars: {}, policy: {on_busy: 'skip', max_queue: 0, max_steps: 100, timeout_ms: 5000}, + ...extra, + }; +} +function start(t, config, dependencies = deps(), trigger = {}, notify) { + const run = create_workflow_run(compile_workflow(config), dependencies, trigger, notify); + t.after(() => run.stop()); + return run; +} +function deferred() { + let resolve; + const promise = new Promise(finish => {resolve = finish;}); + return {promise, resolve}; +} +function until_aborted(signal) { + return new Promise((resolve, reject) => { + if (signal.aborted) reject(signal.reason); + else signal.addEventListener('abort', () => reject(signal.reason), {once: true}); + }); +} +function completed(snapshot, branches = 1) { + assert.equal(snapshot.status, 'completed', JSON.stringify(snapshot.error)); + assert.equal(snapshot.error, null); + assert.equal(snapshot.branches.length, branches); + assert.deepEqual(snapshot.active_nodes, []); + assert.ok(snapshot.ended_at >= snapshot.started_at); + return snapshot.branches[0]; +} + +test('workflow preserves the first failure while siblings drain past its deadline', {timeout: 5000}, async t => { + const abort_seen = deferred(); + const slow_started = deferred(); + const release = deferred(); + const run = start(t, definition([query('fork'), query('bad'), query('slow')], [ + edge('fork_bad', 'fork', 'bad'), edge('fork_slow', 'fork', 'slow'), + ], {policy: {on_busy: 'skip', max_queue: 0, max_steps: 100, timeout_ms: 100}}), deps({ + query: async (source, _args, signal) => { + if (source === 'fork') return null; + if (source === 'bad') { + await slow_started.promise; + throw Object.assign(new Error('first failure'), {code: 'FIRST_FAILURE'}); + } + slow_started.resolve(); + try {await until_aborted(signal);} + finally {abort_seen.resolve(); await release.promise;} + return null; + }, + })); + t.after(() => release.resolve()); + await abort_seen.promise; + await delay(125); + release.resolve(); + const snapshot = await run.done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.stop_reason, null); + assert.equal(snapshot.error.code, 'FIRST_FAILURE'); +}); + +test('workflow compiles JSON configs by value and resolves trigger, vars, outputs, last_result and inputs', {timeout: 5000}, async t => { + const config = definition([ + query('read', {value: ref('trigger', 'nested', 'values', 1)}, {save_as: 'saved'}), + fn('use', [ref('vars', 'saved'), ref('outputs', 'read', 'output'), ref('last_result', 'output'), + ref('inputs', 'read_use', 'result', 'output'), ref('vars', 'initial')]), + {id: 'log', type: 'log', config: {level: 'info', message: ref('last_result', 'output')}}, + ], [edge('read_use', 'read', 'use'), edge('use_log', 'use', 'log')], {initial_vars: {initial: 7}}); + const serialized = JSON.parse(JSON.stringify(config)); + const graph = compile_workflow(serialized); + serialized.nodes[0].config.source = 'changed'; + assert.equal(graph.nodes.get('read').config.source, 'read'); + const calls = [], logs = [], events = []; + const trigger = {nested: {values: ['first', {answer: 42}]}}; + const run = create_workflow_run(graph, deps({ + query: async (_source, args, signal) => {assert.equal(signal.aborted, false); return args.value;}, + run_function: async (path, name, args, signal) => { + assert.equal(signal.aborted, false); calls.push({path, name, args}); return true; + }, + log: (level, message) => logs.push({level, message}), + }), trigger, event => events.push(event)); + t.after(() => run.stop()); + trigger.nested.values[1].answer = 0; + const snapshot = await run.done; + const branch = completed(snapshot); + assert.deepEqual(calls, [{path: 'test', name: 'use', args: Array(4).fill({answer: 42}).concat(7)}]); + assert.deepEqual(logs, [{level: 'info', message: 'true'}]); + assert.deepEqual(branch.vars, {initial: 7, saved: {answer: 42}}); + assert.deepEqual(Object.keys(branch.outputs), ['read', 'use', 'log']); + assert.equal(snapshot.run_id, run.run_id); + assert.equal(snapshot.steps, 3); + assert.deepEqual(events.filter(event => event.type === 'node_finished').map(event => event.node_id), ['read', 'use', 'log']); + snapshot.branches[0].vars.initial = 999; + const copy = run.get_snapshot(); + assert.equal(copy.branches[0].vars.initial, 7); + copy.branches.length = 0; + assert.equal(run.get_snapshot().branches.length, 1); +}); + +test('workflow forks every edge on a success port into independent variable and output state', {timeout: 5000}, async t => { + const snapshot = await start(t, definition([ + query('fork'), query('left', {value: literal('L')}, {save_as: 'left_value'}), + query('right', {value: literal('R')}, {save_as: 'right_value'}), + ], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right')]), + deps({query: async (_source, args) => args.value ?? 'root'})).done; + completed(snapshot, 2); + const left = snapshot.branches.find(branch => branch.outputs.left); + const right = snapshot.branches.find(branch => branch.outputs.right); + assert.deepEqual(left.vars, {left_value: 'L'}); + assert.deepEqual(right.vars, {right_value: 'R'}); + assert.equal(left.outputs.right, undefined); + assert.equal(right.outputs.left, undefined); + assert.equal(left.outputs.fork.output, 'root'); + assert.equal(right.outputs.fork.output, 'root'); +}); + +test('workflow cannot resolve a sibling output even after that sibling has finished', {timeout: 5000}, async t => { + const sibling_finished = deferred(); + const config = definition([ + query('fork'), query('sibling'), query('wait'), + query('illegal', {value: ref('outputs', 'sibling', 'output')}), + ], [edge('to_sibling', 'fork', 'sibling'), edge('to_wait', 'fork', 'wait'), edge('to_illegal', 'wait', 'illegal')]); + const snapshot = await start(t, config, deps({query: async source => { + if (source === 'wait') await sibling_finished.promise; + return source; + }}), {}, event => { + if (event.type === 'node_finished' && event.node_id === 'sibling') sibling_finished.resolve(); + }).done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'VALUE_NOT_FOUND'); + assert.equal(snapshot.error.node_id, 'illegal'); +}); + +test('workflow all merge exposes inputs by incoming edge id and unions branch outputs and vars', {timeout: 5000}, async t => { + const snapshot = await start(t, definition([ + query('fork'), query('left', {}, {save_as: 'left_value'}), query('right', {}, {save_as: 'right_value'}), + merge('join'), query('inspect', {left: ref('outputs', 'left', 'output'), right: ref('outputs', 'right', 'output'), + inputs: ref('outputs', 'join', 'output')}), + ], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('joined', 'join', 'inspect')]), + deps({query: async (source, args) => source === 'inspect' ? args : source})).done; + const branch = completed(snapshot); + assert.deepEqual(branch.vars, {left_value: 'left', right_value: 'right'}); + assert.deepEqual(branch.outputs.join.output, { + left_join: {source: 'left', port: 'success', result: {status: 'success', output: 'left'}}, + right_join: {source: 'right', port: 'success', result: {status: 'success', output: 'right'}}, + }); + assert.deepEqual(branch.outputs.inspect.output, {left: 'left', right: 'right', inputs: branch.outputs.join.output}); + assert.equal(snapshot.steps, 5); +}); + +test('workflow any merge emits once per arrival without sharing sibling state', {timeout: 5000}, async t => { + const events = []; + const snapshot = await start(t, definition([ + query('fork'), query('left'), query('right'), merge('join', 'any'), + ], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]), + deps({query: async source => source}), {}, event => events.push(event)).done; + completed(snapshot, 2); + assert.equal(events.filter(event => event.type === 'node_finished' && event.node_id === 'join').length, 2); + assert.deepEqual(snapshot.branches.map(branch => Object.keys(branch.outputs.join.output)[0]).sort(), ['left_join', 'right_join']); + for (const branch of snapshot.branches) assert.notEqual(Boolean(branch.outputs.left), Boolean(branch.outputs.right)); +}); + +test('workflow routes false results and thrown dependency errors through error nodes', {timeout: 5000}, async t => { + for (const throwing of [false, true]) { + const calls = []; + const code = throwing ? 'TEST_FAILURE' : 'FUNCTION_RETURNED_FALSE'; + const snapshot = await start(t, definition([ + fn('fail'), {id: 'catch', type: 'error', config: {codes: [code]}}, + query('recovered', {code: ref('last_result', 'error', 'code')}), query('unexpected'), + ], [edge('failure', 'fail', 'catch', 'failure'), edge('caught', 'catch', 'recovered', 'error'), + edge('uncaught', 'catch', 'unexpected', 'unmatched')]), deps({ + run_function: async () => { + if (throwing) throw Object.assign(new Error('expected dependency error'), {code}); + return false; + }, + query: async (source, args) => {calls.push(source); return args;}, + })).done; + const branch = completed(snapshot); + assert.deepEqual(calls, ['recovered']); + assert.equal(branch.outputs.fail.status, 'failure'); + assert.deepEqual(branch.outputs.recovered.output, {code}); + } +}); + +test('workflow goto retries are bounded and exhaustion can be explicitly handled', {timeout: 5000}, async t => { + let attempts = 0; + const sleeps = []; + const snapshot = await start(t, definition([ + fn('attempt'), {id: 'retry', type: 'goto', config: {max_retries: 2, delay_ms: 7}}, + query('exhausted', {code: ref('last_result', 'error', 'code')}), + ], [edge('failed', 'attempt', 'retry', 'failure'), edge('again', 'retry', 'attempt', 'retry'), + edge('finished', 'retry', 'exhausted', 'exhausted')]), deps({ + run_function: async () => {attempts++; return false;}, + sleep: async (mode, value, signal) => {assert.equal(signal.aborted, false); sleeps.push([mode, value]);}, + })).done; + const branch = completed(snapshot); + assert.equal(attempts, 3); + assert.deepEqual(sleeps, [['milliseconds', 7], ['milliseconds', 7]]); + assert.deepEqual(branch.retry_counts, {retry: 2}); + assert.deepEqual(branch.outputs.exhausted.output, {code: 'RETRY_EXHAUSTED'}); + assert.equal(snapshot.steps, 7); +}); + +test('workflow all merge fails with MERGE_INCOMPLETE for mutually exclusive branches', {timeout: 5000}, async t => { + const snapshot = await start(t, definition([ + condition('choose', literal(1), literal(2)), query('left'), query('right'), merge('join'), + ], [edge('yes', 'choose', 'left', 'true'), edge('no', 'choose', 'right', 'false'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')])).done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'MERGE_INCOMPLETE'); + assert.equal(snapshot.steps, 2); + assert.deepEqual(snapshot.active_nodes, []); +}); + +test('workflow conflicting branch variables fail with MERGE_CONFLICT', {timeout: 5000}, async t => { + const snapshot = await start(t, definition([ + query('fork'), query('left', {value: literal(1)}, {save_as: 'shared'}), + query('right', {value: literal(2)}, {save_as: 'shared'}), merge('join'), + ], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]), + deps({query: async (_source, args) => args.value ?? null})).done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'MERGE_CONFLICT'); + assert.equal(snapshot.error.node_id, 'join'); + assert.equal(snapshot.steps, 4); +}); + +test('workflow nested forks join inner branches before the outer all merge', {timeout: 5000}, async t => { + const snapshot = await start(t, definition([ + query('root'), query('inner_fork'), query('outer_right'), query('inner_left'), query('inner_right'), + merge('inner_join'), merge('outer_join'), + ], [edge('to_inner', 'root', 'inner_fork'), edge('to_outer_right', 'root', 'outer_right'), + edge('to_inner_left', 'inner_fork', 'inner_left'), edge('to_inner_right', 'inner_fork', 'inner_right'), + edge('inner_left_join', 'inner_left', 'inner_join'), edge('inner_right_join', 'inner_right', 'inner_join'), + edge('inner_outer', 'inner_join', 'outer_join'), edge('right_outer', 'outer_right', 'outer_join')]), + deps({query: async source => source})).done; + const branch = completed(snapshot); + assert.deepEqual(Object.keys(branch.outputs.inner_join.output).sort(), ['inner_left_join', 'inner_right_join']); + assert.deepEqual(Object.keys(branch.outputs.outer_join.output).sort(), ['inner_outer', 'right_outer']); + assert.deepEqual(Object.keys(branch.outputs).sort(), ['inner_fork', 'inner_join', 'inner_left', 'inner_right', 'outer_join', 'outer_right', 'root']); +}); + +test('workflow repeated fork loops use a fresh all-merge group on every iteration', {timeout: 5000}, async t => { + const pairs = []; + const snapshot = await start(t, definition([ + query('fork', {iteration: ref('vars', 'iteration')}), + query('left', {iteration: ref('outputs', 'fork', 'output')}), + query('right', {iteration: ref('outputs', 'fork', 'output')}), merge('join'), + query('advance', {inputs: ref('outputs', 'join', 'output')}, {save_as: 'iteration'}), + condition('again', ref('vars', 'iteration'), literal(3)), query('done'), + ], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('advance', 'join', 'advance'), + edge('check', 'advance', 'again'), edge('loop', 'again', 'fork', 'true'), edge('finish', 'again', 'done', 'false')], + {initial_vars: {iteration: 0}}), deps({query: async (source, args) => { + if (source === 'advance') { + const pair = [args.inputs.left_join.result.output, args.inputs.right_join.result.output]; + pairs.push(pair); return pair[0] + 1; + } + return args.iteration ?? null; + }})).done; + const branch = completed(snapshot); + assert.deepEqual(pairs, [[0, 0], [1, 1], [2, 2]]); + assert.equal(branch.vars.iteration, 3); + assert.equal(snapshot.steps, 19); +}); + +test('workflow overlapping executions of the same fork never cross-pair all-merge inputs', {timeout: 5000}, async t => { + const arrivals = new Map(); + const all_started = deferred(); + const gates = new Map(['left:1', 'left:2', 'right:1', 'right:2'].map(key => [key, deferred()])); + const pairs = []; + const run = start(t, definition([ + query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}), + query('fork', {value: ref('last_result', 'output')}), + query('left', {value: ref('outputs', 'fork', 'output')}), + query('right', {value: ref('outputs', 'fork', 'output')}), merge('join'), + query('collect', {inputs: ref('outputs', 'join', 'output')}), + ], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'), + edge('a_fork', 'seed_a', 'fork'), edge('b_fork', 'seed_b', 'fork'), + edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('collect', 'join', 'collect')]), + deps({query: async (source, args, signal) => { + if (source === 'left' || source === 'right') { + const key = `${source}:${args.value}`; + arrivals.set(key, true); + if (arrivals.size === 4) all_started.resolve(); + await Promise.race([gates.get(key).promise, until_aborted(signal)]); + } + if (source === 'collect') { + const pair = [args.inputs.left_join.result.output, args.inputs.right_join.result.output]; + pairs.push(pair); return pair; + } + return args.value ?? null; + }})); + await all_started.promise; + // Release opposite generations first to force a tempting but invalid join. + for (const key of ['left:1', 'right:2', 'right:1', 'left:2']) { + gates.get(key).resolve(); + await setImmediate(); + } + completed(await run.done, 2); + assert.deepEqual(pairs.sort((a, b) => a[0] - b[0]), [[1, 1], [2, 2]]); +}); + +test('workflow retry bypassing the join owner preserves generations across repeated all merges', {timeout: 5000}, async t => { + const gates = new Map(['a:1', 'b:2', 'b:1', 'a:2'].map(key => [key, deferred()])); + const finished = new Map([...gates.keys()].map(key => [key, deferred()])); + const retry_started = deferred(), first_retry_join = deferred(); + const joins = [], logs = []; + let arrivals = 0; + const config = definition([ + query('root'), query('seed1', {value: literal(1)}), query('seed2', {value: literal(2)}), + query('s', {value: ref('last_result', 'output')}), + ...['a', 'b'].map(id => query(id, {value: ref('outputs', 's', 'output'), outputs: ref('outputs')})), + merge('join'), {id: 'retry', type: 'goto', config: {max_retries: 1, delay_ms: 0}}, + {id: 'end', type: 'log', config: {level: 'info', message: ref('outputs', 's', 'output')}}, + ], [edge('seed1', 'root', 'seed1'), edge('seed2', 'root', 'seed2'), + edge('seed1_s', 'seed1', 's'), edge('seed2_s', 'seed2', 's'), + edge('s_a', 's', 'a'), edge('s_b', 's', 'b'), edge('a_join', 'a', 'join'), edge('b_join', 'b', 'join'), + edge('join_retry', 'join', 'retry'), edge('retry_a', 'retry', 'a', 'retry'), + edge('retry_b', 'retry', 'b', 'retry'), edge('exhausted', 'retry', 'end', 'exhausted')]); + assert.equal(compile_workflow(config).join_owners.get('join'), 's'); + const run = start(t, config, deps({query: async (source, args, signal) => { + if (source !== 'a' && source !== 'b') return args.value ?? null; + const round = Object.hasOwn(args.outputs, 'retry') ? 2 : 1; + if (round === 2) { + if (++arrivals === 4) retry_started.resolve(); + await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]); + } + return {generation: args.value, round}; + }, log: (level, message) => logs.push({level, message})}), {}, event => { + if (event.type !== 'node_finished') return; + if (event.node_id === 'join') { + const inputs = event.result.output; + joins.push([inputs.a_join.result.output, inputs.b_join.result.output]); + if (joins.length === 3) first_retry_join.resolve(); + } else if (event.node_id === 'a' || event.node_id === 'b') { + if (event.result.output.round === 2) { + finished.get(`${event.node_id}:${event.result.output.generation}`).resolve(); + } + } + }); + await retry_started.promise; + const expected = (generation, round) => Array.from({length: 2}, () => ({generation, round})); + assert.deepEqual(joins.slice().sort((a, b) => a[0].generation - b[0].generation), [expected(1, 1), expected(2, 1)]); + // These arrivals would cross-pair if the first join discarded its lineage. + for (const key of ['a:1', 'b:2']) { + gates.get(key).resolve(); await finished.get(key).promise; + } + await setImmediate(); + assert.equal(joins.length, 2); + gates.get('b:1').resolve(); + await first_retry_join.promise; + assert.deepEqual(joins[2], expected(1, 2)); + gates.get('a:2').resolve(); + const snapshot = await run.done; + completed(snapshot, 2); + assert.deepEqual(joins[3], expected(2, 2)); + assert.equal(snapshot.steps, 23); + assert.deepEqual(logs.sort((a, b) => a.message.localeCompare(b.message)), + [{level: 'info', message: '1'}, {level: 'info', message: '2'}]); + for (const branch of snapshot.branches) { + assert.deepEqual(branch.retry_counts, {retry: 1}); + assert.equal(branch.outputs.retry.error.code, 'RETRY_EXHAUSTED'); + assert.equal(branch.outputs.join.output.a_join.result.output.generation, branch.outputs.s.output); + assert.equal(branch.outputs.join.output.b_join.result.output.generation, branch.outputs.s.output); + } +}); + +test('workflow node timeout aborts and drains its dependency before routing failure', {timeout: 5000}, async t => { + let aborted = false, drained = false; + const snapshot = await start(t, definition([ + {...query('slow'), timeout_ms: 100}, query('recover', {code: ref('last_result', 'error', 'code')}), + ], [edge('timeout', 'slow', 'recover', 'failure')]), deps({query: async (source, args, signal) => { + if (source !== 'slow') {assert.equal(drained, true); return args;} + try {await until_aborted(signal);} finally {aborted = signal.aborted; await setImmediate(); drained = true;} + }})).done; + const branch = completed(snapshot); + assert.equal(aborted, true); + assert.equal(drained, true); + assert.equal(branch.outputs.slow.error.code, 'NODE_TIMEOUT'); + assert.deepEqual(branch.outputs.recover.output, {code: 'NODE_TIMEOUT'}); +}); + +test('workflow partial nested forks retain outer join generations across overlapping executions', {timeout: 5000}, async t => { + const leaves = ['inner_left', 'inner_right', 'outer_right']; + const keys = leaves.flatMap(id => [1, 2].map(value => `${id}:${value}`)); + const gates = new Map(keys.map(key => [key, deferred()])); + const finished = new Map(keys.map(key => [key, deferred()])); + const all_started = deferred(), first_join = deferred(); + const triples = []; + let arrivals = 0; + const run = start(t, definition([ + query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}), + query('outer_fork', {value: ref('last_result', 'output')}), + query('partial', {value: ref('last_result', 'output')}), + ...leaves.map(id => query(id, {value: ref('last_result', 'output')})), merge('join'), + query('collect', {inputs: ref('outputs', 'join', 'output')}), + ], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'), + edge('a_outer', 'seed_a', 'outer_fork'), edge('b_outer', 'seed_b', 'outer_fork'), + edge('partial', 'outer_fork', 'partial'), edge('outer_right', 'outer_fork', 'outer_right'), + edge('inner_left', 'partial', 'inner_left'), edge('inner_right', 'partial', 'inner_right'), + ...leaves.map(id => edge(`${id}_join`, id, 'join')), edge('collect', 'join', 'collect')]), + deps({query: async (source, args, signal) => { + if (leaves.includes(source)) { + if (++arrivals === 6) all_started.resolve(); + await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]); + } + if (source === 'collect') { + const triple = leaves.map(id => args.inputs[`${id}_join`].result.output); + triples.push(triple); first_join.resolve(); return triple; + } + return args.value ?? null; + }}), {}, event => { + if (event.type === 'node_finished') finished.get(`${event.node_id}:${event.result.output}`)?.resolve(); + }); + await all_started.promise; + for (const key of ['inner_left:1', 'inner_right:2', 'outer_right:1']) { + gates.get(key).resolve(); await finished.get(key).promise; + } + await setImmediate(); + assert.deepEqual(triples, []); + gates.get('inner_right:1').resolve(); + await first_join.promise; + assert.deepEqual(triples, [[1, 1, 1]]); + gates.get('inner_left:2').resolve(); gates.get('outer_right:2').resolve(); + completed(await run.done, 2); + assert.deepEqual(triples, [[1, 1, 1], [2, 2, 2]]); +}); + +test('workflow partial nested forks open fresh outer join generations over repeated rounds', {timeout: 5000}, async t => { + const leaves = ['inner_left', 'inner_right', 'outer_right']; + const triples = []; + const snapshot = await start(t, definition([ + query('outer_fork', {value: ref('vars', 'round')}), query('partial', {value: ref('last_result', 'output')}), + ...leaves.map(id => query(id, {value: ref('last_result', 'output')})), merge('join'), + query('advance', {inputs: ref('outputs', 'join', 'output')}, {save_as: 'round'}), + condition('again', ref('vars', 'round'), literal(3)), query('done'), + ], [edge('partial', 'outer_fork', 'partial'), edge('outer_right', 'outer_fork', 'outer_right'), + edge('inner_left', 'partial', 'inner_left'), edge('inner_right', 'partial', 'inner_right'), + ...leaves.map(id => edge(`${id}_join`, id, 'join')), edge('advance', 'join', 'advance'), + edge('check', 'advance', 'again'), edge('loop', 'again', 'outer_fork', 'true'), edge('done', 'again', 'done', 'false')], + {initial_vars: {round: 0}}), deps({query: async (source, args) => { + if (source === 'advance') { + const triple = leaves.map(id => args.inputs[`${id}_join`].result.output); + triples.push(triple); return triple[0] + 1; + } + return args.value ?? null; + }})).done; + assert.equal(completed(snapshot).vars.round, 3); + assert.deepEqual(triples, [[0, 0, 0], [1, 1, 1], [2, 2, 2]]); + assert.equal(snapshot.steps, 25); +}); + +test('workflow successive nested all joins preserve overlapping fork generations', {timeout: 5000}, async t => { + const gates = new Map(['x:1', 'x:2', 'c:1', 'c:2'].map(key => [key, deferred()])); + const finished = new Map([...gates.keys()].map(key => [key, deferred()])); + const all_started = deferred(), first_join = deferred(); + let arrivals = 0; + const pairs = []; + const run = start(t, definition([ + query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}), + query('fork', {value: ref('last_result', 'output')}), + ...['a', 'b', 'c'].map(id => query(id, {value: ref('outputs', 'fork', 'output')})), merge('inner'), + query('x', {value: ref('outputs', 'fork', 'output')}), merge('outer'), + query('collect', {inputs: ref('outputs', 'outer', 'output')}), + ], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'), + edge('a_fork', 'seed_a', 'fork'), edge('b_fork', 'seed_b', 'fork'), + ...['a', 'b', 'c'].map(id => edge(`fork_${id}`, 'fork', id)), + edge('a_inner', 'a', 'inner'), edge('b_inner', 'b', 'inner'), edge('inner_x', 'inner', 'x'), + edge('x_outer', 'x', 'outer'), edge('c_outer', 'c', 'outer'), edge('collect', 'outer', 'collect')]), + deps({query: async (source, args, signal) => { + if (source === 'x' || source === 'c') { + if (++arrivals === 4) all_started.resolve(); + await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]); + } + if (source === 'collect') { + const pair = [args.inputs.x_outer.result.output, args.inputs.c_outer.result.output]; + pairs.push(pair); first_join.resolve(); return pair; + } + return args.value ?? null; + }}), {}, event => { + if (event.type === 'node_finished') finished.get(`${event.node_id}:${event.result.output}`)?.resolve(); + }); + await all_started.promise; + for (const key of ['x:1', 'c:2']) { + gates.get(key).resolve(); await finished.get(key).promise; + } + await setImmediate(); + assert.deepEqual(pairs, []); + gates.get('c:1').resolve(); + await first_join.promise; + assert.deepEqual(pairs, [[1, 1]]); + gates.get('x:2').resolve(); + completed(await run.done, 2); + assert.deepEqual(pairs, [[1, 1], [2, 2]]); +}); + +test('workflow all merge accepts equal branch vars with different object key insertion order', {timeout: 5000}, async t => { + const snapshot = await start(t, definition([ + query('fork'), query('left', {}, {save_as: 'shared'}), query('right', {}, {save_as: 'shared'}), merge('join'), + ], [edge('left', 'fork', 'left'), edge('right', 'fork', 'right'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]), deps({query: async source => { + if (source === 'left') return {a: 1, nested: {x: 2, y: [3]}}; + if (source === 'right') return {nested: {y: [3], x: 2}, a: 1}; + return null; + }})).done; + assert.deepEqual(completed(snapshot).vars.shared, {a: 1, nested: {x: 2, y: [3]}}); +}); + +test('workflow all merge keeps one branch write when its sibling retains the inherited value', {timeout: 5000}, async t => { + const branch = completed(await start(t, definition([ + query('fork'), query('write', {}, {save_as: 'shared'}), query('unchanged'), merge('join'), + ], [edge('write', 'fork', 'write'), edge('unchanged', 'fork', 'unchanged'), + edge('write_join', 'write', 'join'), edge('unchanged_join', 'unchanged', 'join')], + {initial_vars: {shared: {value: 0}, preserved: true}}), + deps({query: async source => source === 'write' ? {value: 1} : null})).done); + assert.deepEqual(branch.vars, {shared: {value: 1}, preserved: true}); +}); + +test('workflow merge conflicts route through merge failure edges with the merge node identity', {timeout: 5000}, async t => { + const logs = []; + const branch = completed(await start(t, definition([ + query('fork'), query('left', {}, {save_as: 'shared'}), query('right', {}, {save_as: 'shared'}), merge('join'), + {id: 'recover', type: 'log', config: {level: 'warning', message: ref('last_result', 'error', 'code')}}, + ], [edge('left', 'fork', 'left'), edge('right', 'fork', 'right'), + edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('recover', 'join', 'recover', 'failure')]), + deps({query: async source => source, + log: (level, message) => logs.push({level, message})})).done); + assert.equal(branch.outputs.join.status, 'failure'); + assert.equal(branch.outputs.join.error.code, 'MERGE_CONFLICT'); + assert.equal(branch.outputs.join.error.node_id, 'join'); + assert.deepEqual(logs, [{level: 'warning', message: 'MERGE_CONFLICT'}]); +}); + +test('workflow schedules at most 16 dependencies and resumes queued work as slots free', {timeout: 5000}, async t => { + const gates = Array.from({length: 32}, deferred); + const started = Array.from({length: 32}, deferred); + let active = 0, peak = 0, calls = 0; + const run = start(t, definition([query('fork'), query('work', {inputs: ref('inputs')})], + gates.map((_, index) => edge(`work_${index}`, 'fork', 'work'))), deps({query: async (source, args, signal) => { + if (source === 'fork') return null; + const index = Number(Object.keys(args.inputs)[0].slice('work_'.length)); + calls++; active++; peak = Math.max(peak, active); started[index].resolve(); + try {await Promise.race([gates[index].promise, until_aborted(signal)]); return index;} + finally {active--;} + }})); + await Promise.all(started.slice(0, 16).map(item => item.promise)); + await setImmediate(); + assert.equal(calls, 16); + assert.equal(active, 16); + assert.equal(run.get_snapshot().active_nodes.length, 16); + gates[0].resolve(); + await started[16].promise; + assert.equal(calls, 17); + assert.equal(active, 16); + gates.forEach(item => item.resolve()); + const snapshot = await run.done; + completed(snapshot, 32); + assert.equal(peak, 16); + assert.equal(active, 0); + assert.equal(calls, 32); + assert.equal(snapshot.steps, 33); +}); + +test('workflow token bound accepts 256 branches and rejects 257 before dispatching children', {timeout: 5000}, async t => { + for (const count of [256, 257]) { + let children = 0; + const config = definition([query('fork'), query('leaf')], + Array.from({length: count}, (_, index) => edge(`leaf_${index}`, 'fork', 'leaf'))); + config.policy.max_steps = 512; + const snapshot = await start(t, config, deps({query: async source => { + if (source === 'leaf') children++; + return null; + }})).done; + if (count === 256) { + completed(snapshot, 256); + assert.equal(children, 256); + assert.equal(snapshot.steps, 257); + } else { + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'BRANCH_LIMIT_EXCEEDED'); + assert.equal(children, 0); + assert.equal(snapshot.steps, 1); + } + } +}); + +test('workflow token bound includes completed branches and tokens waiting at all merges', {timeout: 5000}, async t => { + for (const waiting of [false, true]) { + const nodes = [query('root'), query('leaf'), query('expand'), ...(waiting ? [merge('join')] : [query('sink')])]; + const target = waiting ? 'join' : 'sink'; + const edges = [ + ...Array.from({length: 255}, (_, index) => edge(`leaf_${index}`, 'root', 'leaf')), + edge('expand', 'root', 'expand'), edge('expanded_a', 'expand', target), edge('expanded_b', 'expand', target), + ...(waiting ? [edge('leaf_join', 'leaf', 'join')] : []), + ]; + const config = definition(nodes, edges); + config.policy.max_steps = 1024; + const snapshot = await start(t, config).done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'BRANCH_LIMIT_EXCEEDED'); + assert.equal(snapshot.error.node_id, 'expand'); + assert.equal(snapshot.steps, 257); + assert.equal(snapshot.branches.length, waiting ? 0 : 255); + assert.deepEqual(snapshot.active_nodes, []); + } +}); + +test('workflow completion scheduling advances a loop beside a pending sibling and drains at the step limit', {timeout: 5000}, async t => { + const pending_started = deferred(); + let iterations = 0, drained = false; + const config = definition([query('fork'), query('pending'), query('loop')], + [edge('pending', 'fork', 'pending'), edge('loop', 'fork', 'loop'), edge('again', 'loop', 'loop')]); + config.policy.max_steps = 128; + const snapshot = await start(t, config, deps({query: async (source, _args, signal) => { + if (source === 'fork') return null; + if (source === 'pending') { + pending_started.resolve(); + try {await until_aborted(signal);} finally {await setImmediate(); drained = true;} + } + await pending_started.promise; + return ++iterations; + }})).done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'STEP_LIMIT_EXCEEDED'); + assert.equal(snapshot.steps, 128); + assert.equal(iterations, 126); + assert.equal(drained, true); + assert.deepEqual(snapshot.active_nodes, []); +}); + +test('workflow JSON guards reject prototypes, accessors and cycles without invoking getters', () => { + let reads = 0; + const accessor = Object.defineProperty({}, 'secret', {enumerable: true, get() {reads++; return 1;}}); + const cyclic = {}; cyclic.self = cyclic; + for (const value of [Object.create({inherited: 1}), JSON.parse('{"__proto__":1}'), accessor, cyclic]) { + assert.throws(() => assert_json(value), {code: 'INVALID_JSON'}); + } + const state = {trigger: accessor, vars: Object.create({inherited: 1}), outputs: {}, last_result: null, inputs: {}, retry_counts: {}}; + assert.throws(() => resolve_value(ref('trigger', 'secret'), state), {code: 'VALUE_NOT_FOUND'}); + assert.throws(() => resolve_value(ref('vars', 'inherited'), state), {code: 'VALUE_NOT_FOUND'}); + assert.throws(() => resolve_value(ref('vars', '__proto__'), state), {code: 'INVALID_VALUE_SOURCE'}); + assert.throws(() => resolve_value(literal(cyclic), state), {code: 'INVALID_JSON'}); + assert.equal(reads, 0); + const shared = {value: [null, true, 2, 'json']}; + assert.doesNotThrow(() => assert_json({left: shared, right: shared})); + assert.deepEqual(resolve_value(ref('trigger', 'value', 2), {...state, trigger: shared}), 2); +}); + +test('workflow code workers normalize oversized and non-JSON results into routable failures', {timeout: 5000}, async t => { + for (const source of ['return "x".repeat(1048577);', 'return undefined;', 'return NaN;', + 'const value = {}; value.self = value; return value;']) { + const snapshot = await start(t, definition([ + {id: 'code', type: 'code', config: {source}}, query('recover', {code: ref('last_result', 'error', 'code')}), + ], [edge('failure', 'code', 'recover', 'failure')])).done; + const branch = completed(snapshot); + assert.equal(branch.outputs.code.status, 'failure'); + assert.equal(branch.outputs.code.output, null); + assert.equal(branch.outputs.code.error.code, 'INVALID_JSON'); + assert.equal(branch.outputs.code.error.node_id, 'code'); + assert.deepEqual(branch.outputs.recover.output, {code: 'INVALID_JSON'}); + } +}); + +test('workflow code worker infinite loop times out and routes NODE_TIMEOUT after termination', {timeout: 5000}, async t => { + const snapshot = await start(t, definition([ + {id: 'code', type: 'code', timeout_ms: 150, config: {source: 'while (true) {}'}}, + query('recover', {code: ref('last_result', 'error', 'code')}), + ], [edge('timeout', 'code', 'recover', 'failure')])).done; + const branch = completed(snapshot); + assert.equal(snapshot.stop_reason, null); + assert.equal(branch.outputs.code.error.code, 'NODE_TIMEOUT'); + assert.deepEqual(branch.outputs.recover.output, {code: 'NODE_TIMEOUT'}); +}); + +test('workflow run timeout aborts dependencies and finishes as timed_out', {timeout: 5000}, async t => { + let drained = false; + const config = definition([query('slow')]); + config.policy.timeout_ms = 150; + const snapshot = await start(t, config, deps({query: async (_source, _args, signal) => { + try {await until_aborted(signal);} finally {await setImmediate(); drained = true;} + }})).done; + assert.equal(snapshot.status, 'timed_out'); + assert.equal(snapshot.stop_reason, 'timeout'); + assert.equal(drained, true); + assert.deepEqual(snapshot.active_nodes, []); + assert.equal(snapshot.branches.length, 0); +}); + +test('workflow cancellation drains all cooperative fork dependencies before stop or done resolves', {timeout: 5000}, async t => { + const started = Array.from({length: 3}, deferred); + const aborted = Array.from({length: 3}, deferred); + const cleanup = deferred(); + let active = 0, drained = 0; + const block = async (index, signal) => { + active++; started[index].resolve(); + try {await until_aborted(signal);} finally { + aborted[index].resolve(); await cleanup.promise; active--; drained++; + } + }; + t.after(() => cleanup.resolve()); + const run = start(t, definition([ + query('fork'), fn('function_wait'), query('query_wait'), + {id: 'sleep_wait', type: 'sleep', config: {mode: 'ticks', value: 10}}, + ], [edge('function', 'fork', 'function_wait'), edge('query', 'fork', 'query_wait'), edge('sleep', 'fork', 'sleep_wait')]), + deps({run_function: (_path, _name, _args, signal) => block(0, signal), + query: (source, _args, signal) => source === 'fork' ? Promise.resolve(null) : block(1, signal), + sleep: (_mode, _value, signal) => block(2, signal)})); + await Promise.all(started.map(item => item.promise)); + let stop_finished = false, done_finished = false; + const stopping = run.stop('disconnect').then(() => {stop_finished = true;}); + run.done.then(() => {done_finished = true;}); + await Promise.all(aborted.map(item => item.promise)); + await setImmediate(); + assert.equal(run.get_snapshot().status, 'stopping'); + assert.equal(active, 3); + assert.equal(stop_finished, false); + assert.equal(done_finished, false); + cleanup.resolve(); + await stopping; + const snapshot = await run.done; + assert.equal(snapshot.status, 'cancelled'); + assert.equal(snapshot.stop_reason, 'disconnect'); + assert.equal(active, 0); + assert.equal(drained, 3); + assert.deepEqual(snapshot.active_nodes, []); + assert.equal(snapshot.branches.length, 0); + await run.stop('manual'); + assert.equal(run.get_snapshot().stop_reason, 'disconnect'); +}); + +test('workflow unhandled failure aborts and drains sibling work before done resolves', {timeout: 5000}, async t => { + const started = deferred(), aborted = deferred(), cleanup = deferred(); + let drained = false, settled = false; + t.after(() => cleanup.resolve()); + const run = start(t, definition([query('fork'), fn('fail'), query('wait')], + [edge('fail', 'fork', 'fail'), edge('wait', 'fork', 'wait')]), deps({ + run_function: async () => {await started.promise; return false;}, + query: async (source, _args, signal) => { + if (source === 'fork') return null; + started.resolve(); + try {await until_aborted(signal);} finally { + aborted.resolve(); await cleanup.promise; drained = true; + } + }, + })); + run.done.then(() => {settled = true;}); + await aborted.promise; + await setImmediate(); + assert.equal(settled, false); + assert.equal(drained, false); + cleanup.resolve(); + const snapshot = await run.done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'FUNCTION_RETURNED_FALSE'); + assert.equal(drained, true); + assert.deepEqual(snapshot.active_nodes, []); + assert.equal(snapshot.branches.length, 1); + assert.equal(snapshot.branches[0].outputs.wait, undefined); +}); + +test('workflow max_steps terminates cycles at the configured execution budget', {timeout: 5000}, async t => { + const config = definition([query('loop')], [edge('again', 'loop', 'loop')]); + config.policy.max_steps = 5; + let calls = 0; + const snapshot = await start(t, config, deps({query: async () => ++calls})).done; + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.error.code, 'STEP_LIMIT_EXCEEDED'); + assert.equal(snapshot.steps, 5); + assert.equal(calls, 5); + assert.deepEqual(snapshot.active_nodes, []); +}); + +test('workflow code worker returns JSON from trigger state', {timeout: 5000}, async t => { + const branch = completed(await start(t, definition([ + {id: 'code', type: 'code', config: {source: 'return {value: state.trigger.value, initial: state.vars.initial};'}}, + ], [], {initial_vars: {initial: 8}}), deps(), {value: ['worker', 42]}).done); + assert.deepEqual(branch.outputs.code.output, {value: ['worker', 42], initial: 8}); +}); + +test('workflow cancelling an infinite code loop terminates and drains its worker', {timeout: 5000}, async t => { + const entered = deferred(); + const run = start(t, definition([ + {id: 'loop', type: 'code', config: {source: 'while (true) {}'}}, + ]), deps(), {}, event => { + if (event.type === 'node_started' && event.node_id === 'loop') entered.resolve(); + }); + await entered.promise; + await delay(75); + assert.equal(run.get_snapshot().status, 'running'); + await run.stop('manual'); + const snapshot = await run.done; + assert.equal(snapshot.status, 'cancelled'); + assert.deepEqual(snapshot.active_nodes, []); + assert.equal(snapshot.branches.length, 0); +}); + +test('workflow schema rejects malformed definitions, references, policies and graph topology', async t => { + const valid = () => definition([query('first'), query('last')], [edge('next', 'first', 'last')]); + const cases = [ + ['schema version', value => {value.schema_version = 2;}], + ['duplicate node', value => {value.nodes.push(structuredClone(value.nodes[0]));}], + ['duplicate edge', value => {value.edges.push(structuredClone(value.edges[0]));}], + ['dangling source', value => {value.edges[0].source = 'missing';}], + ['dangling target', value => {value.edges[0].target = 'missing';}], + ['missing entry', value => {value.entry = 'missing';}], + ['unreachable node', value => {value.nodes.push(query('unreachable'));}], + ['invalid port', value => {value.edges[0].port = 'true';}], + ['unknown node type', value => {value.nodes[0].type = 'unknown';}], + ['zero step budget', value => {value.policy.max_steps = 0;}], + ['fractional step budget', value => {value.policy.max_steps = 1.5;}], + ['negative queue', value => {value.policy.max_queue = -1;}], + ['invalid busy policy', value => {value.policy.on_busy = 'parallel';}], + ['zero run timeout', value => {value.policy.timeout_ms = 0;}], + ['zero node timeout', value => {value.nodes[0].timeout_ms = 0;}], + ['invalid reference root', value => {value.nodes[0].config.args.x = ref('unknown');}], + ['unsafe reference path', value => {value.nodes[0].config.args.x = ref('vars', '__proto__');}], + ['negative reference index', value => {value.nodes[0].config.args.x = ref('trigger', -1);}], + ['non-JSON literal', value => {value.nodes[0].config.args.x = literal(NaN);}], + ['goto without retry target', value => {value.nodes[1] = {id: 'last', type: 'goto', config: {max_retries: 2, delay_ms: 0}};}], + ['all merge with one input', value => {value.nodes[1] = merge('last');}], + ['unsafe initial var', value => {value.initial_vars = JSON.parse('{"__proto__":1}');}], + ]; + for (const [name, mutate] of cases) await t.test(name, () => { + const value = valid(); mutate(value); + assert.throws(() => compile_workflow(value)); + }); + assert.doesNotThrow(() => compile_workflow(valid())); +}); diff --git a/tests/workflow_catalog.test.mjs b/tests/workflow_catalog.test.mjs new file mode 100644 index 0000000..ee3f895 --- /dev/null +++ b/tests/workflow_catalog.test.mjs @@ -0,0 +1,457 @@ +import assert from 'node:assert/strict'; +import {createHook} from 'node:async_hooks'; +import {isBuiltin, createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import {runInThisContext} from 'node:vm'; +import test from 'node:test'; +import {build} from 'esbuild'; +import {tsImport} from 'tsx/esm/api'; + +const {get_workflow_catalog, validate_workflow_catalog} = await tsImport('../bot/workflow/catalog.ts', import.meta.url); +const {node_registry, start_registry, query_registry} = await tsImport('../bot/workflow/registry.ts', import.meta.url); +const {event_registry, normalize_event} = await tsImport('../bot/workflow/node_action/event_node/registry.ts', import.meta.url); +const {register_event_start} = await tsImport('../bot/workflow/start_node/event_node.ts', import.meta.url); +const {resolve_fields, validate_fields, validate_output} = await tsImport('../bot/workflow/runtime/template.ts', import.meta.url); +const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url); +const {execute_node} = await tsImport('../bot/workflow/runtime/execute.ts', import.meta.url); + +const node_names = ['code', 'condition', 'error', 'function', 'goto', 'log', 'merge', 'query', 'sleep']; +const start_names = ['common', 'event', 'timer']; +const event_names = ['chat', 'custom', 'health', 'message', 'spawn', 'whisper']; +const ports = { + function: ['failure', 'success'], query: ['failure', 'success'], sleep: ['failure', 'success'], + log: ['failure', 'success'], code: ['failure', 'success'], merge: ['failure', 'success'], + condition: ['failure', 'false', 'true'], error: ['error', 'failure', 'normal', 'unmatched'], + goto: ['exhausted', 'failure', 'retry'], +}; +const names = items => items.map(item => item.name).sort(); +const literal = value => ({kind: 'literal', value}); +const translation = () => [{lang: 'en', name: 'Test field', description: 'A described test value.'}]; +const field = (name, type, extra = {}) => ({name, type, required: false, i18n: translation(), ...extra}); +const node_template = (catalog, name) => catalog.nodes.find(node => node.name === name); +const config_field = (catalog, node, name) => node_template(catalog, node).config.find(item => item.name === name); +function definition(type = 'sleep', config = {}, extra = {}) { + return { + schema_version: 1, id: 'catalog_test', name: 'Catalog test', enabled: true, + start: {type: 'common'}, entry: 'entry', nodes: [{id: 'entry', type, config}], edges: [], policy: {}, + ...extra, + }; +} +function described(i18n, path) { + assert.ok(Array.isArray(i18n) && i18n.length > 0, `${path} translations`); + assert.equal(new Set(i18n.map(item => item.lang)).size, i18n.length, `${path} unique languages`); + for (const item of i18n) { + for (const key of ['lang', 'name', 'description']) { + assert.equal(typeof item[key], 'string', `${path}.${key}`); + assert.ok(item[key].trim(), `${path}.${key} cannot be empty`); + } + } +} +function described_fields(fields, path) { + assert.equal(new Set(fields.map(item => item.name)).size, fields.length, `${path} unique fields`); + for (const item of fields) { + described(item.i18n, `${path}.${item.name}`); + assert.equal(typeof item.required, 'boolean'); + for (const option of item.options ?? []) described(option.i18n, `${path}.${item.name}.${option.value}`); + if (item.fields) described_fields(item.fields, `${path}.${item.name}`); + if (item.item) described_fields([item.item], `${path}.${item.name}.item`); + } +} +function described_output(output, path) { + described(output.i18n, path); + for (const [name, child] of Object.entries(output.properties ?? {})) described_output(child, `${path}.${name}`); + if (output.items) described_output(output.items, `${path}.items`); + if (output.additional_properties) described_output(output.additional_properties, `${path}.*`); +} +function matches_output(value, output, path = 'output') { + if (value === null && (output.nullable || output.type === 'json' || output.type === 'null')) return; + if (output.type === 'json') return; + if (output.type === 'object') { + assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value), path); + for (const [key, child] of Object.entries(value)) { + const descriptor = output.properties?.[key] ?? output.additional_properties; + assert.ok(descriptor, `${path}.${key} must be described`); + matches_output(child, descriptor, `${path}.${key}`); + } + } else if (output.type === 'array') { + assert.ok(Array.isArray(value), path); + assert.ok(output.items, `${path} needs item descriptions`); + value.forEach((child, index) => matches_output(child, output.items, `${path}.${index}`)); + } else assert.equal(typeof value, output.type, path); +} + +test('runtime fixture ports and output values agree with all nine node declarations', async t => { + const state = {trigger: {}, vars: {}, outputs: {}, last_result: null, inputs: {}, retry_counts: {}}; + const failed = {...state, last_result: {status: 'failure', output: false, + error: {code: 'TEST', message: 'fixture failure', node_id: 'prior'}}}; + const deps = { + run_function: async () => true, query: async () => ({value: false}), sleep: async () => {}, + log() {}, execute_code: async () => [0, false, null], + }; + const fixtures = [ + ['function', {path: 'bot', name: 'chat', args: []}, state, 'success', true], + ['query', {source: 'info', args: {}}, state, 'success', {value: false}], + ['sleep', {mode: 'milliseconds', value: 0}, state, 'success', null], + ['log', {level: 'info', message: literal(false)}, state, 'success', false], + ['code', {source: 'return [0, false, null];'}, state, 'success', [0, false, null]], + ['condition', {condition: {op: 'eq', left: literal(0), right: literal(0)}}, state, 'true', true], + ['condition', {condition: {op: 'eq', left: literal(0), right: literal(false)}}, state, 'false', false], + ['error', {}, state, 'normal', null], ['error', {}, failed, 'error', false], + ['error', {codes: []}, failed, 'unmatched', false], + ['goto', {max_retries: 1, delay_ms: 0}, state, 'retry', null], + ['goto', {max_retries: 0, delay_ms: 0}, state, 'exhausted', null], + ['merge', {mode: 'any'}, state, 'success', {}], + ]; + for (const [type, config, branch, port, output] of fixtures) await t.test(`${type}.${port}`, async () => { + const returned = await node_registry[type].execute({id: 'fixture', type, config}, structuredClone(branch), new AbortController().signal, deps); + assert.equal(returned.port, port); + assert.deepEqual(returned.result.output, output); + assert.ok(node_registry[type].config_template.ports.some(item => item.name === returned.port)); + matches_output(returned.result.output, node_registry[type].config_template.output, `${type}.output`); + }); + for (const type of ['function', 'condition', 'merge']) await t.test(`${type}.failure null output`, async () => { + const returned = await execute_node({id: 'fixture', type, config: {}, timeout_ms: 0}, structuredClone(state), new AbortController().signal, deps); + assert.equal(returned.port, 'failure'); + assert.equal(returned.result.status, 'failure'); + assert.equal(returned.result.output, null); + matches_output(returned.result.output, node_registry[type].config_template.output, `${type}.failure.output`); + }); +}); + +test('info query output describes the actual value including null and falsy values', () => { + const module = query_registry.info; + for (const value of [null, false, 0, '', [], {nested: [false, 0, null]}]) { + const result = module.query_info({info_manager: {get_info(name) { + assert.equal(name, 'fixture'); + return {name: 'fixture', i18n: translation(), value}; + }}}, {name: 'fixture'}); + assert.deepEqual(result, value); + matches_output(result, module.config_template.output); + } + for (const args of [{}, {name: ''}, {name: 0}]) { + assert.throws(() => resolve_fields(module.config_template.config, args)); + assert.throws(() => module.query_info({info_manager: null}, args), {code: 'INVALID_QUERY_ARGUMENTS'}); + } +}); + +test('catalog contains every registered node, start, event and query with recursive descriptions', () => { + const catalog = get_workflow_catalog(); + assert.equal(catalog.catalog_version, 1); + assert.deepEqual(names(catalog.nodes), node_names); + assert.deepEqual(names(catalog.start_nodes), start_names); + assert.deepEqual(names(catalog.events), event_names); + assert.deepEqual(names(catalog.queries), ['info']); + for (const [registry, entries] of [ + [node_registry, catalog.nodes], [start_registry, catalog.start_nodes], + [event_registry, catalog.events], [query_registry, catalog.queries], + ]) { + assert.deepEqual(Object.keys(registry).sort(), names(entries)); + for (const [name, module] of Object.entries(registry)) { + assert.equal(module.config_template.name, name); + assert.deepEqual(entries.find(item => item.name === name).config, module.config_template.config); + } + } + for (const item of [...catalog.nodes, ...catalog.start_nodes, ...catalog.events, ...catalog.queries]) { + assert.match(item.version, /^\d+\.\d+\.\d+$/); + described(item.i18n, item.name); + described_fields(item.config, item.name); + described_output(item.output, `${item.name}.output`); + assert.doesNotThrow(() => validate_fields(item.config)); + assert.doesNotThrow(() => validate_output(item.output)); + } + for (const node of catalog.nodes) { + assert.equal(typeof node_registry[node.name].execute, 'function'); + assert.deepEqual(names(node.ports), ports[node.name]); + node.ports.forEach(port => described(port.i18n, `${node.name}.${port.name}`)); + } + catalog.events.forEach(event => assert.equal(event.readiness, 'spawned')); + assert.equal(catalog.events.find(event => event.name === 'custom').custom, true); + assert.equal(catalog.queries[0].provider, 'info'); + assert.equal(catalog.queries[0].config.find(item => item.name === 'name').provider, 'info'); + described_fields(catalog.common.node_fields, 'common.node_fields'); + described_fields(catalog.common.policy_fields, 'common.policy_fields'); + described_output(catalog.common.result, 'common.result'); + described_output(catalog.common.edge_input, 'common.edge_input'); + assert.deepEqual(catalog.common.references.map(item => item.root).sort(), ['inputs', 'last_result', 'outputs', 'trigger', 'vars']); + catalog.common.references.forEach(item => described(item.i18n, item.root)); + assert.doesNotThrow(() => validate_workflow_catalog(catalog)); +}); + +test('catalog inspection imports and runs without creating Bot, timer or worker resources', async () => { + const compiled = await build({ + entryPoints: [fileURLToPath(new URL('../bot/workflow/catalog.ts', import.meta.url))], + bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true, + }); + assert.ok(!Object.keys(compiled.metafile.inputs).some(path => /bot\/(instance|registry)|forward_server/.test(path))); + const resources = []; + const hook = createHook({init(_id, type) { resources.push(type); }}); + const require = createRequire(import.meta.url); + const module = {exports: {}}; + const before = process.getActiveResourcesInfo().sort(); + hook.enable(); + try { + const load = runInThisContext(`(function(module, exports, require, setTimeout, setInterval) {\n${compiled.outputFiles[0].text}\n})`); + load(module, module.exports, + name => { assert.ok(isBuiltin(name), `unexpected runtime dependency ${name}`); return require(name); }, + () => assert.fail('catalog started a timeout'), + () => assert.fail('catalog started an interval')); + for (let index = 0; index < 3; index++) module.exports.get_workflow_catalog({allow_code: index === 1}); + } finally { hook.disable(); } + assert.deepEqual(process.getActiveResourcesInfo().sort(), before); + assert.deepEqual(resources.filter(type => /^(Timeout|WORKER|MESSAGEPORT|TCPWRAP|TCPSERVERWRAP|UDPWRAP)$/.test(type)), []); +}); + +test('catalog results are detached at every nested level and code is disabled unless explicitly enabled', () => { + const baseline = get_workflow_catalog(); + const changed = get_workflow_catalog(); + node_template(changed, 'sleep').i18n[0].name = 'changed'; + config_field(changed, 'sleep', 'mode').options[0].i18n[0].description = 'changed'; + config_field(changed, 'function', 'args').default.push(literal(9)); + config_field(changed, 'query', 'args').default.changed = literal(false); + node_template(changed, 'merge').output.additional_properties.properties.source.i18n[0].description = 'changed'; + changed.events[0].output.properties.event.i18n[0].description = 'changed'; + changed.start_nodes[0].config.length = 0; + changed.queries[0].i18n.length = 0; + changed.common.node_fields[0].default = 2; + changed.common.result.properties.status.i18n[0].description = 'changed'; + changed.common.references[0].root = 'changed'; + node_template(changed, 'code').availability.available = true; + assert.deepEqual(get_workflow_catalog(), baseline); + assert.deepEqual(node_template(baseline, 'code').availability, {available: false, reason: 'CODE_EXECUTION_DISABLED'}); + const enabled = get_workflow_catalog({allow_code: true}); + assert.deepEqual(node_template(enabled, 'code').availability, {available: true}); + enabled.nodes = enabled.nodes.filter(node => node.name !== 'code'); + assert.deepEqual(get_workflow_catalog({allow_code: false}), baseline); + assert.equal(node_registry.code.config_template.availability.available, false); +}); + +test('compile applies documented defaults without mutating caller input or registry templates', () => { + const input = definition(); + const original = structuredClone(input); + const catalog = get_workflow_catalog(); + const graph = compile_workflow(input); + assert.deepEqual(graph.definition.start, {type: 'common', auto_start: 'never'}); + assert.deepEqual(graph.nodes.get('entry').config, {mode: 'milliseconds', value: 1000}); + assert.equal(graph.nodes.get('entry').timeout_ms, 30000); + assert.deepEqual(graph.definition.policy, { + max_concurrent_runs: 1, on_busy: 'skip', max_queue: 10, max_steps: 1000, timeout_ms: 60000, + }); + assert.deepEqual(input, original); + graph.definition.start.auto_start = 'spawn'; + graph.nodes.get('entry').config.value = 2; + graph.definition.policy.max_queue = 999; + assert.deepEqual(input, original); + assert.deepEqual(get_workflow_catalog(), catalog); + assert.equal(compile_workflow(input).nodes.get('entry').config.value, 1000); + const without_policy = definition(); + delete without_policy.policy; + assert.deepEqual(compile_workflow(without_policy).definition.policy, resolve_fields(catalog.common.policy_fields, {})); +}); + +test('compile preserves explicit zero, false and complete valid configs', () => { + const input = definition('sleep', {mode: 'ticks', value: 0}, { + enabled: false, start: {type: 'common', auto_start: 'spawn'}, initial_vars: {zero: 0, flag: false, empty: ''}, + policy: {max_concurrent_runs: 2, on_busy: 'queue', max_queue: 0, max_steps: 1, timeout_ms: 1}, + }); + input.nodes[0].timeout_ms = 1; + const original = structuredClone(input); + assert.deepEqual(compile_workflow(input).definition, original); + assert.deepEqual(input, original); + const fn = compile_workflow(definition('function', {path: 'bot', name: 'chat'})); + assert.deepEqual(fn.nodes.get('entry').config, {path: 'bot', name: 'chat', args: []}); + const query = compile_workflow(definition('query', {source: 'info'})); + assert.deepEqual(query.nodes.get('entry').config, {source: 'info', args: {}}); + const log = compile_workflow(definition('log', {message: literal(false)})); + assert.deepEqual(log.nodes.get('entry').config, {level: 'info', message: literal(false)}); +}); + +test('compile preserves omitted error filters as match-all and explicit empty filters as match-none', async () => { + const state = {last_result: {status: 'failure', output: false, error: {code: 'TEST', message: 'test', node_id: 'prior'}}}; + const signal = new AbortController().signal; + const omitted = compile_workflow(definition('error')).nodes.get('entry'); + assert.equal(Object.hasOwn(omitted.config, 'codes'), false); + assert.equal((await node_registry.error.execute(omitted, state, signal, {})).port, 'error'); + const empty = compile_workflow(definition('error', {codes: []})).nodes.get('entry'); + assert.equal((await node_registry.error.execute(empty, state, signal, {})).port, 'unmatched'); +}); + +test('compile rejects explicit invalid values rather than replacing them with defaults', async t => { + const cases = [ + ['sleep null', value => { value.nodes[0].config.value = null; }], + ['sleep false', value => { value.nodes[0].config.value = false; }], + ['sleep string', value => { value.nodes[0].config.value = '1000'; }], + ['sleep negative', value => { value.nodes[0].config.value = -1; }], + ['sleep fraction', value => { value.nodes[0].config.value = 0.5; }], + ['sleep overflow', value => { value.nodes[0].config.value = 2147483648; }], + ['invalid select', value => { value.nodes[0].config.mode = 'seconds'; }], + ['null config', value => { value.nodes[0].config = null; }], + ['array config', value => { value.nodes[0].config = []; }], + ['node timeout zero', value => { value.nodes[0].timeout_ms = 0; }], + ['node timeout null', value => { value.nodes[0].timeout_ms = null; }], + ['invalid start choice', value => { value.start.auto_start = false; }], + ['null policy', value => { value.policy = null; }], + ['invalid busy choice', value => { value.policy.on_busy = 'replace'; }], + ['negative queue', value => { value.policy.max_queue = -1; }], + ['queue overflow', value => { value.policy.max_queue = 1001; }], + ['zero steps', value => { value.policy.max_steps = 0; }], + ['zero run timeout', value => { value.policy.timeout_ms = 0; }], + ['zero concurrency', value => { value.policy.max_concurrent_runs = 0; }], + ['concurrency overflow', value => { value.policy.max_concurrent_runs = 257; }], + ]; + for (const [name, mutate] of cases) await t.test(name, () => { + const input = definition(); mutate(input); + const original = structuredClone(input); + assert.throws(() => compile_workflow(input)); + assert.deepEqual(input, original); + }); + const invalid_nodes = [ + ['function', {}], ['function', {path: 'bot'}], ['function', {name: 'chat'}], + ['function', {path: 'bot', name: 'chat', args: null}], + ['function', {path: 'bot', name: 'chat', args: Array(101).fill(literal(0))}], + ['query', {}], ['query', {source: 'info', args: []}], + ['condition', {}], ['condition', {condition: {op: 'unknown'}}], + ['log', {}], ['log', {level: 'debug', message: literal('test')}], + ['code', {}], ['code', {source: false}], ['code', {source: 'x'.repeat(100001)}], + ]; + for (const [index, [type, config]] of invalid_nodes.entries()) await t.test(`${type} required fields and constraints ${index}`, () => { + assert.throws(() => compile_workflow(definition(type, config))); + }); + assert.doesNotThrow(() => compile_workflow(definition('code', {source: 'return false;'}))); +}); + +test('resolve_fields fills nested absent values and returns independent defaults', () => { + const child_fields = [field('count', 'number', {default: 7, integer: true, min: 0, max: 10}), field('flag', 'boolean', {default: true})]; + const child = field('entry', 'object', {fields: child_fields}); + const fields = [ + field('count', 'number', {default: 5}), field('flag', 'boolean', {default: true}), + field('label', 'string', {default: 'fallback'}), field('settings', 'object', {fields: child_fields, default: {}}), + field('rows', 'array', {item: child, default: [{}]}), field('lookup', 'map', {item: child, default: {first: {}}}), + field('optional', 'string'), + ]; + const template_before = structuredClone(fields); + const input = {count: 0, flag: false, label: '', settings: {count: 0, flag: false}, rows: [{}], lookup: {first: {flag: false}}, extra: {ok: false}}; + const before = structuredClone(input); + const result = resolve_fields(fields, input); + assert.deepEqual(result, { + count: 0, flag: false, label: '', settings: {count: 0, flag: false}, rows: [{count: 7, flag: true}], + lookup: {first: {count: 7, flag: false}}, extra: {ok: false}, + }); + assert.equal(Object.hasOwn(result, 'optional'), false); + result.rows[0].count = 9; result.extra.ok = true; + const defaults = resolve_fields(fields, {}); + defaults.lookup.first.flag = false; + defaults.rows.push({count: 1, flag: false}); + assert.deepEqual(resolve_fields(fields, {}).rows, [{count: 7, flag: true}]); + assert.deepEqual(input, before); + assert.deepEqual(fields, template_before); + for (const value of [null, '0', false, -1, 11, 0.5]) { + assert.throws(() => resolve_fields(child_fields, {count: value}), `invalid explicit count ${value}`); + } + let getters = 0; + const accessor = {get count() { getters++; return 1; }}; + assert.throws(() => resolve_fields(child_fields, accessor)); + assert.equal(getters, 0); +}); + +test('normalizers return exactly the keys and types described by event templates', async t => { + const fixtures = [ + ['message', [{json: {translate: 'chat.type.text', with: ['Alex', 'hello']}}, 'chat', 'sender-id', false], + {event: 'message', username: 'Alex', message: 'hello', plainText: ' hello', position: 'chat', sender: 'sender-id', verified: false}], + ['message', [{text: 'system notice'}], + {event: 'message', username: '', message: 'system notice', plainText: 'system notice', position: '', sender: null, verified: null}], + ['chat', ['Alex', 'hello', new Date()], {event: 'chat', args: ['Alex', 'hello', null], username: 'Alex', message: 'hello'}], + ['whisper', ['Alex', 'secret'], {event: 'whisper', args: ['Alex', 'secret'], username: 'Alex', message: 'secret'}], + ['spawn', [], {event: 'spawn', args: []}], ['health', [], {event: 'health', args: []}], + ['custom', [0, false, null, {nested: ['ok']}], {event: 'custom', args: [0, false, null, {nested: ['ok']}]}], + ]; + for (const [index, [event, args, expected]] of fixtures.entries()) await t.test(`${event} ${index}`, () => { + const value = event_registry[event].normalize(args); + assert.deepEqual(value, expected); + assert.deepEqual(Object.keys(value).sort(), Object.keys(event_registry[event].config_template.output.properties).sort()); + matches_output(value, event_registry[event].config_template.output); + assert.deepEqual(normalize_event(event, args), expected); + }); + for (const event of ['chat', 'whisper']) { + assert.equal(normalize_event(event, [null, 'message']), undefined); + assert.equal(normalize_event(event, ['Alex', 0]), undefined); + } + assert.equal(normalize_event('message', [{text: 'x'.repeat(65537)}]), undefined); + assert.equal(normalize_event('custom', Array(33).fill(0)), undefined); +}); + +test('unknown and custom events sanitize unsafe arguments to null and detach JSON arguments', () => { + const cycle = {}; cycle.self = cycle; + let getters = 0; + const accessor = {get value() { getters++; return 1; }}; + const nested = {values: [0, false, null]}; + const args = [nested, undefined, () => {}, new Date(), NaN, Infinity, cycle, accessor, Symbol('test'), 1n]; + const expected = {event: 'unknown_event', args: [nested, ...Array(9).fill(null)]}; + const result = normalize_event('unknown_event', args); + assert.deepEqual(result, expected); + matches_output(result, event_registry.custom.config_template.output); + result.args[0].values[0] = 7; + assert.deepEqual(nested, {values: [0, false, null]}); + assert.equal(getters, 0); + assert.deepEqual(normalize_event('custom', [null]), {event: 'custom', args: [null]}); + assert.deepEqual(normalize_event('constructor', [false]), {event: 'constructor', args: [false]}); + assert.deepEqual(normalize_event('any', ['chat', 'Alex', 'hi']), {event: 'any', args: ['chat', 'Alex', 'hi']}); + assert.equal(normalize_event('unknown_event', ['x'.repeat(65537)]), undefined); + assert.equal(normalize_event('unknown_event', Array(33).fill(0)), undefined); +}); + +test('event start ignores workflow_update recursion directly and through any', () => { + const listeners = new Map(); + const context = { + on(event, callback) { listeners.set(event, callback); }, + off(event, callback) { if (listeners.get(event) === callback) listeners.delete(event); }, + }; + const received = []; + const config = definition('sleep', {}, {start: {type: 'event', event: 'any'}}); + const dispose = register_event_start(context, config, trigger => received.push(trigger)); + try { + listeners.get('any')('workflow_update', {run_id: 'test'}); + assert.deepEqual(received, []); + listeners.get('any')('custom', {value: false}); + assert.deepEqual(received, [{event: 'any', args: ['custom', {value: false}]}]); + assert.throws(() => register_event_start(context, + definition('sleep', {}, {start: {type: 'event', event: 'workflow_update'}}), () => {})); + } finally { dispose(); dispose(); } + assert.equal(listeners.size, 0); +}); + +test('catalog validator rejects duplicate entries, invalid translations, ports and recursive templates', async t => { + const cases = [ + ['version', value => { value.catalog_version = 2; }], + ...['nodes', 'start_nodes', 'events', 'queries'].map(group => [`duplicate ${group}`, value => { value[group].push(structuredClone(value[group][0])); }]), + ['entry version', value => { value.nodes[0].version = 'latest'; }], + ['missing translation', value => { value.nodes[0].i18n = []; }], + ['duplicate translation', value => { value.nodes[0].i18n.push({...value.nodes[0].i18n[0]}); }], + ['blank description', value => { value.nodes[0].i18n[0].description = ' '; }], + ['duplicate port', value => { value.nodes[0].ports.push(structuredClone(value.nodes[0].ports[0])); }], + ['invalid port name', value => { value.nodes[0].ports[0].name = 'bad port'; }], + ['missing failure port', value => { value.nodes[0].ports = value.nodes[0].ports.filter(port => port.name !== 'failure'); }], + ['port description', value => { value.nodes[0].ports[0].i18n = []; }], + ['duplicate field', value => { value.nodes[0].config.push(structuredClone(value.nodes[0].config[0])); }], + ['field description', value => { config_field(value, 'sleep', 'value').i18n[0].description = ''; }], + ['numeric default type', value => { config_field(value, 'sleep', 'value').default = false; }], + ['numeric default range', value => { config_field(value, 'sleep', 'value').default = -1; }], + ['numeric default integer', value => { config_field(value, 'sleep', 'value').default = 0.5; }], + ['inverted range', value => { config_field(value, 'sleep', 'value').min = 2147483648; }], + ['select default', value => { config_field(value, 'sleep', 'mode').default = 'seconds'; }], + ['select choices missing', value => { config_field(value, 'sleep', 'mode').options = []; }], + ['select choices duplicate', value => { const options = config_field(value, 'sleep', 'mode').options; options.push(structuredClone(options[0])); }], + ['choice description', value => { config_field(value, 'sleep', 'mode').options[0].i18n[0].description = ''; }], + ['array item missing', value => { delete config_field(value, 'function', 'args').item; }], + ['array item description', value => { config_field(value, 'function', 'args').item.i18n = []; }], + ['map item missing', value => { delete config_field(value, 'query', 'args').item; }], + ['output description', value => { value.events[0].output.properties.event.i18n = []; }], + ['array output item', value => { value.events.find(event => event.name === 'custom').output.properties.args.items.type = 'unknown'; }], + ['wrong output items owner', value => { node_template(value, 'sleep').output.items = {type: 'json', i18n: translation()}; }], + ['common field default', value => { value.common.node_fields[0].default = 0; }], + ['common reference translation', value => { value.common.references[0].i18n = []; }], + ]; + for (const [name, mutate] of cases) await t.test(name, () => { + const catalog = get_workflow_catalog(); mutate(catalog); + assert.throws(() => validate_workflow_catalog(catalog)); + }); +}); diff --git a/tests/workflow_catalog_rpc.test.mjs b/tests/workflow_catalog_rpc.test.mjs new file mode 100644 index 0000000..f389cff --- /dev/null +++ b/tests/workflow_catalog_rpc.test.mjs @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {EventEmitter} from 'node:events'; +import {createRequire, isBuiltin} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(import.meta.url); +async function bundle(mock_catalog = false) { + const compiled = await build({ + stdin: {contents: `export * from './bot/forward/stream.ts'; + export * from './bot/forward/manager.ts'; + export * from './utils/forward_protocol.ts'; + export * from './bot/workflow/catalog.ts'; + export * from './service/server/update/workflow_catalog.ts'; + export {report_logs} from './utils/console_print.ts';`, resolveDir: root, loader: 'ts'}, + bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true, + plugins: [{name: 'isolated-node', setup(build) { + build.onResolve({filter: /(?:instance\/index\.ts|^socket\.io-client$|console_print\.ts|path_utils\.ts)$/}, args => + ({path: args.path.includes('instance/') ? 'registry' : args.path === 'socket.io-client' ? 'socket' + : args.path.endsWith('path_utils.ts') ? 'path' : 'console', namespace: 'stub'})); + if (mock_catalog) build.onResolve({filter: /(?:^|\/)workflow\/catalog\.ts$/}, () => + ({path: 'catalog', namespace: 'stub'})); + build.onLoad({filter: /.*/, namespace: 'stub'}, ({path}) => ({loader: 'js', contents: { + registry: 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};', + socket: 'export const io = () => {throw Error("Unexpected real socket")};', + console: 'export const report_logs = []; export const console_print = {success: x => x, log: x => report_logs.push(x)};', + path: 'export const get_project_root_path = () => {throw Error("Unexpected filesystem access")};', + catalog: 'let reader; export const set_catalog_reader = value => {reader = value}; export const get_workflow_catalog = (...args) => reader(...args);', + }[path]})); + }}], + }); + assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server'))); + const module = {exports: {}}; + new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => { + assert.ok(isBuiltin(specifier), `Unexpected external import: ${specifier}`); + return require(specifier); + }, module, module.exports); + return module.exports; +} + +const real = await bundle(); +const controlled = await bundle(true); +const {BotStream, validate_rpc, validate_workflow_payload, error_ack} = real; +const fails = (action, code = 'INVALID_REQUEST') => assert.throws(action, error => error.code === code); +const request = (payload = {}, scope = randomUUID(), deadline_at = Date.now() + 1000) => ({ + request_id: randomUUID(), correlation_id: randomUUID(), method: 'workflow.catalog', payload, + forward_session_id: scope, deadline_at, +}); +function stream(t, api = real, context, now = () => 100) { + const value = new api.BotStream(1, {now, source: {get: () => context, observe: () => () => {}}}); + t.after(() => value.dispose()); + return value; +} +const sample = () => ({catalog_version: 1, nodes: [{name: 'sample', config: [{name: 'nested', default: {value: 1}}]}], + start_nodes: [], events: [], queries: [], common: {references: []}}); + +test('catalog RPC accepts only an exact empty payload, including direct stream validation', t => { + const value = stream(t); + assert.equal(validate_rpc(request()).method, 'workflow.catalog'); + assert.deepEqual(validate_workflow_payload('workflow.catalog', Object.create(null)), Object.create(null)); + let reads = 0; + const accessor = Object.defineProperty({}, 'run_id', {enumerable: true, get() { reads++; return randomUUID(); }}); + for (const payload of [undefined, null, [], 1, 'text', new Date(), Object.create({inherited: true}), + {run_id: randomUUID()}, {run_id: undefined}, {allow_code: true}, {extra: true}, {[Symbol('extra')]: true}, + Object.defineProperty({}, 'hidden', {value: true}), accessor]) { + const raw = {...request(), payload}; + fails(() => validate_rpc(raw)); + fails(() => validate_workflow_payload('workflow.catalog', payload)); + fails(() => value.workflow_catalog(payload, 200)); + } + assert.equal(reads, 0); + fails(() => validate_workflow_payload('workflow.unknown', {})); + assert.deepEqual(validate_workflow_payload('workflow.snapshot', {}), {}); + const run_id = randomUUID(); + assert.deepEqual(validate_workflow_payload('workflow.snapshot', {run_id}), {run_id}); + fails(() => validate_workflow_payload('workflow.snapshot', {run_id, extra: true})); +}); + +test('real catalog bundles independently and works offline or without any context, returning isolated clones', t => { + const absent = stream(t); + const offline = stream(t, real, Object.assign(new EventEmitter(), {bot_status: 'offline', bot_instance: null})); + const expected = real.get_workflow_catalog(); + const result = absent.workflow_catalog({}, 200); + assert.equal(result.catalog_version, 1); + assert.ok(result.nodes.length > 0); + assert.deepEqual(result.nodes.find(node => node.name === 'code').availability, + {available: false, reason: 'CODE_EXECUTION_DISABLED'}); + assert.deepEqual(result, expected); + assert.deepEqual(offline.workflow_catalog({}, 200), expected); + assert.notEqual(result, expected); + result.nodes[0].name = 'mutated'; + result.common.references.length = 0; + assert.deepEqual(absent.workflow_catalog({}, 200), expected); +}); + +test('catalog reads pass no trusted code option and clone nested catalog values', t => { + const original = sample(); + controlled.set_catalog_reader((...args) => { assert.deepEqual(args, []); return original; }); + const value = stream(t, controlled); + const result = value.workflow_catalog({}, 200); + assert.deepEqual(result, original); + result.nodes[0].config[0].default.value = 2; + assert.equal(original.nodes[0].config[0].default.value, 1); +}); + +test('catalog deadlines, transport and disposal are checked before and after the read', t => { + let now = 100; + let reads = 0; + controlled.set_catalog_reader(() => { reads++; return sample(); }); + const value = stream(t, controlled, undefined, () => now); + for (const deadline of [100, 99, NaN, Infinity, 1.5, undefined]) + fails(() => value.workflow_catalog({}, deadline), 'TIMEOUT'); + const stale = () => { throw Object.assign(Error('SECRET transport'), {code: 'SESSION_STALE'}); }; + fails(() => value.workflow_catalog({}, 200, stale), 'SESSION_STALE'); + assert.equal(reads, 0); + let checks = 0; + controlled.set_catalog_reader(() => { assert.equal(checks, 1); now = 200; return sample(); }); + fails(() => value.workflow_catalog({}, 200, () => checks++), 'TIMEOUT'); + assert.equal(checks, 2); + now = 100; + controlled.set_catalog_reader(() => sample()); + checks = 0; + fails(() => value.workflow_catalog({}, 200, () => { if (++checks === 2) stale(); }), 'SESSION_STALE'); + controlled.set_catalog_reader(() => { value.dispose(); return sample(); }); + fails(() => value.workflow_catalog({}, 200), 'CANCELLED'); + fails(() => value.workflow_catalog({}, 200), 'CANCELLED'); +}); + +async function relay(t, value) { + const command = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'session', node_id: 1, bot_id: 1, + url: 'http://127.0.0.1:1234', path: '/socket.io', namespace: '/', token: 'opaque', + expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000}; + const socket = new EventEmitter(); + socket.auth = {}; + socket.io = {engine: {writeBuffer: []}}; + socket.volatile = {emit() {}}; + socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); }; + socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); }; + socket.timeout = () => ({emit(event, _payload, ack) { + const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command; + const data = event === 'forward_node_ready' ? {node_ready: true} + : {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at, role: 'node', node_ready: false}; + queueMicrotask(() => ack(null, {status: 'success', data})); + }}); + const manager = new controlled.NodeForwardManager(1, 'session', {socket_factory: () => socket, stream_factory: () => value}); + t.after(() => manager.stop()); + assert.equal((await manager.ensure(command)).status, 'success'); + const handler = socket.listeners('relay_request')[0]; + return {socket, manager, rpc: (payload = {}, deadline = Date.now() + 1000) => new Promise(resolve => + handler(request(payload, command.forward_session_id, deadline), resolve))}; +} + +test('manager dispatches context-free catalog RPC and sanitizes stale transport and read failures', async t => { + const catalog = sample(); + let reads = 0; + controlled.set_catalog_reader(() => { reads++; return catalog; }); + const value = stream(t, controlled, undefined, Date.now); + const channel = await relay(t, value); + assert.deepEqual(await channel.rpc(), {status: 'success', data: catalog}); + assert.deepEqual(await channel.rpc({run_id: randomUUID()}), error_ack('INVALID_REQUEST')); + assert.deepEqual(await channel.rpc({}, Date.now() - 1), error_ack('TIMEOUT')); + assert.equal(reads, 1); + controlled.set_catalog_reader(() => { throw Error('SECRET catalog'); }); + assert.deepEqual(await channel.rpc(), error_ack('INTERNAL_ERROR')); + controlled.set_catalog_reader(() => { channel.socket.disconnect(); return catalog; }); + assert.deepEqual(await channel.rpc(), error_ack('SESSION_STALE')); + assert.deepEqual(await channel.rpc(), error_ack('SESSION_STALE')); +}); + +function reporter(t, response = {status: 'success', data: 0}) { + const reports = []; + const logs = controlled.report_logs; + logs.length = 0; + t.after(() => { logs.length = 0; }); + const catalog = sample(); + controlled.set_catalog_reader((...args) => { assert.deepEqual(args, []); return catalog; }); + const socket = {connected: true, timeout(ms) { assert.equal(ms, 5000); return this; }, + async emitWithAck(...args) { reports.push(args); return response; }}; + const state = {current: true}; + return {socket, state, catalog, reports, logs, + run: () => controlled.update_workflow_catalog(socket, 7, () => state.current)}; +} + +test('catalog reporter emits node metadata, accepts success ACK and supports the default session guard', async t => { + const app = reporter(t); + await app.run(); + assert.deepEqual(app.reports, [['update_workflow_catalog', {id: 7, workflow_catalog: app.catalog}]]); + assert.equal(app.logs.length, 1); + assert.equal(app.logs[0], '工作流目录更新成功'); + app.socket.connected = false; + await controlled.update_workflow_catalog(app.socket, 7); + assert.equal(app.reports.length, 1); + app.socket.connected = true; + await controlled.update_workflow_catalog(app.socket, 7); + assert.equal(app.reports.length, 2); +}); + +test('catalog reporter safely rejects malformed/error ACKs, read errors and transport failures', async t => { + for (const response of [null, {}, {status: 'unknown'}, {status: 'error', data: 'SECRET rejection'}]) { + const app = reporter(t, response); + await assert.rejects(app.run(), error => error.message === 'Workflow catalog report failed' && error.cause === undefined); + assert.equal(app.logs.length, 0); + } + const app = reporter(t); + app.socket.emitWithAck = async () => undefined; + await assert.rejects(app.run(), /^Error: Workflow catalog report failed$/); + app.socket.emitWithAck = async () => { throw Error('SECRET timeout'); }; + await assert.rejects(app.run(), /^Error: Workflow catalog report failed$/); + controlled.set_catalog_reader(() => { throw Error('SECRET catalog path'); }); + await assert.rejects(app.run(), /^Error: Workflow catalog report failed$/); +}); + +test('catalog reporter skips stale sessions before emission and ignores stale ACKs and rejections', async t => { + for (const change of ['disconnect', 'session']) { + const app = reporter(t); + const stale = () => { if (change === 'disconnect') app.socket.connected = false; else app.state.current = false; }; + stale(); + await app.run(); + assert.equal(app.reports.length, 0); + app.socket.connected = app.state.current = true; + controlled.set_catalog_reader(() => { stale(); return app.catalog; }); + await app.run(); + assert.equal(app.reports.length, 0); + for (const reject of [false, true]) { + app.socket.connected = app.state.current = true; + controlled.set_catalog_reader(() => app.catalog); + let finish; + app.socket.emitWithAck = () => new Promise((resolve, fail) => { finish = reject ? fail : resolve; }); + const task = app.run(); + stale(); + finish(reject ? Error('SECRET late ACK') : {status: 'success'}); + await task; + assert.equal(app.logs.length, 0); + } + } +}); diff --git a/tests/workflow_conditions.test.mjs b/tests/workflow_conditions.test.mjs new file mode 100644 index 0000000..8505a00 --- /dev/null +++ b/tests/workflow_conditions.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url); +const {execute} = await tsImport('../bot/workflow/node/condition_node.ts', import.meta.url); +const {create_event_handler} = await tsImport('../bot/workflow/start_node/event_node.ts', import.meta.url); + +const literal = value => ({kind: 'literal', value}); +const ref = (...path) => ({kind: 'ref', root: 'trigger', path}); +const eq = (left, value) => ({op: 'eq', left, right: literal(value)}); +const leaf = name => eq(ref(name), true); +const group = (op, ...items) => ({op, items}); +const state = trigger => ({trigger, vars: {}, outputs: {}, last_result: null, inputs: {}, retry_counts: {}}); +const truth_table = Array.from({length: 8}, (_, bits) => ({ + A: Boolean(bits & 4), B: Boolean(bits & 2), C: Boolean(bits & 1), +})); + +function definition(condition, start = {type: 'common', auto_start: 'never'}) { + return { + schema_version: 1, id: 'conditions', name: 'Conditions', enabled: true, start, + entry: 'check', nodes: [{id: 'check', type: 'condition', config: {condition}}], edges: [], + initial_vars: {}, policy: {on_busy: 'skip', max_queue: 0, max_steps: 10, timeout_ms: 5000}, + }; +} + +async function check_result(node, trigger, expected) { + const result = await execute(node, state(trigger), new AbortController().signal); + assert.deepEqual(result, {port: expected ? 'true' : 'false', result: {status: 'success', output: expected}}); +} + +test('compiled condition node requires all three AND leaves', async t => { + const graph = compile_workflow(definition(group('and', leaf('A'), leaf('B'), leaf('C')))); + for (const trigger of truth_table) await t.test(JSON.stringify(trigger), async () => { + await check_result(graph.nodes.get('check'), trigger, trigger.A && trigger.B && trigger.C); + }); +}); + +test('compiled condition node OR matches the middle leaf and rejects no match', async t => { + const graph = compile_workflow(definition(group('or', leaf('A'), leaf('B'), leaf('C')))); + for (const trigger of [{A: false, B: true, C: false}, {A: false, B: false, C: false}]) { + await t.test(JSON.stringify(trigger), async () => { + await check_result(graph.nodes.get('check'), trigger, trigger.B); + }); + } +}); + +test('compiled condition node preserves mixed nested (A AND B) OR C semantics', async t => { + const condition = group('or', group('and', leaf('A'), leaf('B')), leaf('C')); + const graph = compile_workflow(definition(condition)); + for (const trigger of truth_table) await t.test(JSON.stringify(trigger), async () => { + await check_result(graph.nodes.get('check'), trigger, (trigger.A && trigger.B) || trigger.C); + }); +}); + +test('compiled condition node does not resolve irrelevant missing references', async t => { + const missing = eq(ref('missing', 'value'), true); + for (const [label, condition, expected] of [ + ['AND stops at false', group('and', eq(literal(false), true), missing, missing), false], + ['OR stops at true', group('or', eq(literal(true), true), missing, missing), true], + ['nested OR skips missing AND leaf', group('or', group('and', leaf('A'), missing), leaf('C')), true], + ['NOT preserves short circuit', {op: 'not', item: group('and', leaf('A'), missing)}, true], + ]) await t.test(label, async () => { + const graph = compile_workflow(definition(condition)); + await check_result(graph.nodes.get('check'), {A: false, C: true}, expected); + }); + for (const op of ['and', 'or']) await t.test(`${op} resolves a relevant missing leaf`, async () => { + const condition = group(op, eq(literal(op === 'and'), true), missing); + const graph = compile_workflow(definition(condition)); + await assert.rejects(() => execute(graph.nodes.get('check'), state({}), new AbortController().signal), + {code: 'VALUE_NOT_FOUND'}); + }); +}); + +for (const event of ['message', 'chat']) { + const args = (username, message) => event === 'message' + ? [{json: {translate: 'chat.type.text', with: [{text: username}, {text: message}]}}, 'chat', 'sender-id', true] + : [username, message]; + const event_definition = condition => compile_workflow(definition(eq(literal(true), true), + {type: 'event', event, condition})).definition; + + test(`compiled ${event} event requires all three normalized field checks`, () => { + const condition = group('and', eq(ref('username'), 'Alice'), + {op: 'contains', left: ref('message'), right: literal('ready')}, eq(ref('event'), event)); + const fired = []; + const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger)); + handler(...args('Bob', 'ready')); + handler(...args('Alice', 'waiting')); + assert.deepEqual(fired, []); + handler(...args('Alice', 'ready now')); + assert.equal(fired.length, 1); + assert.equal(fired[0].event, event); + assert.equal(fired[0].username, 'Alice'); + assert.equal(fired[0].message, 'ready now'); + if (event === 'message') { + assert.equal(fired[0].plainText, ' ready now'); + assert.equal(fired[0].position, 'chat'); + assert.equal(fired[0].sender, 'sender-id'); + assert.equal(fired[0].verified, true); + } else assert.deepEqual(fired[0].args, ['Alice', 'ready now']); + const wrong_event = group('and', ...condition.items.slice(0, 2), eq(ref('event'), 'other')); + create_event_handler(event_definition(wrong_event), trigger => fired.push(trigger))(...args('Alice', 'ready now')); + assert.equal(fired.length, 1, 'the third check must also prevent firing'); + }); + + test(`compiled ${event} event OR matches the middle check and rejects no match`, () => { + const condition = group('or', eq(ref('username'), 'Alice'), eq(ref('message'), 'ready'), + eq(ref('event'), 'other')); + const fired = []; + const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger)); + handler(...args('Bob', 'waiting')); + assert.deepEqual(fired, []); + handler(...args('Bob', 'ready')); + assert.equal(fired.length, 1); + assert.equal(fired[0].username, 'Bob'); + assert.equal(fired[0].message, 'ready'); + }); + + test(`compiled ${event} event preserves mixed nested conditions`, () => { + const condition = group('or', group('and', eq(ref('username'), 'Alice'), eq(ref('message'), 'ready')), + eq(ref('message'), 'override')); + const fired = []; + const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger)); + handler(...args('Alice', 'waiting')); + handler(...args('Bob', 'ready')); + assert.deepEqual(fired, []); + handler(...args('Alice', 'ready')); + handler(...args('Bob', 'override')); + assert.deepEqual(fired.map(({username, message}) => [username, message]), [['Alice', 'ready'], ['Bob', 'override']]); + }); + + test(`compiled ${event} event short circuits missing references and skips relevant failures`, () => { + const missing = eq(ref('missing', 'value'), true); + const fired = []; + const handler = create_event_handler(event_definition(group('or', + group('and', eq(ref('username'), 'Alice'), missing), eq(ref('message'), 'override'), missing)), + trigger => fired.push(trigger)); + handler(...args('Bob', 'override')); + assert.equal(fired.length, 1, 'irrelevant missing refs must not suppress a matching event'); + assert.doesNotThrow(() => handler(...args('Alice', 'override'))); + assert.doesNotThrow(() => handler(...args('Bob', 'waiting'))); + assert.equal(fired.length, 1, 'relevant missing refs must skip the occurrence'); + }); +} diff --git a/tests/workflow_functions.test.mjs b/tests/workflow_functions.test.mjs new file mode 100644 index 0000000..f8103f9 --- /dev/null +++ b/tests/workflow_functions.test.mjs @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; +import {EventEmitter, getEventListeners} from 'node:events'; +import test from 'node:test'; +import {register} from 'tsx/esm/api'; + +const loader = register({namespace: 'workflow-functions-test'}); +const tsImport = loader.import; +test.after(() => loader.unregister()); +const {create_function_manager, get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url); +const {WorkflowControlFunction} = await tsImport('../bot/function/workflow/shared.ts', import.meta.url); +const names = ['run_once', 'stop', 'trigger_start', 'trigger_stop', 'stop_run', 'reload']; +const operations = Object.fromEntries(await Promise.all(names.map(async name => { + const {init} = await tsImport(`../bot/function/workflow/${name}.ts`, import.meta.url); + return [name, () => new init()]; +}))); +const turn = () => new Promise(resolve => setImmediate(resolve)); +const deferred = () => { + let resolve; + const promise = new Promise(yes => { resolve = yes; }); + return {promise, resolve}; +}; + +async function fixture(t, control) { + const bot = new EventEmitter(); + bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null}; + const calls = []; + const workflow = {control(request) { + calls.push(request); + return control?.(request) ?? {...request, workflow_id: request.workflow_id ?? null, state: 'accepted'}; + }}; + const context = {bot_instance: bot, bot_status: 'online', workflow_manager: workflow}; + const manager = await create_function_manager(context); + t.after(() => manager.dispose()); + return {bot, manager, context, workflow, calls}; +} + +test('discovery publishes six Chinese workflow functions without exposing execution metadata', async () => { + const category = (await get_function_list()).find(item => item.path === 'workflow'); + assert.equal(category.name, 'workflow'); + assert.equal(category.i18n[0].lang, 'zh-CN'); + assert.ok(category.i18n[0].name && category.i18n[0].description); + assert.deepEqual(category.function_list.map(fn => fn.name).sort(), [...names].sort()); + for (const fn of category.function_list) { + assert.equal(fn.i18n.lang, 'zh-CN'); + assert.ok(fn.i18n.name && fn.i18n.description); + assert.deepEqual(fn.args.map(arg => [arg.name, arg.type]), [[fn.name === 'stop_run' ? 'run_id' : 'workflow_id', 'string']]); + assert.equal(fn.args[0].i18n.lang, 'zh-CN'); + assert.ok(fn.args[0].i18n.name && fn.args[0].i18n.description); + assert.equal(Object.hasOwn(fn, 'execution'), false); + assert.equal(Object.hasOwn(fn.args[0], 'default'), false); + assert.equal(operations[fn.name]().execution, 'workflow_control'); + } +}); + +test('each invocation submits one structured control request with a fresh UUID and no input', async t => { + const {manager, calls} = await fixture(t); + let checks = 0; + for (const name of [...names, ...names]) { + const id = name === 'stop_run' ? 'a347a828-fcdd-4ff5-ae88-c20ea39ca749' : 'workflow_1-x'; + assert.equal(await manager.run('workflow', name, [id], () => checks++), true); + const request = calls.at(-1); + const key = name === 'stop_run' ? 'run_id' : 'workflow_id'; + assert.deepEqual(Object.keys(request).sort(), ['action', 'command_id', key].sort()); + assert.equal(request.action, name); + assert.equal(request[key], id); + assert.match(request.command_id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + } + assert.equal(new Set(calls.map(request => request.command_id)).size, 12); + assert.equal(checks, 12); +}); + +test('long ordinary execution and a full ordinary queue cannot block offline workflow control', async t => { + const {bot, manager, context, calls} = await fixture(t); + const click = deferred(); + t.after(() => click.resolve()); + const clicks = []; + bot.clickWindow = slot => { clicks.push(slot); return click.promise; }; + let finished = false; + const running = manager.run('inventory', 'slot_to_slot', [0, 1]).then(result => { finished = true; return result; }); + await turn(); + assert.deepEqual(clicks, [0]); + let queuedChecks = 0; + const queued = Array.from({length: 64}, () => manager.run('inventory', 'slot_to_slot', [0, 1], () => queuedChecks++)); + await assert.rejects(manager.run('inventory', 'slot_to_slot', [0, 1]), /队列已满/); + context.bot_status = 'disconnecting'; + for (const name of ['stop', 'stop_run', 'reload']) { + const result = manager.run('workflow', name, ['target']); + assert.equal(calls.at(-1).action, name, 'control dispatches synchronously'); + assert.equal(await Promise.race([result, turn().then(() => 'blocked')]), true); + } + assert.equal(finished, false); + assert.equal(queuedChecks, 0); + await assert.rejects(manager.run('bot', 'jump', []), /不在线/); + context.bot_status = 'online'; + click.resolve(); + assert.equal(await running, true); + assert.deepEqual(await Promise.all(queued), Array(64).fill(true)); + assert.equal(queuedChecks, 64); +}); + +test('self-stop acknowledges before its own function node can finish and cancellation drains independently', async t => { + const controller = new AbortController(); + const ownNode = deferred(); + let stopFinished = false; + const {manager, calls} = await fixture(t, request => { + assert.equal(request.run_id, 'current_run'); + // The stop task waits for this very function node, as the real runtime does. + void ownNode.promise.then(() => { controller.abort(); stopFinished = true; }); + return {...request, workflow_id: 'self', state: 'accepted'}; + }); + const result = manager.run('workflow', 'stop_run', ['current_run'], undefined, {signal: controller.signal}); + assert.equal(await Promise.race([result, turn().then(() => 'blocked')]), true); + assert.equal(calls.length, 1); + assert.equal(stopFinished, false); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + ownNode.resolve(); + await turn(); + assert.equal(stopFinished, true); +}); + +test('a forged workflow_control flag on a non-workflow function cannot bypass the queue or online check', async t => { + // Simulate an ordinary loaded class declaring the same flag, without changing source files. + Object.defineProperty(Object.prototype, 'execution', {value: 'workflow_control', configurable: true}); + try { + const {bot, manager, context, calls} = await fixture(t); + const click = deferred(); + t.after(() => click.resolve()); + bot.clickWindow = () => click.promise; + const running = manager.run('inventory', 'slot_to_slot', [0, 1]); + await turn(); + let before = 0; + const queued = manager.run('inventory', 'slot_to_slot', [0, 1], () => before++); + await turn(); + assert.equal(before, 0); + assert.deepEqual(calls, []); + context.bot_status = 'offline'; + await assert.rejects(manager.run('bot', 'jump', []), /不在线/); + context.bot_status = 'online'; + click.resolve(); + assert.equal(await running, true); + assert.equal(await queued, true); + assert.equal(before, 1); + } finally { delete Object.prototype.execution; } +}); + +test('missing manager, rejected commands and control exceptions return false', async t => { + const {manager, context, workflow, calls} = await fixture(t); + for (const missing of [null, undefined]) { + context.workflow_manager = missing; + for (const name of names) assert.equal(await manager.run('workflow', name, ['target']), false); + } + context.workflow_manager = workflow; + workflow.control = () => { throw Error('not ready'); }; + assert.equal(await manager.run('workflow', 'stop', ['target']), false); + workflow.control = () => ({state: 'failed', error: {code: 'NOT_FOUND', message: 'missing'}}); + assert.equal(await manager.run('workflow', 'stop', ['target']), false); + workflow.control = () => ({state: 'completed'}); + assert.equal(await manager.run('workflow', 'stop', ['target']), true); + assert.deepEqual(calls, []); + for (const name of names) assert.equal(await operations[name]().run(null, ['target']), false); +}); + +test('IDs reject malformed values and injection strings without calling control', async t => { + const {manager, calls} = await fixture(t); + for (const name of names) { + const fn = operations[name](); + const execution = {workflow: {control() { assert.fail('invalid input reached control'); }}}; + for (const args of [null, [], [1], [{}], ['target', {}], ['target', 'extra']]) + assert.equal(await fn.run(null, args, execution), false); + for (const id of ['', ' ', '../target', 'a/b', 'a.b', 'a b', 'x'.repeat(101), '__proto__', 'prototype', + 'constructor', 'target\n', 'target\0', '${process.exit()}', '";process.exit();//']) { + assert.equal(await fn.run(null, [id], execution), false); + assert.equal(await manager.run('workflow', name, [id]), false); + } + for (const args of [[], [1], ['target', {}]]) + await assert.rejects(manager.run('workflow', name, args), /参数/); + } + assert.deepEqual(calls, []); +}); + +test('permission callback, signal and manager or Bot changes prevent stale control dispatch', async t => { + for (const change of ['deny', 'abort', 'dispose', 'bot', 'manager']) { + await t.test(change, async t => { + const {manager, context, calls} = await fixture(t); + const controller = new AbortController(); + const denied = Error('permission denied'); + await assert.rejects(manager.run('workflow', 'stop', ['target'], () => { + if (change === 'deny') throw denied; + if (change === 'abort') controller.abort(denied); + if (change === 'dispose') manager.dispose(); + if (change === 'bot') context.bot_instance = {}; + if (change === 'manager') context.workflow_manager = {control() { assert.fail('replacement used'); }}; + }, {signal: controller.signal}), change === 'deny' || change === 'abort' ? error => error === denied : undefined); + assert.deepEqual(calls, []); + }); + } + const {manager, calls} = await fixture(t); + const controller = new AbortController(); + controller.abort(); + await assert.rejects(manager.run('workflow', 'stop', ['target'], () => assert.fail('pre-aborted callback'), + {signal: controller.signal}), {name: 'AbortError'}); + assert.deepEqual(calls, []); +}); + +test('direct control checks cancellation, disposal and identity again after execution', async t => { + for (const change of ['abort', 'dispose', 'bot', 'manager']) { + await t.test(change, async t => { + const {manager, context, workflow, calls} = await fixture(t); + const controller = new AbortController(); + workflow.control = request => { + calls.push(request); + if (change === 'abort') controller.abort(); + if (change === 'dispose') manager.dispose(); + if (change === 'bot') context.bot_instance = null; + if (change === 'manager') context.workflow_manager = null; + return {...request, state: 'accepted'}; + }; + await assert.rejects(manager.run('workflow', 'stop', ['target'], undefined, {signal: controller.signal})); + assert.equal(calls.length, 1); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + }); + } +}); + +test('execution context exposes only a bound control capability and rejects later manager replacement', async t => { + const original = WorkflowControlFunction.prototype.run; + let execution; + t.mock.method(WorkflowControlFunction.prototype, 'run', function(bot, args, context) { + execution = context; + return original.call(this, bot, args, context); + }); + const {manager, context, workflow, calls} = await fixture(t); + workflow.control = function(request) { + assert.equal(this, workflow); + calls.push(request); + return {...request, state: 'accepted'}; + }; + assert.equal(await manager.run('workflow', 'reload', ['target']), true); + assert.deepEqual(Object.keys(execution), ['workflow']); + assert.deepEqual(Object.keys(execution.workflow), ['control']); + assert.notEqual(execution.workflow, workflow); + context.workflow_manager = null; + assert.throws(() => execution.workflow.control({command_id: 'stale', action: 'stop', workflow_id: 'target'}), /已变化/); + assert.equal(calls.length, 1); +}); diff --git a/tests/workflow_hot.test.mjs b/tests/workflow_hot.test.mjs new file mode 100644 index 0000000..45f356c --- /dev/null +++ b/tests/workflow_hot.test.mjs @@ -0,0 +1,599 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {create_workflow_manager} = await tsImport('../bot/workflow/runtime/states_manager.ts', import.meta.url); +const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url); +const {validate_workflow} = await tsImport('../bot/workflow/runtime/validate.ts', import.meta.url); +const turn = () => new Promise(resolve => setImmediate(resolve)); +async function until(check) { + for (let i = 0; i < 100; i++) { if (check()) return; await turn(); } + assert.fail('Expected runtime state'); +} +function context(ready = true) { + const events = new EventEmitter(); + const bot = new EventEmitter(); + if (ready) bot.entity = {id: 1}; + return {events, bot_instance: bot, bot_status: 'online', info_manager: null, + function_manager: {run: async () => true}, logger: null, log() {}, + on: events.on.bind(events), off: events.off.bind(events), emit: events.emit.bind(events)}; +} +function definition(id = 'one', start = {type: 'common', auto_start: 'never'}) { + return {schema_version: 1, id, name: id, enabled: true, start, entry: 'entry', + nodes: [{id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1}}], edges: [], + policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}}; +} +const state = (manager, id = 'one') => manager.get_runtime_snapshot().workflows.find(item => item.workflow_id === id); +let sequence = 0; +async function command(manager, action, extra = {}) { + const accepted = manager.control({command_id: `cmd_${++sequence}`, action, workflow_id: 'one', ...extra}); + assert.equal(accepted.state, 'accepted'); + await until(() => manager.get_command(accepted.command_id)?.state !== 'accepted'); + return manager.get_command(accepted.command_id); +} + +test('apply precompiles all graphs and revisions are immutable, ordered and idempotent', async () => { + const ctx = context(); + const original = definition('one', {type: 'event', event: 'hello'}); + const manager = create_workflow_manager(ctx, [original], {source_config_revision: 4}); + try { + const run = manager.start('one'); + manager.start('one'); + const before = state(manager); + const invalid = definition('bad'); invalid.entry = 'missing'; + await assert.rejects(manager.apply([{...original, name: 'new'}, invalid], 5)); + assert.deepEqual(state(manager), before); + assert.equal(ctx.events.listenerCount('hello'), 1); + await manager.apply([structuredClone(original)], 4); + assert.deepEqual(state(manager), before); + await assert.rejects(manager.apply([original], 3), {code: 'STALE_SOURCE_REVISION'}); + await assert.rejects(manager.apply([{...original, name: 'new'}], 4), {code: 'SOURCE_REVISION_CONFLICT'}); + assert.equal(manager.get_snapshot(run.run_id).ended_at, null); + await manager.apply([original], 5); + assert.equal(manager.get_runtime_snapshot().source_config_revision, 5); + assert.deepEqual(state(manager), before); + } finally { await manager.dispose(); } +}); + +test('unversioned baseline zero is optional and explicit zero conflicts with changed content', async () => { + const original = definition(); + const manager = create_workflow_manager(context(), [original]); + try { + await manager.apply([original], 0); + await assert.rejects(manager.apply([], 0), {code: 'SOURCE_REVISION_CONFLICT'}); + await manager.apply([]); + await manager.apply([original]); + assert.equal(state(manager).generation, 2); + for (const revision of [-1, 1.5, NaN, Infinity, '1']) { + await assert.rejects(manager.apply([original], revision), {code: 'INVALID_SOURCE_REVISION'}); + } + } finally { await manager.dispose(); } +}); + +test('changed and deleted workflows detach triggers, discard queues and await cancellation drain', async () => { + const ctx = context(); + let release; + const drain = new Promise(resolve => { release = resolve; }); + let signal; + ctx.function_manager.run = async (_path, _name, _args, check, options) => { + signal = options.signal; + await drain; + check(); return true; + }; + const original = definition('one', {type: 'event', event: 'hello'}); + original.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'chat', args: []}}]; + const other = definition('other'); + const manager = create_workflow_manager(ctx, [original, other]); + try { + const run = manager.start('one'); + manager.start('one'); + manager.start('other'); + await until(() => signal); + let applied = false; + const applying = manager.apply([{...original, name: 'new'}, other], 1).then(() => { applied = true; }); + await until(() => signal.aborted); + assert.equal(applied, false); + assert.equal(state(manager).trigger_state, 'reloading'); + assert.equal(state(manager).queued_count, 0); + assert.equal(ctx.events.listenerCount('hello'), 0); + assert.throws(() => manager.start('one'), {code: 'WORKFLOW_RELOADING'}); + release(); + await applying; + assert.equal(state(manager).generation, 2); + assert.equal(state(manager, 'other').generation, 1); + assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'updated'); + assert.equal(ctx.events.listenerCount('hello'), 1); + await manager.apply([other], 2); + assert.equal(state(manager), undefined); + assert.equal(ctx.events.listenerCount('hello'), 0); + await manager.apply([original, other], 3); + assert.equal(state(manager).generation, 3); + assert.equal(manager.get_snapshots().filter(item => item.workflow_id === 'one').length, 1); + } finally { release(); await manager.dispose(); } +}); + +for (const action of ['apply', 'reload']) { + for (const type of ['event', 'timer']) { + test(`${action} keeps the ${type} workflow blocked across reconnect until cancellation drains`, async t => { + t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000}); + const ctx = context(); + let release; + const drain = new Promise(resolve => { release = resolve; }); + const calls = []; + ctx.function_manager.run = async (_path, name, _args, check, {signal}) => { + calls.push({name, signal}); + if (calls.length === 1) await drain; + check(); return true; + }; + const start = type === 'event' ? {type, event: 'hello'} : {type, interval_ms: 100}; + const original = definition('one', start); + original.policy.max_concurrent_runs = 2; + original.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'old', args: []}}]; + const replacement = structuredClone(original); + replacement.nodes[0].config.name = 'new'; + const manager = create_workflow_manager(ctx, [original], {max_concurrent_runs: 2}); + try { + const old = manager.start('one'); + await until(() => calls.length === 1); + let finished = false; + const transition = action === 'apply' ? manager.apply([replacement], 1) + : command(manager, 'reload', {expected_generation: 1}); + const done = transition.then(result => { finished = true; return result; }); + await until(() => calls[0].signal.aborted); + ctx.emit('end'); + ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}}); + ctx.emit('spawn'); + assert.equal(finished, false); + assert.equal(state(manager).generation, 1); + assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'}); + assert.equal(ctx.events.listenerCount('hello'), 0); + assert.equal(state(manager).next_fire_at, null); + ctx.emit('hello'); + t.mock.timers.tick(300); + await turn(); + assert.equal(calls.length, 1); + assert.equal(manager.get_snapshots().length, 1); + assert.equal(state(manager).queued_count, 0); + release(); + const result = await done; + if (action === 'reload') assert.equal(result.state, 'completed'); + assert.equal(state(manager).generation, 2); + assert.deepEqual(state(manager).active_run_ids, []); + assert.equal(manager.get_snapshot(old.run_id).stop_reason, action === 'apply' ? 'updated' : 'reloaded'); + if (type === 'event') { + assert.equal(ctx.events.listenerCount('hello'), 1); + ctx.emit('hello'); + } else { + assert.equal(state(manager).next_fire_at, 1400); + t.mock.timers.tick(100); + } + await until(() => calls.length === 2); + assert.equal(calls[1].name, action === 'apply' ? 'new' : 'old'); + await until(() => state(manager).active_run_ids.length === 0); + assert.equal(manager.get_snapshots().length, 2); + } finally { release(); await manager.dispose(); } + }); + } + + test(`${action} leaves the old entry fenced with an error when draining fails`, async () => { + const ctx = context(); + const original = definition('one', {type: 'event', event: 'hello'}); + const replacement = {...original, name: 'new'}; + const manager = create_workflow_manager(ctx, [original]); + const drain = manager.drain; + manager.drain = async () => { throw Object.assign(new Error('Drain failed'), {code: 'DRAIN_FAILED'}); }; + try { + if (action === 'apply') await assert.rejects(manager.apply([replacement], 1), {code: 'DRAIN_FAILED'}); + else assert.equal((await command(manager, 'reload')).error.code, 'DRAIN_FAILED'); + assert.equal(state(manager).generation, 1); + assert.equal(state(manager).trigger_state, 'error'); + assert.equal(state(manager).last_error.code, 'DRAIN_FAILED'); + assert.equal(ctx.events.listenerCount('hello'), 0); + ctx.emit('end'); + ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}}); + ctx.emit('spawn'); + assert.equal(ctx.events.listenerCount('hello'), 0); + assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'}); + manager.drain = drain; + if (action === 'apply') await manager.apply([replacement], 1); + else assert.equal((await command(manager, 'reload')).state, 'completed'); + assert.equal(state(manager).generation, 2); + assert.equal(ctx.events.listenerCount('hello'), 1); + } finally { manager.drain = drain; await manager.dispose(); } + }); +} + +test('common spawn fires once per bot connection and manual stop survives repeated spawn', async () => { + const ctx = context(false); + const config = definition('one', {type: 'common', auto_start: 'spawn'}); + const manager = create_workflow_manager(ctx, [config]); + try { + ctx.bot_instance.entity = {id: 1}; + ctx.emit('spawn'); ctx.emit('spawn'); + assert.equal(manager.get_snapshots().length, 1); + assert.equal(state(manager).accepted_count, 1); + await command(manager, 'stop'); + ctx.emit('spawn'); + await manager.apply([structuredClone(config)], 1); + assert.equal(manager.get_snapshots().length, 1); + ctx.emit('end'); ctx.emit('spawn'); + assert.equal(manager.get_snapshots().length, 1); + ctx.emit('instance_created'); + ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}}); + ctx.emit('spawn'); + assert.equal(manager.get_snapshots().length, 2); + assert.equal(state(manager).accepted_count, 1); + } finally { await manager.dispose(); } +}); + +test('event once only exhausts on acceptance, detaches before reentrant events, and can restart', async () => { + const ctx = context(); + const config = definition('one', {type: 'event', event: 'hello', mode: 'once'}); + config.policy.on_busy = 'skip'; + const manager = create_workflow_manager(ctx, [config]); + try { + const occupied = manager.start('one'); + ctx.emit('hello'); + assert.equal(state(manager).accepted_count, 0); + assert.equal(state(manager).trigger_state, 'listening'); + assert.equal(ctx.events.listenerCount('hello'), 1); + await manager.stop(occupied.run_id); + const off = manager.on_update(event => { if (event.type === 'run_updated') ctx.emit('hello'); }); + ctx.emit('hello'); + off(); + assert.equal(state(manager).accepted_count, 1); + assert.equal(state(manager).trigger_state, 'exhausted'); + assert.equal(ctx.events.listenerCount('hello'), 0); + assert.equal(manager.get_snapshots().length, 2); + assert.equal((await command(manager, 'trigger_start')).state, 'completed'); + assert.equal(state(manager).accepted_count, 0); + assert.equal(ctx.events.listenerCount('hello'), 1); + assert.equal((await command(manager, 'trigger_start')).state, 'completed'); + assert.equal(ctx.events.listenerCount('hello'), 1); + } finally { await manager.dispose(); } +}); + +test('queued event acceptance counts and trigger_stop leaves existing run and queue intact', async () => { + const ctx = context(); + const config = definition('one', {type: 'event', event: 'hello', mode: 'once'}); + const manager = create_workflow_manager(ctx, [config]); + try { + const run = manager.start('one'); + ctx.emit('hello'); + assert.equal(state(manager).accepted_count, 1); + assert.equal(state(manager).queued_count, 1); + await command(manager, 'trigger_stop'); + assert.equal(state(manager).queued_count, 1); + assert.equal(manager.get_snapshot(run.run_id).ended_at, null); + assert.equal((await command(manager, 'stop_run', {run_id: run.run_id})).state, 'completed'); + await until(() => manager.get_snapshots().length === 2); + assert.equal(state(manager).trigger_state, 'stopped'); + assert.equal(state(manager).active_run_ids.length, 1); + } finally { await manager.dispose(); } +}); + +test('once event ignores an outer emitter callback already captured before nested exhaustion', async () => { + const ctx = context(); + let nested = false; + ctx.on('hello', () => { + if (!nested) { nested = true; ctx.emit('hello'); } + }); + const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello', mode: 'once'})]); + try { + ctx.emit('hello'); + assert.equal(state(manager).accepted_count, 1); + assert.equal(state(manager).queued_count, 0); + assert.equal(manager.get_snapshots().length, 1); + } finally { await manager.dispose(); } +}); + +test('concurrent applies and controls execute in submission order with revision checks after drain', async () => { + const ctx = context(); + const original = definition(); + const manager = create_workflow_manager(ctx, [original]); + try { + const updated = {...original, name: 'version_2'}; + const applying = manager.apply([updated], 2); + const accepted = manager.control({command_id: 'after_apply', action: 'reload', workflow_id: 'one', expected_generation: 2}); + const stale = manager.apply([original], 1); + await applying; + await assert.rejects(stale, {code: 'STALE_SOURCE_REVISION'}); + await until(() => manager.get_command(accepted.command_id).state !== 'accepted'); + assert.equal(manager.get_command(accepted.command_id).state, 'completed'); + assert.equal(state(manager).generation, 3); + assert.equal(manager.get_runtime_snapshot().source_config_revision, 2); + } finally { await manager.dispose(); } +}); + +test('offline apply stores new definitions and waits for a new connection to install auto triggers', async t => { + t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000}); + const ctx = context(); + const config = definition('one', {type: 'event', event: 'old'}); + const manager = create_workflow_manager(ctx, [config]); + try { + ctx.emit('end'); + await manager.apply([{...config, start: {type: 'timer', interval_ms: 100}}], 1); + assert.equal(ctx.events.listenerCount('old'), 0); + assert.equal(state(manager).trigger_state, 'stopped'); + assert.equal(state(manager).next_fire_at, null); + t.mock.timers.tick(1000); + assert.equal(manager.get_snapshots().length, 0); + ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}}); + ctx.emit('spawn'); + assert.equal(state(manager).trigger_state, 'scheduled'); + assert.equal(state(manager).next_fire_at, 2100); + } finally { await manager.dispose(); } +}); + +test('timers count accepted runs, retry skips, preserve schedules on equal apply, and reset on manual start', async t => { + t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000}); + const ctx = context(); + const config = definition('one', {type: 'timer', interval_ms: 100, max_runs: 2}); + config.policy.on_busy = 'skip'; + const manager = create_workflow_manager(ctx, [config]); + try { + assert.equal(state(manager).next_fire_at, 1100); + t.mock.timers.tick(100); + assert.equal(state(manager).accepted_count, 1); + t.mock.timers.tick(100); + assert.equal(state(manager).accepted_count, 1); + assert.equal(state(manager).next_fire_at, 1300); + await manager.apply([structuredClone(config)], 1); + assert.equal(state(manager).next_fire_at, 1300); + const run = state(manager).active_run_ids[0]; + await manager.stop(run); + t.mock.timers.tick(100); + assert.equal(state(manager).accepted_count, 2); + assert.equal(state(manager).trigger_state, 'exhausted'); + assert.equal(state(manager).next_fire_at, null); + await command(manager, 'trigger_start'); + const scheduled = state(manager).next_fire_at; + assert.equal(state(manager).accepted_count, 0); + await command(manager, 'trigger_start'); + assert.equal(state(manager).next_fire_at, scheduled); + await command(manager, 'trigger_stop'); + await manager.apply([config], 2); + assert.equal(state(manager).trigger_state, 'stopped'); + t.mock.timers.tick(1000); + assert.equal(state(manager).accepted_count, 0); + } finally { await manager.dispose(); } +}); + +test('once timer queues one run and disconnect destroys all event listeners and timers until a new bot spawns', async t => { + t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000}); + const ctx = context(); + const manager = create_workflow_manager(ctx, [definition('one', {type: 'timer', interval_ms: 100, mode: 'once'}), + definition('event', {type: 'event', event: 'hello'})]); + try { + manager.start('one'); + t.mock.timers.tick(100); + assert.equal(state(manager).queued_count, 1); + assert.equal(state(manager).accepted_count, 1); + assert.equal(state(manager).trigger_state, 'exhausted'); + ctx.emit('end'); + assert.equal(ctx.events.listenerCount('hello'), 0); + assert.equal(state(manager).next_fire_at, null); + assert.equal(state(manager).queued_count, 0); + t.mock.timers.tick(500); + ctx.emit('spawn'); + assert.equal(ctx.events.listenerCount('hello'), 0); + ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}}); + ctx.emit('spawn'); + assert.equal(ctx.events.listenerCount('hello'), 1); + assert.equal(state(manager).accepted_count, 0); + assert.equal(state(manager).next_fire_at, 1700); + await manager.dispose(); + t.mock.timers.tick(1000); + assert.equal(ctx.events.eventNames().length, 0); + } finally { await manager.dispose(); } +}); + +test('auto_start false and unavailable bot preserve stopped trigger state', async () => { + const ctx = context(false); + const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello', auto_start: false})]); + try { + const failed = await command(manager, 'trigger_start'); + assert.equal(failed.state, 'failed'); + assert.equal(failed.error.code, 'BOT_NOT_READY'); + assert.equal(state(manager).trigger_state, 'stopped'); + assert.equal(ctx.events.listenerCount('hello'), 0); + assert.equal((await command(manager, 'run_once')).error.code, 'BOT_NOT_READY'); + ctx.bot_instance.entity = {id: 1}; ctx.emit('spawn'); + assert.equal(ctx.events.listenerCount('hello'), 0); + await command(manager, 'trigger_start'); + assert.equal(ctx.events.listenerCount('hello'), 1); + await command(manager, 'stop'); + ctx.emit('spawn'); + assert.equal(ctx.events.listenerCount('hello'), 0); + } finally { await manager.dispose(); } +}); + +test('controls return accepted before execution, deduplicate fingerprints and enforce generations', async () => { + const ctx = context(); + const manager = create_workflow_manager(ctx, [definition()]); + try { + const request = {command_id: 'once', action: 'run_once', workflow_id: 'one', input: {nested: {x: 1}}}; + const accepted = manager.control(request); + assert.equal(accepted.state, 'accepted'); + assert.equal(manager.get_snapshots().length, 0); + assert.deepEqual(manager.control(structuredClone(request)), accepted); + assert.throws(() => manager.control({...request, input: {x: 2}}), {code: 'COMMAND_ID_CONFLICT'}); + request.input.nested.x = 2; + await until(() => manager.get_command('once').state === 'completed'); + const result = manager.get_command('once'); + assert.equal(result.result.status, 'started'); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshot(result.result.run_id).ended_at !== null); + assert.equal(manager.get_snapshot(result.result.run_id).branches[0].trigger.nested.x, 1); + const stale = await command(manager, 'reload', {expected_generation: 99}); + assert.equal(stale.error.code, 'WORKFLOW_GENERATION_CONFLICT'); + assert.equal(state(manager).generation, 1); + assert.equal((await command(manager, 'trigger_start')).error.code, 'INVALID_TRIGGER_TYPE'); + assert.equal((await command(manager, 'stop_run', {run_id: 'missing'})).error.code, 'WORKFLOW_RUN_NOT_FOUND'); + } finally { await manager.dispose(); } +}); + +test('stop clears workflow queue and runs while stop_run leaves the trigger running', async () => { + const ctx = context(); + const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello'})]); + try { + const first = manager.start('one'); manager.start('one'); + await command(manager, 'stop_run', {run_id: first.run_id}); + assert.equal(state(manager).trigger_state, 'listening'); + await until(() => state(manager).active_run_ids.length === 1); + ctx.emit('hello'); + await command(manager, 'stop'); + assert.equal(state(manager).trigger_state, 'stopped'); + assert.equal(state(manager).queued_count, 0); + assert.equal(state(manager).active_run_ids.length, 0); + assert.equal(ctx.events.listenerCount('hello'), 0); + } finally { await manager.dispose(); } +}); + +test('workflow stop rejects direct starts during deferred cancellation drain', async () => { + const ctx = context(); + let release; + const drain = new Promise(resolve => { release = resolve; }); + let signal; + ctx.function_manager.run = async (_path, _name, _args, check, options) => { + signal = options.signal; await drain; check(); return true; + }; + const config = definition(); + config.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'chat', args: []}}]; + const manager = create_workflow_manager(ctx, [config]); + try { + manager.start('one'); await until(() => signal); + const accepted = manager.control({command_id: 'draining_stop', action: 'stop', workflow_id: 'one'}); + await until(() => signal.aborted); + assert.equal(manager.get_command(accepted.command_id).state, 'accepted'); + assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'}); + release(); + await until(() => manager.get_command(accepted.command_id).state === 'completed'); + assert.equal(state(manager).active_run_ids.length, 0); + assert.equal(state(manager).queued_count, 0); + } finally { release(); await manager.dispose(); } +}); + +test('reload uses the latest stored graph, cancels and drains, then resets generation and trigger', async () => { + const ctx = context(); + const original = definition('one', {type: 'event', event: 'old'}); + const manager = create_workflow_manager(ctx, [original]); + try { + await manager.apply([{...original, start: {type: 'event', event: 'new'}}], 1); + const run = manager.start('one'); manager.start('one'); + const result = await command(manager, 'reload', {expected_generation: 2}); + assert.equal(result.result.generation, 3); + assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'reloaded'); + assert.equal(state(manager).queued_count, 0); + assert.equal(state(manager).accepted_count, 0); + assert.equal(ctx.events.listenerCount('old'), 0); + assert.equal(ctx.events.listenerCount('new'), 1); + assert.equal(manager.get_runtime_snapshot().source_config_revision, 1); + await manager.apply([{...original, start: {type: 'event', event: 'new'}}], 1); + assert.equal(state(manager).generation, 3); + } finally { await manager.dispose(); } +}); + +test('a workflow can submit its own stop or reload without waiting on its cancellation drain', async () => { + for (const action of ['stop', 'reload']) { + const ctx = context(); + const config = definition(); + config.nodes = [{id: 'entry', type: 'function', config: {path: 'workflow', name: action, args: []}}]; + let manager; + ctx.function_manager.run = async () => { + assert.equal(manager.control({command_id: 'self', action, workflow_id: 'one'}).state, 'accepted'); + return true; + }; + manager = create_workflow_manager(ctx, [config]); + try { + manager.start('one'); + await until(() => manager.get_command('self')?.state === 'completed'); + assert.equal(state(manager).generation, action === 'reload' ? 2 : 1); + assert.equal(state(manager).active_run_ids.length, 0); + } finally { await manager.dispose(); } + } +}); + +test('self reload loops are limited to ten reloads within ten seconds', async t => { + t.mock.timers.enable({apis: ['Date'], now: 1000}); + const ctx = context(); + const config = definition('one', {type: 'common', auto_start: 'spawn'}); + config.nodes = [{id: 'entry', type: 'function', config: {path: 'workflow', name: 'reload', args: []}}]; + let manager; + let count = 0; + ctx.function_manager.run = async () => { + manager.control({command_id: `self_${++count}`, action: 'reload', workflow_id: 'one'}); + return true; + }; + manager = create_workflow_manager(ctx, [config]); + try { + await until(() => manager.get_command('self_11')?.state === 'failed'); + assert.equal(manager.get_command('self_11').error.code, 'WORKFLOW_RELOAD_RATE_LIMIT'); + assert.equal(state(manager).generation, 11); + assert.equal(count, 11); + t.mock.timers.tick(10_000); + assert.equal((await command(manager, 'reload')).state, 'completed'); + } finally { await manager.dispose(); } +}); + +test('pending controls are bounded at 32 and completed command history at 100', async () => { + const manager = create_workflow_manager(context(), [definition()]); + try { + for (let i = 0; i < 32; i++) manager.control({command_id: `pending_${i}`, action: 'trigger_stop', workflow_id: 'one'}); + assert.throws(() => manager.control({command_id: 'overflow', action: 'stop', workflow_id: 'one'}), + {code: 'WORKFLOW_CONTROL_BUSY'}); + await until(() => manager.get_command('pending_31')?.state === 'failed'); + for (let i = 0; i < 105; i++) await command(manager, 'trigger_stop'); + await turn(); + assert.equal(manager.get_runtime_snapshot().commands.length, 100); + assert.equal(manager.get_command('pending_0'), undefined); + } finally { await manager.dispose(); } +}); + +test('runtime updates reach context and subscribers, snapshots contain no node output and are cloned', async () => { + const ctx = context(); + const manager = create_workflow_manager(ctx, [definition()]); + let local = 0, context_events = 0; + ctx.on('workflow_update', event => { if (event.type === 'runtime_updated') context_events++; }); + const off = manager.on_update(event => { + if (event.type === 'runtime_updated') { local++; event.snapshot.workflows.length = 0; } + }); + try { + manager.start('one', {secret: 'hidden'}); manager.start('one'); + await command(manager, 'run_once', {input: {secret: 'hidden'}}); + await turn(); + const snapshot = manager.get_runtime_snapshot(); + assert.ok(local > 0 && context_events > 0); + assert.equal(snapshot.workflows.length, 1); + assert.equal(snapshot.workflows[0].active_run_ids.length, 1); + assert.equal(snapshot.workflows[0].queued_count, 2); + assert.equal(JSON.stringify(snapshot).includes('secret'), false); + assert.equal(JSON.stringify(snapshot).includes('outputs'), false); + snapshot.workflows[0].generation = 999; + assert.equal(state(manager).generation, 1); + const cmd = snapshot.commands[0]; cmd.state = 'failed'; + assert.equal(manager.get_command(cmd.command_id).state, 'completed'); + } finally { off(); await manager.dispose(); } +}); + +test('trigger fields validate strictly and metadata defaults compile correctly', () => { + for (const type of ['event', 'timer']) { + const start = type === 'event' ? {type, event: 'hello'} : {type, interval_ms: 100}; + const graph = compile_workflow(definition('one', start)); + assert.equal(graph.definition.start.auto_start, true); + assert.equal(graph.definition.start.mode, type === 'event' ? 'continuous' : 'repeat'); + if (type === 'timer') assert.equal(graph.definition.start.max_runs, null); + for (const auto_start of [null, 0, 'true']) { + assert.throws(() => compile_workflow(definition('one', {...start, auto_start}))); + assert.throws(() => validate_workflow(definition('one', {...start, auto_start}))); + } + for (const mode of [null, true, 'invalid']) assert.throws(() => compile_workflow(definition('one', {...start, mode}))); + } + for (const max_runs of [0, -1, 100001, 1.5, '2', false]) { + assert.throws(() => compile_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs}))); + assert.throws(() => validate_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs}))); + } + for (const max_runs of [null, 1, 100000]) compile_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs})); +}); diff --git a/tests/workflow_hot_rpc.test.mjs b/tests/workflow_hot_rpc.test.mjs new file mode 100644 index 0000000..0e7e88b --- /dev/null +++ b/tests/workflow_hot_rpc.test.mjs @@ -0,0 +1,367 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {EventEmitter} from 'node:events'; +import {createRequire, isBuiltin} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const compiled = await build({ + stdin: {contents: `export * from './bot/forward/stream.ts'; export * from './bot/forward/manager.ts'; + export * from './utils/forward_protocol.ts';`, resolveDir: root, loader: 'ts'}, + bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true, + plugins: [{name: 'isolated-node', setup(build) { + build.onResolve({filter: /(?:instance\/index\.ts|^socket\.io-client$|console_print\.ts|path_utils\.ts)$/}, args => + ({path: args.path.includes('instance/') ? 'registry' : args.path === 'socket.io-client' ? 'socket' + : args.path.endsWith('path_utils.ts') ? 'path' : 'console', namespace: 'stub'})); + build.onLoad({filter: /.*/, namespace: 'stub'}, ({path}) => ({loader: 'js', contents: { + registry: 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};', + socket: 'export const io = () => {throw Error("Unexpected real socket")};', + console: 'export const console_print = {success: x => x, log() {}};', + path: 'export const get_project_root_path = () => {throw Error("Unexpected filesystem access")};', + }[path]})); + }}], +}); +assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server'))); +const require = createRequire(import.meta.url); +const module = {exports: {}}; +new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => { + assert.ok(isBuiltin(specifier), `Unexpected external import: ${specifier}`); + return require(specifier); +}, module, module.exports); +const {BotStream, NodeForwardManager, validate_rpc, validate_workflow_payload, error_ack} = module.exports; +const actions = ['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload']; +const fails = (action, code = 'INVALID_REQUEST') => assert.throws(action, error => error.code === code); +const request = (method, payload, scope = randomUUID(), deadline_at = Date.now() + 2000) => ({ + request_id: randomUUID(), correlation_id: randomUUID(), method, payload, forward_session_id: scope, deadline_at, +}); +const control = (action = 'run_once', context_generation = randomUUID()) => ({ + command_id: randomUUID(), context_generation, action, + ...(action === 'stop_run' ? {run_id: randomUUID()} : {workflow_id: 'gather'}), +}); + +function fixture(t) { + let now = 100; + const calls = []; + const command = {command_id: randomUUID(), action: 'run_once', workflow_id: 'gather', state: 'accepted'}; + const runtime = {source_config_revision: 7, workflows: [{workflow_id: 'gather', generation: 3, + enabled: true, start_type: 'event', trigger_state: 'listening', accepted_count: 2, max_runs: null, + next_fire_at: null, active_run_ids: [randomUUID()], queued_count: 1, last_error: null}], commands: [command]}; + const manager = { + control(input) { calls.push(['control', input]); return command; }, + get_command(id) { calls.push(['command', id]); return id === command.command_id ? command : undefined; }, + get_runtime_snapshot() { calls.push(['runtime']); return runtime; }, + }; + const context = Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat() {}}, workflow_manager: manager}); + let current = context; + const observers = new Set(); + const source = {get: () => current, observe(_id, callback) { observers.add(callback); return () => observers.delete(callback); }}; + const stream = new BotStream(1, {source, now: () => now}); + t.after(() => stream.dispose()); + return {stream, manager, runtime, command, calls, context, + generation: stream.snapshot().context_generation, + set(context) { current = context; for (const listener of observers) listener(context); }, + time(value) { now = value; }, + }; +} + +test('hot workflow protocol accepts exact action-specific payloads and safe generations', () => { + for (const action of actions) { + const payload = control(action); + const valid = request('workflow.control', payload); + assert.equal(validate_rpc(valid), valid); + assert.equal(validate_workflow_payload('workflow.control', payload), payload); + if (action !== 'stop_run') for (const expected_generation of [1, Number.MAX_SAFE_INTEGER]) { + assert.equal(validate_rpc(request('workflow.control', {...payload, expected_generation})).payload.expected_generation, + expected_generation); + } + for (const key of ['command_id', 'context_generation', 'action', action === 'stop_run' ? 'run_id' : 'workflow_id']) { + const missing = {...payload}; delete missing[key]; + fails(() => validate_rpc(request('workflow.control', missing))); + } + fails(() => validate_rpc(request('workflow.control', {...payload, extra: true}))); + fails(() => validate_rpc(request('workflow.control', {...payload, [action === 'stop_run' ? 'workflow_id' : 'run_id']: randomUUID()}))); + if (action !== 'run_once') fails(() => validate_rpc(request('workflow.control', {...payload, input: {}}))); + if (action === 'stop_run') fails(() => validate_rpc(request('workflow.control', {...payload, expected_generation: 1}))); + } + const payload = control(); + for (const expected_generation of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1', null, undefined]) + fails(() => validate_rpc(request('workflow.control', {...payload, expected_generation}))); + for (const action of ['', 'start', 'delete', 'constructor', null, undefined, 1]) + fails(() => validate_rpc(request('workflow.control', {...payload, action}))); + for (const workflow_id of ['', '../x', 'a.b', 'a/b', 'a b', 'x'.repeat(101), '__proto__', 'constructor', 'prototype', 1]) + fails(() => validate_rpc(request('workflow.control', {...payload, workflow_id}))); + assert.equal(validate_rpc(request('workflow.control', {...payload, workflow_id: 'x'.repeat(100)})).method, 'workflow.control'); +}); + +test('runtime and command reads reject extra fields and require both UUID identities', () => { + assert.equal(validate_rpc(request('workflow.runtime', {})).method, 'workflow.runtime'); + for (const payload of [undefined, null, [], 1, {workflow_id: 'gather'}, {command_id: randomUUID()}, + {[Symbol('extra')]: true}, Object.defineProperty({}, 'hidden', {value: true})]) + fails(() => validate_rpc(request('workflow.runtime', payload))); + const payload = {command_id: randomUUID(), context_generation: randomUUID()}; + assert.equal(validate_rpc(request('workflow.command', payload)).payload, payload); + for (const key of ['command_id', 'context_generation']) { + for (const value of ['', 'not-a-uuid', 'x'.repeat(36), null, undefined, 1]) { + fails(() => validate_rpc(request('workflow.command', {...payload, [key]: value}))); + fails(() => validate_rpc(request('workflow.control', {...control(), [key]: value}))); + } + const missing = {...payload}; delete missing[key]; + fails(() => validate_rpc(request('workflow.command', missing))); + } + fails(() => validate_rpc(request('workflow.control', {...control('stop_run'), run_id: 'invalid'}))); + for (const key of ['workflow_id', 'run_id', 'action', 'input', 'expected_generation', 'extra']) + fails(() => validate_rpc(request('workflow.command', {...payload, [key]: true}))); +}); + +test('run_once input is strict plain JSON with an exact 64 KiB UTF-8 serialized boundary', () => { + const payload = control(); + const input = {text: 'x'.repeat(65_536 - Buffer.byteLength(JSON.stringify({text: ''})))}; + assert.equal(Buffer.byteLength(JSON.stringify(input)), 65_536); + assert.equal(validate_rpc(request('workflow.control', {...payload, input})).payload.input, input); + fails(() => validate_rpc(request('workflow.control', {...payload, input: {text: input.text + 'x'}}))); + const multibyte = {text: '\u00e9'.repeat(32_768)}; + assert.ok(JSON.stringify(multibyte).length < 65_536); + assert.ok(Buffer.byteLength(JSON.stringify(multibyte)) > 65_536); + fails(() => validate_rpc(request('workflow.control', {...payload, input: multibyte}))); + const cycle = {}; cycle.self = cycle; + const sparse = Array(2); + let deep = {}; for (let index = 0; index < 66; index++) deep = {child: deep}; + for (const input of [null, [], 'text', undefined, new Date(), Object.create({inherited: true}), cycle, deep, + {sparse}, {value: NaN}, {value: Infinity}, {value: 1n}, {value: undefined}, {value() {}}, + JSON.parse('{"__proto__":1}'), {nested: {constructor: 1}}, {[Symbol('key')]: 1}, + Object.defineProperty({}, 'hidden', {value: 1})]) + fails(() => validate_rpc(request('workflow.control', {...payload, input}))); + const plain = Object.assign(Object.create(null), {nested: {values: [false, 0, null]}}); + assert.equal(validate_rpc(request('workflow.control', {...payload, input: plain})).payload.input, plain); +}); + +test('hot payload descriptors are rejected without invoking enumerable or hidden accessors', async t => { + for (const [method, base, keys] of [ + ['workflow.control', control(), ['command_id', 'context_generation', 'action', 'workflow_id', 'expected_generation', 'input', 'extra']], + ['workflow.command', {command_id: randomUUID(), context_generation: randomUUID()}, ['command_id', 'context_generation', 'extra']], + ['workflow.runtime', {}, ['extra']], + ]) for (const key of keys) for (const enumerable of [true, false]) { + await t.test(`${method}.${key} enumerable=${enumerable}`, () => { + let reads = 0; + const payload = Object.defineProperty({...base}, key, {enumerable, get() { reads++; return base[key]; }}); + fails(() => validate_rpc(request(method, payload))); + assert.equal(reads, 0); + }); + } + for (const enumerable of [true, false]) { + let reads = 0; + const input = Object.defineProperty({}, 'secret', {enumerable, get() { reads++; return 1; }}); + fails(() => validate_rpc(request('workflow.control', {...control(), input}))); + assert.equal(reads, 0); + } +}); + +test('stream control is synchronous, strips context identity and clones input and accepted command', t => { + const app = fixture(t); + const payload = {...control('run_once', app.generation), expected_generation: 3, input: {nested: {value: 1}}}; + app.command.command_id = payload.command_id; + app.manager.control = input => { + app.calls.push(input); + assert.equal(Object.hasOwn(input, 'context_generation'), false); + input.input.nested.value = 2; + return app.command; + }; + const result = app.stream.workflow_control(payload, 200); + assert.equal(result instanceof Promise, false); + assert.deepEqual(result, app.command); + assert.notEqual(result, app.command); + assert.equal(payload.input.nested.value, 1); + assert.deepEqual(app.calls[0], {command_id: payload.command_id, action: 'run_once', workflow_id: 'gather', + expected_generation: 3, input: {nested: {value: 2}}}); + result.state = 'completed'; + assert.equal(app.command.state, 'accepted'); +}); + +test('stream runtime and command reads work offline, deeply clone state and return null for unknown command', t => { + const app = fixture(t); + app.context.bot_status = 'offline'; app.context.bot_instance = null; + const state = app.stream.workflow_runtime({}, 200); + const payload = {command_id: app.command.command_id, context_generation: app.generation}; + const accepted = app.stream.workflow_command(payload, 200); + assert.deepEqual(state, app.runtime); + assert.deepEqual(accepted, app.command); + state.workflows[0].active_run_ids.push(randomUUID()); state.commands[0].state = 'failed'; + accepted.state = 'completed'; + assert.equal(app.runtime.workflows[0].active_run_ids.length, 1); + assert.equal(app.command.state, 'accepted'); + app.command.state = 'completed'; app.command.result = {status: 'started', run_id: randomUUID()}; + const completed = app.stream.workflow_command(payload, 200); + assert.deepEqual(completed, app.command); + completed.result.run_id = 'mutated'; + assert.notEqual(app.command.result.run_id, 'mutated'); + app.command.state = 'failed'; delete app.command.result; + app.command.error = {code: 'GENERATION_MISMATCH', message: 'Workflow generation changed'}; + const failed = app.stream.workflow_command(payload, 200); + assert.deepEqual(failed, app.command); + failed.error.message = 'mutated'; + assert.equal(app.command.error.message, 'Workflow generation changed'); + assert.equal(app.stream.workflow_command({...payload, command_id: randomUUID()}, 200), null); +}); + +test('stale context generations reject control and command lookup before any manager dispatch', t => { + const app = fixture(t); + const original = app.generation; + assert.match(original, /^[a-f0-9-]{36}$/i); + for (const generation of [randomUUID(), original]) { + if (generation === original) { + app.set(Object.assign(new EventEmitter(), app.context)); + assert.notEqual(app.stream.snapshot().context_generation, original); + } + fails(() => app.stream.workflow_control(control('run_once', generation), 200), 'CANCELLED'); + fails(() => app.stream.workflow_command({command_id: app.command.command_id, context_generation: generation}, 200), 'CANCELLED'); + } + assert.deepEqual(app.calls, []); +}); + +test('hot stream APIs reject invalid payloads, missing managers and unavailable optional controls', async t => { + for (const name of ['runtime', 'control', 'command']) await t.test(name, t => { + const app = fixture(t); + const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation) + : {command_id: app.command.command_id, context_generation: app.generation}; + const invoke = (input = payload) => app.stream[`workflow_${name}`](input, 200); + fails(() => invoke({...payload, extra: true})); + assert.deepEqual(app.calls, []); + app.context.workflow_manager = null; + fails(invoke, 'INTERNAL_ERROR'); + app.context.workflow_manager = {}; + fails(invoke, 'INTERNAL_ERROR'); + app.set(undefined); + fails(invoke, 'NO_CONTEXT'); + app.stream.dispose(); + fails(invoke, 'CANCELLED'); + }); +}); + +test('hot stream APIs enforce deadlines, transport and identity before and after synchronous dispatch', async t => { + for (const name of ['runtime', 'control', 'command']) { + for (const change of ['deadline', 'context', 'instance', 'manager', 'disposed', 'transport']) { + await t.test(`${name}: ${change}`, t => { + const app = fixture(t); + const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation) + : {command_id: app.command.command_id, context_generation: app.generation}; + let stale = false; + const transport = () => { if (stale) throw Object.assign(Error('SECRET transport'), {code: 'SESSION_STALE'}); }; + const invoke = deadline => app.stream[`workflow_${name}`](payload, deadline, transport); + for (const deadline of [100, 99, NaN, Infinity, 1.5, undefined]) fails(() => invoke(deadline), 'TIMEOUT'); + stale = true; + fails(() => invoke(200), 'SESSION_STALE'); + assert.deepEqual(app.calls, []); + stale = false; + const key = {runtime: 'get_runtime_snapshot', control: 'control', command: 'get_command'}[name]; + let dispatched = 0; + app.manager[key] = () => { + dispatched++; + if (change === 'deadline') app.time(200); + if (change === 'context') app.set(Object.assign(new EventEmitter(), app.context)); + if (change === 'instance') app.context.bot_instance = {}; + if (change === 'manager') app.context.workflow_manager = {}; + if (change === 'disposed') app.stream.dispose(); + if (change === 'transport') stale = true; + return name === 'runtime' ? app.runtime : app.command; + }; + fails(() => invoke(200), change === 'deadline' ? 'TIMEOUT' : change === 'transport' ? 'SESSION_STALE' : 'CANCELLED'); + assert.equal(dispatched, 1); + }); + } + } +}); + +async function relay(t, stream) { + const command = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'session', node_id: 1, bot_id: 1, + url: 'http://127.0.0.1:1234', path: '/socket.io', namespace: '/', token: 'opaque', + expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000}; + const socket = new EventEmitter(); + socket.auth = {}; socket.io = {engine: {writeBuffer: []}}; socket.volatile = {emit() {}}; + socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); }; + socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); }; + socket.timeout = () => ({emit(event, _payload, ack) { + const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command; + const data = event === 'forward_node_ready' ? {node_ready: true} + : {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at, role: 'node', node_ready: false}; + queueMicrotask(() => ack(null, {status: 'success', data})); + }}); + const manager = new NodeForwardManager(1, 'session', {socket_factory: () => socket, stream_factory: () => stream}); + t.after(() => manager.stop()); + assert.equal((await manager.ensure(command)).status, 'success'); + return {socket, manager, rpc: (method, payload, deadline = Date.now() + 2000) => new Promise(resolve => + socket.emit('relay_request', request(method, payload, command.forward_session_id, deadline), resolve))}; +} + +test('real NodeForwardManager dispatches accepted controls, command polling and runtime in invocation order', async t => { + const app = fixture(t); app.time(Date.now()); + const channel = await relay(t, app.stream); + const snapshot = await channel.rpc('bot.snapshot', {}); + assert.equal(snapshot.status, 'success'); + const generation = snapshot.data.context_generation; + assert.equal(generation, app.generation); + const payload = {...control('run_once', generation), expected_generation: 3, input: {value: 1}}; + app.command.command_id = payload.command_id; + assert.deepEqual(await channel.rpc('workflow.control', payload), {status: 'success', data: app.command}); + assert.deepEqual(await channel.rpc('workflow.command', {command_id: payload.command_id, context_generation: generation}), + {status: 'success', data: app.command}); + app.command.state = 'completed'; app.command.result = {status: 'queued', request_id: randomUUID()}; + app.context.bot_status = 'offline'; app.context.bot_instance = null; + const completed = await channel.rpc('workflow.command', {command_id: payload.command_id, context_generation: generation}); + assert.deepEqual(completed, {status: 'success', data: app.command}); + completed.data.result.request_id = 'mutated'; + const runtime = await channel.rpc('workflow.runtime', {}); + assert.deepEqual(runtime, {status: 'success', data: app.runtime}); + runtime.data.workflows[0].active_run_ids.length = 0; + assert.equal(app.runtime.workflows[0].active_run_ids.length, 1); + assert.deepEqual(await channel.rpc('workflow.command', {command_id: randomUUID(), context_generation: generation}), + {status: 'success', data: null}); + assert.deepEqual(app.calls.map(([name]) => name), ['control', 'command', 'command', 'runtime', 'command']); + assert.deepEqual(app.calls[0][1], {command_id: payload.command_id, action: 'run_once', workflow_id: 'gather', + expected_generation: 3, input: {value: 1}}); +}); + +test('Node dispatch rejects malformed, stale-context and expired hot requests before manager invocation', async t => { + const app = fixture(t); app.time(Date.now()); + const channel = await relay(t, app.stream); + for (const [method, payload, expected, deadline] of [ + ['workflow.runtime', {extra: true}, 'INVALID_REQUEST'], + ['workflow.control', {...control('trigger_stop', app.generation), input: {}}, 'INVALID_REQUEST'], + ['workflow.control', control(), 'CANCELLED'], + ['workflow.command', {command_id: app.command.command_id, context_generation: randomUUID()}, 'CANCELLED'], + ['workflow.runtime', {}, 'TIMEOUT', Date.now() - 1], + ['workflow.control', control('reload', app.generation), 'TIMEOUT', Date.now() - 1], + ['workflow.command', {command_id: app.command.command_id, context_generation: app.generation}, 'TIMEOUT', Date.now() - 1], + ]) assert.deepEqual(await channel.rpc(method, payload, deadline), error_ack(expected)); + assert.deepEqual(app.calls, []); +}); + +test('Node sanitizes workflow manager failures and retains only approved control error codes', async t => { + const app = fixture(t); app.time(Date.now()); + const channel = await relay(t, app.stream); + for (const [method, key, payload, code] of [ + ['workflow.runtime', 'get_runtime_snapshot', {}, 'INTERNAL_ERROR'], + ['workflow.command', 'get_command', {command_id: app.command.command_id, context_generation: app.generation}, 'INTERNAL_ERROR'], + ['workflow.control', 'control', control('run_once', app.generation), 'INVALID_REQUEST'], + ]) { + app.manager[key] = () => { throw Error('SECRET manager /private/path password'); }; + assert.deepEqual(await channel.rpc(method, payload), error_ack(code)); + } + app.manager.control = () => { throw Object.assign(Error('SECRET rate limit'), {code: 'RATE_LIMITED'}); }; + assert.deepEqual(await channel.rpc('workflow.control', control('reload', app.generation)), error_ack('RATE_LIMITED')); + app.manager.control = () => { throw Object.assign(Error('SECRET custom error'), {code: 'PRIVATE_MANAGER_CODE'}); }; + assert.deepEqual(await channel.rpc('workflow.control', control('stop', app.generation)), error_ack('INVALID_REQUEST')); +}); + +test('Node transport guards reject hot results when the relay disconnects during manager dispatch', async t => { + for (const name of ['runtime', 'control', 'command']) await t.test(name, async t => { + const app = fixture(t); app.time(Date.now()); + const channel = await relay(t, app.stream); + const key = {runtime: 'get_runtime_snapshot', control: 'control', command: 'get_command'}[name]; + const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation) + : {command_id: app.command.command_id, context_generation: app.generation}; + app.manager[key] = () => { channel.socket.disconnect(); return name === 'runtime' ? app.runtime : app.command; }; + assert.deepEqual(await channel.rpc(`workflow.${name}`, payload), error_ack('SESSION_STALE')); + }); +}); diff --git a/tests/workflow_info.test.mjs b/tests/workflow_info.test.mjs new file mode 100644 index 0000000..8bbbf3b --- /dev/null +++ b/tests/workflow_info.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {EventEmitter} from 'node:events'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const compiled = await build({ + entryPoints: [fileURLToPath(new URL('../bot/info/bot/workflow_states.ts', import.meta.url))], + bundle: true, platform: 'node', format: 'cjs', write: false, +}); +const module = {exports: {}}; +new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => { + assert.fail(`Unexpected workflow info runtime dependency: ${specifier}`); +}, module, module.exports); +const WorkflowStates = module.exports.init; + +function fixture(t, legacy = false) { + const command = {command_id: randomUUID(), action: 'trigger_start', workflow_id: 'gather', state: 'accepted'}; + const runtime = {source_config_revision: 5, workflows: [{workflow_id: 'gather', generation: 2, enabled: true, + start_type: 'event', trigger_state: 'listening', accepted_count: 0, max_runs: null, + next_fire_at: null, active_run_ids: [], queued_count: 0, last_error: null}], commands: [command]}; + const run = {run_id: randomUUID(), workflow_id: 'gather', status: 'running', steps: 1, active_nodes: ['query'], + started_at: 1, ended_at: null, stop_reason: null, error: null, + ...(!legacy ? {trigger: {secret: 'PRIVATE_RUN_TRIGGER'}, trigger_available: true} : {}), + branches: [{trigger: {secret: 'PRIVATE_TRIGGER'}, vars: {secret: 'PRIVATE_VARS'}, + outputs: {secret: 'PRIVATE_OUTPUT'}, inputs: {secret: 'PRIVATE_INPUT'}, last_result: null, retry_counts: {}}]}; + const runs = [run]; + const listeners = new Set(); + let unsubscribes = 0; + const source = {get_snapshots: () => runs, + ...(!legacy ? {get_runtime_snapshot: () => runtime} : {}), + on_update(callback) { + listeners.add(callback); + return () => { unsubscribes++; listeners.delete(callback); }; + }, + }; + const bot = new EventEmitter(); + const info = new WorkflowStates(bot, {workflow_source: source}); + t.after(() => info.dispose()); + return {info, bot, source, runtime, command, run, runs, listeners, + unsubscribes: () => unsubscribes, + emit(event) { for (const listener of [...listeners]) listener(event); }, + }; +} + +test('workflow_states reads runtime workflows and commands and excludes run branch state from streamed info', t => { + t.mock.timers.enable({apis: ['setTimeout']}); + const app = fixture(t); + assert.equal(app.info.name, 'workflow_states'); + assert.equal(app.bot.listenerCount('end'), 0); + assert.equal(app.listeners.size, 1); + const {branches, trigger, ...summary} = app.run; + const initial = app.info.get_info(); + assert.deepEqual(initial, {available: true, ...app.runtime, runs: [summary]}); + assert.equal(initial.runs[0].trigger_available, true); + assert.equal(Object.hasOwn(initial.runs[0], 'trigger'), false); + assert.equal(Object.hasOwn(initial.runs[0], 'branches'), false); + assert.equal(JSON.stringify(initial).includes('PRIVATE_'), false); + initial.workflows[0].active_run_ids.push('mutated'); + initial.commands[0].state = 'failed'; initial.runs[0].active_nodes.push('mutated'); + assert.deepEqual(app.runtime.workflows[0].active_run_ids, []); + assert.equal(app.command.state, 'accepted'); + assert.deepEqual(app.run.active_nodes, ['query']); + const updates = []; + app.info.on_update(() => { throw Error('listener failure'); }); + app.info.on_update(value => { value.commands[0].state = 'failed'; value.runs.length = 0; }); + app.info.on_update(value => updates.push(value)); + app.run.status = 'completed'; app.run.ended_at = 2; app.run.active_nodes = []; + app.command.state = 'completed'; app.command.result = {generation: 2}; + app.emit({type: 'run_updated', snapshot: app.run}); + assert.equal(updates.length, 0); + t.mock.timers.tick(250); + assert.equal(updates.length, 1); + assert.equal(updates[0].runs[0].status, 'completed'); + assert.equal(updates[0].commands[0].state, 'completed'); + assert.equal(Object.hasOwn(updates[0].runs[0], 'branches'), false); + assert.equal(Object.hasOwn(updates[0].runs[0], 'trigger'), false); + assert.equal(updates[0].runs[0].trigger_available, true); + assert.equal(JSON.stringify(updates[0]).includes('PRIVATE_'), false); + updates[0].commands[0].result.generation = 99; + assert.equal(app.info.get_info().commands[0].result.generation, 2); +}); + +test('runtime_updated publishes trigger and command changes with no runs and ignores node-only events', t => { + t.mock.timers.enable({apis: ['setTimeout']}); + const app = fixture(t); + app.runs.length = 0; + const updates = []; + const unsubscribe = app.info.on_update(value => updates.push(value)); + assert.deepEqual(updates, []); + app.runtime.source_config_revision = 6; + app.runtime.workflows[0].trigger_state = 'stopped'; + app.command.state = 'completed'; app.command.result = {generation: 2}; + app.emit({type: 'runtime_updated', snapshot: app.runtime}); + t.mock.timers.tick(250); + assert.deepEqual(updates, [{available: true, ...app.runtime, runs: []}]); + app.emit({type: 'node_started', run_id: app.run.run_id, node_id: 'query', execution: 1}); + app.emit({type: 'node_finished', run_id: app.run.run_id, node_id: 'query', execution: 1, + result: {status: 'success', output: true}}); + assert.equal(updates.length, 1); + unsubscribe(); unsubscribe(); + app.emit({type: 'runtime_updated', snapshot: app.runtime}); + assert.equal(updates.length, 1); +}); + +test('workflow info disposes only its own source subscription once and preserves its last detached snapshot', t => { + const app = fixture(t); + const updates = []; + app.info.on_update(value => updates.push(value)); + const late = [...app.listeners][0]; + const before = app.info.get_info(); + app.info.dispose(); app.info.dispose(); + assert.equal(app.unsubscribes(), 1); + assert.equal(app.listeners.size, 0); + late({type: 'runtime_updated', snapshot: app.runtime}); + app.emit({type: 'run_updated', snapshot: app.run}); + assert.deepEqual(updates, []); + assert.deepEqual(app.info.get_info(), before); + const detached = app.info.get_info(); detached.workflows.length = 0; + assert.deepEqual(app.info.get_info(), before); + assert.throws(() => app.info.on_update(() => {}), /disposed/); +}); + +test('workflow info preserves optional legacy source fallback and distinguishes absent from empty sources', t => { + const missing = new WorkflowStates(new EventEmitter()); + t.after(() => missing.dispose()); + assert.deepEqual(missing.get_info(), {available: false, source_config_revision: 0, workflows: [], commands: [], runs: []}); + const app = fixture(t, true); + const {branches, ...summary} = app.run; + assert.deepEqual(app.info.get_info(), {available: true, source_config_revision: 0, workflows: [], commands: [], + runs: [{...summary, trigger_available: false}]}); + app.runs.length = 0; + app.emit({type: 'run_updated', snapshot: app.run}); + assert.deepEqual(app.info.get_info(), {available: true, source_config_revision: 0, workflows: [], commands: [], runs: []}); +}); + +test('live state includes only 20 completed commands while preserving accepted controls', t => { + const app = fixture(t); + const commands = Array.from({length: 100}, () => ({command_id: randomUUID(), action: 'run_once', + workflow_id: 'gather', state: 'completed'})); + const pending = [app.command, {...app.command, command_id: randomUUID()}]; + app.runtime.commands = [pending[0], ...commands, pending[1]]; + app.emit({type: 'runtime_updated', snapshot: app.runtime}); + const state = app.info.get_info(); + assert.equal(state.commands.length, 22); + assert.deepEqual(state.commands.filter(command => command.state === 'completed'), commands.slice(-20)); + assert.deepEqual(state.commands.filter(command => command.state === 'accepted'), pending); + assert.equal(app.runtime.commands.length, 102); +}); + +test('workflow pushes coalesce a burst into its latest snapshot and skip unchanged duplicates', t => { + t.mock.timers.enable({apis: ['setTimeout']}); + const app = fixture(t), updates = []; + app.info.on_update(value => updates.push(value)); + let baseline_bytes = 0; + for (let index = 1; index <= 1000; index++) { + app.run.steps = index; + app.emit({type: 'run_updated', snapshot: app.run}); + baseline_bytes += Buffer.byteLength(JSON.stringify(app.info.get_info())); + } + assert.equal(updates.length, 0); + assert.equal(app.info.get_info().runs[0].steps, 1000); + t.mock.timers.tick(250); + assert.equal(updates.length, 1); + assert.equal(updates[0].runs[0].steps, 1000); + const optimized_bytes = Buffer.byteLength(JSON.stringify(updates[0])); + assert.ok(optimized_bytes < baseline_bytes / 100); + for (let index = 0; index < 1000; index++) app.emit({type: 'runtime_updated', snapshot: app.runtime}); + t.mock.timers.tick(250); + assert.equal(updates.length, 1); + t.diagnostic(`Synthetic 1000-update burst: ${baseline_bytes} JSON bytes -> ${optimized_bytes} JSON bytes; duplicate burst: 0 extra pushes`); +}); diff --git a/tests/workflow_manager.test.mjs b/tests/workflow_manager.test.mjs new file mode 100644 index 0000000..2703ded --- /dev/null +++ b/tests/workflow_manager.test.mjs @@ -0,0 +1,732 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import test from 'node:test'; +import {tsImport} from 'tsx/esm/api'; + +const {create_workflow_manager} = await tsImport('../bot/workflow/runtime/states_manager.ts', import.meta.url); +const {init_workflow} = await tsImport('../bot/workflow/init.ts', import.meta.url); +const {parse_message_trigger} = await tsImport('../bot/workflow/node_action/event_node/message.ts', import.meta.url); + +function context(ready = true) { + const events = new EventEmitter(); + const bot = new EventEmitter(); + if (ready) bot.entity = {id: 1}; + return { + bot_instance: bot, bot_status: 'online', function_manager: {run: async () => true}, info_manager: null, + workflow_manager: null, bot_config: {}, logger: null, log() {}, + on: events.on.bind(events), off: events.off.bind(events), + emit: events.emit.bind(events), events, + }; +} + +function definition(id = 'one', node = {id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1}}, extra = {}) { + return { + schema_version: 1, id, name: id, enabled: true, start: {type: 'common', auto_start: 'never'}, + entry: 'entry', nodes: [node], edges: [], + policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}, ...extra, + }; +} + +function deferred() { + let resolve; + const promise = new Promise(finish => { resolve = finish; }); + return {promise, resolve}; +} + +function function_definition(id) { + return definition(id, {id: 'entry', type: 'function', config: {path: 'bot', name: id, args: []}}); +} + +const turn = () => new Promise(resolve => setImmediate(resolve)); +async function until(check) { + for (let index = 0; index < 100; index++) { + if (check()) return; + await turn(); + } + assert.fail('Expected workflow state was not reached'); +} +const ended = snapshot => snapshot?.ended_at !== null && snapshot?.ended_at !== undefined; + +test('manager validates disabled graphs, duplicate ids and recursive workflow event', () => { + const ctx = context(); + assert.throws(() => create_workflow_manager(ctx, [definition(), definition()])); + assert.throws(() => create_workflow_manager(ctx, [definition('off', undefined, {enabled: false, entry: 'missing'})])); + assert.throws(() => create_workflow_manager(ctx, [definition('off', undefined, + {enabled: false, start: {type: 'event', event: 'workflow_update'}})])); + assert.equal(ctx.events.eventNames().length, 0); +}); + +test('manager validates global and definition concurrency limits before installing listeners', async () => { + const ctx = context(); + for (const max_concurrent_runs of [0, 257, -1, 1.5, NaN, Infinity, '2']) { + assert.throws(() => create_workflow_manager(ctx, [definition()], {max_concurrent_runs}), + {code: 'INVALID_CONCURRENCY_LIMIT'}); + assert.equal(ctx.events.eventNames().length, 0); + } + for (const max_concurrent_runs of [0, 257, -1, 1.5, '2', null]) { + const config = definition(); + config.policy.max_concurrent_runs = max_concurrent_runs; + assert.throws(() => create_workflow_manager(ctx, [config]), /max_concurrent_runs|invalid concurrent run limit/); + assert.equal(ctx.events.eventNames().length, 0); + } + for (const max_concurrent_runs of [1, 256]) { + const config = definition(); + config.policy.max_concurrent_runs = max_concurrent_runs; + const manager = create_workflow_manager(ctx, [config], {max_concurrent_runs}); + await manager.dispose(); + assert.equal(ctx.events.eventNames().length, 0); + } +}); + +test('manager requires spawn and resets readiness on status disconnect', async () => { + const ctx = context(false); + const manager = create_workflow_manager(ctx, [definition()]); + try { + assert.throws(() => manager.start('missing'), {code: 'WORKFLOW_NOT_FOUND'}); + assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'}); + ctx.bot_instance.entity = {id: 1}; + assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'}); + ctx.emit('spawn'); + const run = manager.start('one'); + assert.equal(run.status, 'started'); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1); + ctx.bot_status = 'disconnecting'; + ctx.emit('status_update', 'disconnecting'); + await until(() => ended(manager.get_snapshot(run.run_id))); + assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disconnect'); + assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0); + ctx.bot_status = 'online'; + ctx.emit('status_update', 'online'); + assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'}); + } finally { await manager.dispose(); } + assert.equal(ctx.events.eventNames().length, 0); +}); + +test('manager queues or skips repeated definitions while distinct workflows run together', async () => { + const ctx = context(); + const first = definition('one'); + first.policy.max_queue = 1; + const skip = definition('skip'); + skip.policy.on_busy = 'skip'; + const manager = create_workflow_manager(ctx, [first, skip]); + try { + const running = manager.start('one'); + const trigger = {nested: {value: 7}}; + assert.equal(manager.start('one', trigger).status, 'queued'); + trigger.nested.value = 99; + assert.equal(manager.start('one').status, 'skipped'); + const distinct = manager.start('skip'); + assert.equal(distinct.status, 'started'); + assert.equal(manager.start('skip').status, 'skipped'); + assert.equal(manager.get_snapshots().length, 2); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2); + await manager.stop(running.run_id); + await until(() => manager.get_snapshots().length === 3); + const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'one' + && snapshot.run_id !== running.run_id); + assert.equal(ended(manager.get_snapshot(distinct.run_id)), false); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2); + ctx.bot_instance.emit('physicsTick'); + await until(() => ended(manager.get_snapshot(queued.run_id)) && ended(manager.get_snapshot(distinct.run_id))); + assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.nested.value, 7); + } finally { await manager.dispose(); } +}); + +test('manager defaults to sixteen concurrent distinct definitions', async () => { + const ctx = context(); + ctx.bot_instance.setMaxListeners(0); + const configs = Array.from({length: 17}, (_, index) => definition(`workflow_${index}`)); + const manager = create_workflow_manager(ctx, configs); + try { + for (const config of configs.slice(0, 16)) assert.equal(manager.start(config.id).status, 'started'); + assert.equal(manager.start(configs[16].id).status, 'queued'); + assert.equal(manager.get_snapshots().length, 16); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 16); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshots().length === 17 + && ctx.bot_instance.listenerCount('physicsTick') === 1); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshots().every(ended)); + assert.ok(manager.get_snapshots().every(snapshot => snapshot.status === 'completed')); + } finally { await manager.dispose(); } +}); + +test('distinct concurrent workflows keep triggers, variables and outputs independent', async () => { + const ctx = context(); + const releases = {one: deferred(), two: deferred()}; + const calls = []; + ctx.info_manager = {get_info: name => ({value: {owner: name}})}; + ctx.function_manager = {async run(_path, name, args, check, {signal}) { + calls.push({name, args, signal}); + await releases[name].promise; + check(); + return true; + }}; + const configs = ['one', 'two'].map(id => definition(id, undefined, { + initial_vars: {owner: id}, + nodes: [ + {id: 'entry', type: 'query', config: {source: 'info', save_as: 'saved', + args: {name: {kind: 'ref', root: 'trigger', path: ['nested', 'owner']}}}}, + {id: 'use', type: 'function', config: {path: 'bot', name: id, args: [ + {kind: 'ref', root: 'trigger', path: ['nested']}, + {kind: 'ref', root: 'vars', path: ['owner']}, + {kind: 'ref', root: 'vars', path: ['saved']}, + {kind: 'ref', root: 'outputs', path: ['entry', 'output']}, + ]}}, + ], + edges: [{id: 'use_result', source: 'entry', port: 'success', target: 'use'}], + })); + const manager = create_workflow_manager(ctx, configs); + try { + const trigger = {nested: {owner: 'one'}}; + const one = manager.start('one', trigger); + trigger.nested.owner = 'two'; + const two = manager.start('two', trigger); + trigger.nested.owner = 'mutated'; + assert.equal(one.status, 'started'); + assert.equal(two.status, 'started'); + assert.notEqual(one.run_id, two.run_id); + await until(() => calls.length === 2); + for (const call of calls) { + assert.deepEqual(call.args, [{owner: call.name}, call.name, {owner: call.name}, {owner: call.name}]); + assert.equal(call.signal.aborted, false); + } + assert.notEqual(calls[0].signal, calls[1].signal); + releases.two.resolve(); + await until(() => ended(manager.get_snapshot(two.run_id))); + assert.equal(ended(manager.get_snapshot(one.run_id)), false); + releases.one.resolve(); + await until(() => ended(manager.get_snapshot(one.run_id))); + for (const run of [one, two]) { + const snapshot = manager.get_snapshot(run.run_id); + assert.equal(snapshot.status, 'completed'); + const branch = snapshot.branches[0]; + assert.deepEqual(branch.trigger, {nested: {owner: snapshot.workflow_id}}); + assert.deepEqual(branch.vars, {owner: snapshot.workflow_id, saved: {owner: snapshot.workflow_id}}); + assert.deepEqual(branch.outputs.entry.output, {owner: snapshot.workflow_id}); + assert.equal(branch.outputs.use.output, true); + } + } finally { + Object.values(releases).forEach(release => release.resolve()); + await manager.dispose(); + } +}); + +test('definition concurrency two overlaps two runs and queues the next instance', async () => { + const ctx = context(); + const config = definition(); + config.policy.max_concurrent_runs = 2; + config.policy.max_queue = 1; + const manager = create_workflow_manager(ctx, [config]); + try { + const first = manager.start('one', {instance: 1}); + const second = manager.start('one', {instance: 2}); + assert.equal(first.status, 'started'); + assert.equal(second.status, 'started'); + assert.notEqual(first.run_id, second.run_id); + assert.equal(manager.start('one', {instance: 3}).status, 'queued'); + assert.equal(manager.start('one', {instance: 4}).status, 'skipped'); + assert.equal(manager.get_snapshots().length, 2); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2); + await manager.stop(first.run_id); + await until(() => manager.get_snapshots().length === 3 + && ctx.bot_instance.listenerCount('physicsTick') === 2); + assert.equal(ended(manager.get_snapshot(second.run_id)), false); + const queued = manager.get_snapshots().find(snapshot => ![first.run_id, second.run_id].includes(snapshot.run_id)); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshots().every(ended)); + assert.equal(manager.get_snapshot(second.run_id).status, 'completed'); + assert.equal(manager.get_snapshot(queued.run_id).status, 'completed'); + assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.instance, 3); + } finally { await manager.dispose(); } +}); + +test('global concurrency two queues or skips distinct definitions at capacity', async () => { + const ctx = context(); + const skip = definition('skip'); + skip.policy.on_busy = 'skip'; + const manager = create_workflow_manager(ctx, [definition('one'), definition('two'), definition('three'), skip], + {max_concurrent_runs: 2}); + try { + const one = manager.start('one'); + const two = manager.start('two'); + assert.equal(one.status, 'started'); + assert.equal(two.status, 'started'); + assert.equal(manager.start('three').status, 'queued'); + assert.equal(manager.start('skip').status, 'skipped'); + assert.equal(manager.get_snapshots().length, 2); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2); + await manager.stop(one.run_id); + await until(() => manager.get_snapshots().length === 3 + && ctx.bot_instance.listenerCount('physicsTick') === 2); + assert.equal(ended(manager.get_snapshot(two.run_id)), false); + assert.ok(manager.get_snapshots().some(snapshot => snapshot.workflow_id === 'three')); + assert.ok(manager.get_snapshots().every(snapshot => snapshot.workflow_id !== 'skip')); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshots().every(ended)); + } finally { await manager.dispose(); } +}); + +test('queue pump bypasses a busy definition without losing its earlier queued request', async () => { + const ctx = context(); + const manager = create_workflow_manager(ctx, [definition('one'), definition('occupied'), definition('two')], + {max_concurrent_runs: 2}); + try { + const one = manager.start('one', {instance: 1}); + const occupied = manager.start('occupied'); + assert.equal(manager.start('one', {instance: 2}).status, 'queued'); + assert.equal(manager.start('two').status, 'queued'); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2); + await manager.stop(occupied.run_id); + await until(() => manager.get_snapshots().length === 3 + && ctx.bot_instance.listenerCount('physicsTick') === 2); + const two = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'two'); + assert.ok(two); + assert.equal(manager.get_snapshots().filter(snapshot => snapshot.workflow_id === 'one').length, 1); + assert.equal(ended(manager.get_snapshot(one.run_id)), false); + await manager.stop(two.run_id); + await turn(); + assert.equal(manager.get_snapshots().length, 3); + await manager.stop(one.run_id); + await until(() => manager.get_snapshots().length === 4 + && ctx.bot_instance.listenerCount('physicsTick') === 1); + const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'one' + && snapshot.run_id !== one.run_id); + ctx.bot_instance.emit('physicsTick'); + await until(() => ended(manager.get_snapshot(queued.run_id))); + assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.instance, 2); + } finally { await manager.dispose(); } +}); + +test('manager bounds global queue at 256 and disconnect clears queued work', async () => { + const ctx = context(); + const config = definition(); + config.policy.max_queue = 1000; + const manager = create_workflow_manager(ctx, [config]); + try { + const run = manager.start('one'); + for (let index = 0; index < 256; index++) assert.equal(manager.start('one').status, 'queued'); + assert.equal(manager.start('one').status, 'skipped'); + ctx.bot_status = 'offline'; + ctx.emit('status_update', 'offline'); + await until(() => ended(manager.get_snapshot(run.run_id))); + ctx.bot_status = 'online'; + ctx.emit('spawn'); + await turn(); + assert.equal(manager.get_snapshots().length, 1); + } finally { await manager.dispose(); } +}); + +test('manager validates bounded JSON triggers without invoking accessors', async () => { + const manager = create_workflow_manager(context(), [definition()]); + try { + let reads = 0; + const accessor = Object.defineProperty({}, 'value', {enumerable: true, get() { reads++; return 1; }}); + const cycle = {}; cycle.self = cycle; + for (const trigger of [accessor, cycle, [], null, {text: 'x'.repeat(70_000)}, {value: Infinity}]) { + assert.throws(() => manager.start('one', trigger)); + } + assert.equal(reads, 0); + assert.equal(manager.get_snapshots().length, 0); + } finally { await manager.dispose(); } +}); + +test('reentrant observer can launch another definition after the launching gate clears', async () => { + const ctx = context(); + const manager = create_workflow_manager(ctx, [definition(), definition('two')]); + let result; + const off = manager.on_update(() => { + if (!result) result = manager.start('two', {queued: true}); + throw new Error('broken observer'); + }); + ctx.on('workflow_update', () => { throw new Error('broken context observer'); }); + try { + const run = manager.start('one'); + assert.equal(result.status, 'queued'); + assert.equal(manager.get_snapshots().length, 1); + await until(() => manager.get_snapshots().length === 2 + && ctx.bot_instance.listenerCount('physicsTick') === 2); + assert.equal(ended(manager.get_snapshot(run.run_id)), false); + assert.ok(manager.get_snapshots().some(snapshot => snapshot.workflow_id === 'two')); + off(); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshots().every(ended)); + const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'two'); + assert.deepEqual(queued.branches[0].trigger, {queued: true}); + } finally { await manager.dispose(); } +}); + +test('terminal snapshots are readable in the final update and retained snapshots are cloned', async () => { + const ctx = context(); + const manager = create_workflow_manager(ctx, [definition('one', {id: 'entry', type: 'log', + config: {level: 'info', message: {kind: 'literal', value: 'done'}}})]); + let terminal; + manager.on_update(event => { + if (event.type === 'run_updated' && event.snapshot.ended_at !== null) { + terminal = manager.get_snapshots().find(snapshot => snapshot.run_id === event.snapshot.run_id); + } + }); + try { + const run = manager.start('one', {value: 1}); + await until(() => terminal); + assert.equal(terminal.status, 'completed'); + terminal.branches[0].trigger.value = 2; + assert.equal(manager.get_snapshot(run.run_id).branches[0].trigger.value, 1); + await manager.stop(run.run_id); + await assert.rejects(manager.stop('unknown'), {code: 'WORKFLOW_RUN_NOT_FOUND'}); + } finally { await manager.dispose(); } +}); + +test('function dependencies bind at actual queue start and pass scoped cancellation signal', async () => { + const ctx = context(); + const calls = []; + const manager = create_workflow_manager(ctx, [definition(), definition('function', {id: 'entry', type: 'function', + config: {path: 'bot', name: 'chat', args: [{kind: 'literal', value: 'hello'}]}})], {max_concurrent_runs: 1}); + try { + const first = manager.start('one'); + manager.start('function'); + ctx.function_manager = {async run(path, name, args, check, options) { + check(); calls.push({path, name, args, signal: options.signal}); return true; + }}; + await manager.stop(first.run_id); + await until(() => calls.length === 1); + assert.equal(calls[0].path, 'bot'); + assert.equal(calls[0].name, 'chat'); + assert.deepEqual(calls[0].args, ['hello']); + assert.ok(calls[0].signal instanceof AbortSignal); + } finally { await manager.dispose(); } +}); + +test('function cancellation drains before the queued workflow starts', async () => { + const ctx = context(); + let aborted = false; + let drained; + const drain = new Promise(resolve => { drained = resolve; }); + ctx.function_manager = {async run(_path, _name, _args, _check, {signal}) { + await new Promise(resolve => signal.addEventListener('abort', () => { aborted = true; resolve(); }, {once: true})); + await drain; + signal.throwIfAborted(); + }}; + const manager = create_workflow_manager(ctx, [definition(), definition('function', {id: 'entry', type: 'function', + config: {path: 'bot', name: 'chat', args: []}})], {max_concurrent_runs: 1}); + try { + const run = manager.start('function'); + await turn(); + manager.start('one'); + let stopped = false; + const stop = manager.stop(run.run_id).then(() => { stopped = true; }); + await until(() => aborted); + assert.equal(stopped, false); + assert.equal(manager.get_snapshots().length, 1); + drained(); + await stop; + await until(() => manager.get_snapshots().length === 2); + } finally { drained(); await manager.dispose(); } +}); + +test('stopping one concurrent run preserves other signals and holds its slot until drain', async () => { + const ctx = context(); + const releases = {one: deferred(), two: deferred()}; + const calls = []; + ctx.function_manager = {async run(_path, name, _args, check, {signal}) { + calls.push({name, signal}); + await releases[name].promise; + check(); + return true; + }}; + const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'), + definition('queued')], {max_concurrent_runs: 2}); + try { + const one = manager.start('one'); + const two = manager.start('two'); + assert.equal(manager.start('queued').status, 'queued'); + await until(() => calls.length === 2); + const first = calls.find(call => call.name === 'one'); + const second = calls.find(call => call.name === 'two'); + assert.notEqual(first.signal, second.signal); + let stopped = false; + const stop = manager.stop(one.run_id).then(() => { stopped = true; }); + await until(() => first.signal.aborted); + await turn(); + assert.equal(stopped, false); + assert.equal(second.signal.aborted, false); + assert.equal(ended(manager.get_snapshot(two.run_id)), false); + assert.equal(manager.get_snapshots().length, 2); + assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0); + releases.one.resolve(); + await stop; + await until(() => manager.get_snapshots().length === 3 + && ctx.bot_instance.listenerCount('physicsTick') === 1); + assert.equal(manager.get_snapshot(one.run_id).status, 'cancelled'); + assert.equal(manager.get_snapshot(one.run_id).stop_reason, 'manual'); + assert.equal(second.signal.aborted, false); + releases.two.resolve(); + await until(() => ended(manager.get_snapshot(two.run_id))); + assert.equal(manager.get_snapshot(two.run_id).status, 'completed'); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshots().every(ended)); + assert.equal(manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'queued').status, 'completed'); + } finally { + Object.values(releases).forEach(release => release.resolve()); + await manager.dispose(); + } +}); + +test('dispose aborts all concurrent runs and awaits every deferred callback drain', async () => { + const ctx = context(); + const releases = {one: deferred(), two: deferred()}; + const calls = []; + ctx.function_manager = {async run(_path, name, _args, check, {signal}) { + calls.push({name, signal}); + await releases[name].promise; + check(); + return true; + }}; + const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'), + definition('queued')], {max_concurrent_runs: 2}); + try { + const one = manager.start('one'); + const two = manager.start('two'); + assert.equal(manager.start('queued').status, 'queued'); + await until(() => calls.length === 2); + let disposed = false; + const dispose = manager.dispose(); + assert.equal(manager.dispose(), dispose); + const done = dispose.then(() => { disposed = true; }); + await until(() => calls.every(call => call.signal.aborted)); + assert.equal(disposed, false); + assert.equal(ctx.events.eventNames().length, 0); + assert.throws(() => manager.start('one'), {code: 'WORKFLOW_MANAGER_DISPOSED'}); + releases.one.resolve(); + await until(() => ended(manager.get_snapshot(one.run_id))); + assert.equal(disposed, false); + assert.equal(ended(manager.get_snapshot(two.run_id)), false); + releases.two.resolve(); + await done; + assert.equal(disposed, true); + assert.equal(manager.get_snapshots().length, 2); + for (const run of [one, two]) { + assert.ok(ended(manager.get_snapshot(run.run_id))); + assert.equal(manager.get_snapshot(run.run_id).status, 'cancelled'); + assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disposed'); + } + assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0); + assert.equal(ctx.bot_instance.listenerCount('end'), 0); + } finally { + Object.values(releases).forEach(release => release.resolve()); + await manager.dispose(); + } +}); + +test('disconnect cancels every concurrent run and clears queued definitions across respawn', async () => { + const ctx = context(); + const releases = {one: deferred(), two: deferred()}; + const calls = []; + ctx.function_manager = {async run(_path, name, _args, check, {signal}) { + calls.push({name, signal}); + await releases[name].promise; + check(); + return true; + }}; + const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'), + definition('queued')], {max_concurrent_runs: 2}); + try { + const one = manager.start('one'); + const two = manager.start('two'); + assert.equal(manager.start('queued').status, 'queued'); + assert.equal(manager.start('one').status, 'queued'); + await until(() => calls.length === 2); + ctx.bot_status = 'offline'; + ctx.emit('status_update', 'offline'); + await until(() => calls.every(call => call.signal.aborted)); + assert.equal(manager.get_snapshots().length, 2); + assert.ok(manager.get_snapshots().every(snapshot => !ended(snapshot))); + ctx.bot_status = 'online'; + ctx.emit('status_update', 'online'); + assert.throws(() => manager.start('queued'), {code: 'BOT_NOT_READY'}); + ctx.emit('spawn'); + releases.one.resolve(); + releases.two.resolve(); + await until(() => manager.get_snapshots().every(ended)); + await turn(); + assert.equal(manager.get_snapshots().length, 2); + assert.equal(calls.length, 2); + for (const run of [one, two]) { + assert.equal(manager.get_snapshot(run.run_id).status, 'cancelled'); + assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disconnect'); + } + assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0); + const fresh = manager.start('queued'); + assert.equal(fresh.status, 'started'); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1); + ctx.bot_instance.emit('physicsTick'); + await until(() => ended(manager.get_snapshot(fresh.run_id))); + assert.equal(manager.get_snapshot(fresh.run_id).status, 'completed'); + } finally { + Object.values(releases).forEach(release => release.resolve()); + await manager.dispose(); + } +}); + +test('info query uses the info adapter and unknown sources fail predictably', async () => { + const ctx = context(); + ctx.info_manager = {get_info: name => name === 'health' ? {value: {health: 20}} : undefined}; + const manager = create_workflow_manager(ctx, ['info', 'missing'].map(source => definition(source, + {id: 'entry', type: 'query', config: {source, args: {name: {kind: 'literal', value: 'health'}}}}))); + try { + const run = manager.start('info'); + await until(() => ended(manager.get_snapshot(run.run_id))); + assert.deepEqual(manager.get_snapshot(run.run_id).branches[0].last_result.output, {health: 20}); + const bad = manager.start('missing'); + await until(() => ended(manager.get_snapshot(bad.run_id))); + assert.equal(manager.get_snapshot(bad.run_id).error.code, 'UNKNOWN_QUERY_SOURCE'); + } finally { await manager.dispose(); } +}); + +test('info query metadata validation preserves the argument error code', async () => { + const ctx = context(); + ctx.info_manager = {get_info: () => ({value: 20})}; + const configs = [{}, {name: {kind: 'literal', value: ''}}, {name: {kind: 'literal', value: 1}}] + .map((args, index) => definition(`invalid_${index}`, {id: 'entry', type: 'query', config: {source: 'info', args}})); + const manager = create_workflow_manager(ctx, configs); + try { + const runs = configs.map(config => manager.start(config.id)); + await until(() => runs.every(run => ended(manager.get_snapshot(run.run_id)))); + for (const run of runs) assert.equal(manager.get_snapshot(run.run_id).error.code, 'INVALID_QUERY_ARGUMENTS'); + } finally {await manager.dispose();} +}); + +test('common spawn, message conditions, arbitrary events and any recursion filter work', async () => { + const ctx = context(false); + const auto = definition('auto', undefined, {start: {type: 'common', auto_start: 'spawn'}}); + const event = definition('message', undefined, {start: {type: 'event', event: 'message', condition: { + op: 'eq', left: {kind: 'ref', root: 'trigger', path: ['username']}, right: {kind: 'literal', value: 'Alice'}, + }}}); + const custom = definition('custom', undefined, {start: {type: 'event', event: 'someCustomEvent'}}); + const any = definition('any', undefined, {start: {type: 'event', event: 'any'}}); + const manager = create_workflow_manager(ctx, [auto, event, custom, any]); + try { + ctx.bot_instance.entity = {id: 1}; + ctx.emit('spawn'); + assert.equal(manager.get_snapshots()[0].workflow_id, 'auto'); + ctx.emit('message', {text: ' no'}, 'chat'); + ctx.emit('message', {text: ' hello'}, 'chat'); + ctx.emit('someCustomEvent', 1, {safe: true}, ctx.bot_instance); + ctx.emit('any', 'workflow_update', {loop: true}); + ctx.emit('any', 'chat', 'Alice', 'hello'); + await until(() => ctx.bot_instance.listenerCount('physicsTick') === 4); + assert.equal(manager.get_snapshots().length, 4); + assert.ok(manager.get_snapshots().every(snapshot => !ended(snapshot))); + ctx.bot_instance.emit('physicsTick'); + await until(() => manager.get_snapshots().length === 4 && manager.get_snapshots().every(ended)); + const message = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'message'); + assert.equal(message.branches[0].trigger.message, 'hello'); + const custom_trigger = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'custom').branches[0].trigger; + assert.deepEqual(custom_trigger.args, [1, {safe: true}, null]); + } finally { await manager.dispose(); } +}); + +test('message adapter reads bounded ChatMessage JSON and rejects malformed objects', () => { + class ChatMessage { constructor() { this.json = {text: ' hello'}; } } + assert.deepEqual(parse_message_trigger([new ChatMessage(), 'chat', 'uuid', true]), { + event: 'message', username: 'Alice', message: 'hello', plainText: ' hello', + position: 'chat', sender: 'uuid', verified: true, + }); + assert.equal(parse_message_trigger([{text: 'x'.repeat(70_000)}]), undefined); + const cyclic = {}; cyclic.text = cyclic; + assert.equal(parse_message_trigger([cyclic]), undefined); +}); + +test('initially ready common start and timer lifetime are disposed', async t => { + t.mock.timers.enable({apis: ['setTimeout']}); + const ctx = context(); + const config = definition('auto', undefined, {start: {type: 'common', auto_start: 'spawn'}}); + const manager = create_workflow_manager(ctx, [config, + definition('timer', undefined, {start: {type: 'timer', interval_ms: 10}})]); + await turn(); + assert.equal(manager.get_snapshots()[0].workflow_id, 'auto'); + t.mock.timers.tick(25); + await turn(); + await manager.dispose(); + const count = manager.get_snapshots().length; + t.mock.timers.tick(25); + assert.equal(manager.get_snapshots().length, count); + assert.equal(ctx.events.eventNames().length, 0); + assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0); + assert.equal(ctx.bot_instance.listenerCount('end'), 0); +}); + +test('init assigns before subscriptions and rolls back all listeners on setup failure', async () => { + const ctx = context(); + ctx.bot_config = {config_revision: 1}; + const original_on = ctx.on; + let observed = false; + ctx.on = (event, callback) => { + assert.ok(ctx.workflow_manager); + observed = true; + original_on(event, callback); + if (event === 'someCustomEvent') throw new Error('listener install failed'); + }; + await assert.rejects(init_workflow(ctx, [definition('bad', undefined, + {start: {type: 'event', event: 'someCustomEvent'}})]), /listener install failed/); + assert.equal(observed, true); + assert.equal(ctx.workflow_manager, null); + assert.equal(ctx.events.eventNames().length, 0); +}); + +test('dispose during first-node logging awaits launched run cleanup', async () => { + const ctx = context(); + let dispose; + let manager; + ctx.logger = {info: message => ({message})}; + ctx.log = () => { dispose = manager.dispose(); }; + manager = create_workflow_manager(ctx, [definition('one', {id: 'entry', type: 'log', + config: {level: 'info', message: {kind: 'literal', value: 'dispose'}}})]); + const run = manager.start('one'); + await until(() => dispose); + await dispose; + assert.ok(ended(manager.get_snapshot(run.run_id))); + assert.equal(ctx.events.eventNames().length, 0); + assert.throws(() => manager.start('one'), {code: 'WORKFLOW_MANAGER_DISPOSED'}); +}); + +test('manager disables code by default and trusted host can explicitly enable it', async () => { + const config = definition('code', {id: 'entry', type: 'code', config: {source: 'return 42;'}}); + const blocked = create_workflow_manager(context(), [config]); + const trusted = create_workflow_manager(context(), [config], {allow_code: true}); + try { + const first = blocked.start('code'); + const second = trusted.start('code'); + await until(() => ended(blocked.get_snapshot(first.run_id))); + assert.equal(blocked.get_snapshot(first.run_id).error.code, 'CODE_EXECUTION_DISABLED'); + await new Promise(resolve => trusted.on_update(event => { + if (event.type === 'run_updated' && event.snapshot.ended_at !== null) resolve(); + })); + assert.equal(trusted.get_snapshot(second.run_id).branches[0].last_result.output, 42); + } finally { await blocked.dispose(); await trusted.dispose(); } +}); + +test('manager retains only the latest 20 completed runs without evicting active runs', async () => { + const manager = create_workflow_manager(context(), [definition('one', {id: 'entry', type: 'log', + config: {level: 'info', message: {kind: 'literal', value: 'done'}}}), + definition('active', {id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1000}})]); + const completed_ids = []; + try { + const active = manager.start('active'); + for (let index = 0; index < 25; index++) { + const run = manager.start('one'); + completed_ids.push(run.run_id); + await until(() => ended(manager.get_snapshot(run.run_id))); + await turn(); + } + const snapshots = manager.get_snapshots(); + assert.equal(snapshots.length, 21); + assert.deepEqual(snapshots.filter(ended).map(run => run.run_id), completed_ids.slice(-20)); + for (const id of completed_ids.slice(0, 5)) assert.equal(manager.get_snapshot(id), undefined); + assert.equal(manager.get_snapshot(active.run_id).status, 'running'); + await manager.stop(active.run_id); + assert.equal(manager.get_snapshots().length, 20); + assert.equal(manager.get_snapshot(completed_ids[5]), undefined); + assert.equal(manager.get_snapshot(active.run_id).status, 'cancelled'); + } finally { await manager.dispose(); } +}); diff --git a/tests/workflow_rpc.test.mjs b/tests/workflow_rpc.test.mjs new file mode 100644 index 0000000..87f9aa7 --- /dev/null +++ b/tests/workflow_rpc.test.mjs @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const compiled = await build({ + stdin: {contents: `export * from './bot/forward/stream.ts'; export * from './bot/forward/manager.ts'; + export * from './utils/forward_protocol.ts';`, resolveDir: root, loader: 'ts'}, + bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, + plugins: [{name: 'isolated-registry', setup(build) { + build.onResolve({filter: /^\.\.\/instance\/index\.ts$/}, () => ({path: 'registry', namespace: 'mock'})); + build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({loader: 'js', contents: + 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};'})); + }}], +}); +const module = {exports: {}}; +new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(createRequire(import.meta.url), module, module.exports); +const {BotStream, NodeForwardManager, validate_rpc} = module.exports; +const turn = () => new Promise(resolve => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return {promise, resolve, reject}; +}; +const fails = (action, code = 'INVALID_REQUEST') => assert.throws(action, error => error.code === code); +const rejects = (promise, code) => assert.rejects(promise, error => error.code === code); +const request = (method, payload, scope = randomUUID(), deadline_at = Date.now() + 1000) => ({ + request_id: randomUUID(), correlation_id: randomUUID(), method, payload, forward_session_id: scope, deadline_at, +}); + +function fixture(t) { + let now = 100; + const calls = []; + const run_id = randomUUID(); + const snapshot = {run_id, workflow_id: 'gather', status: 'running', steps: 1, active_nodes: ['node'], + started_at: 1, ended_at: null, stop_reason: null, error: null, + trigger: {event: 'custom', args: [false, 0, null, {nested: [false, 0, null]}]}, trigger_available: true, + branches: [{trigger: {}, vars: {value: 1}, outputs: {}, inputs: {}, last_result: null, retry_counts: {}}]}; + const manager = { + start(id, trigger) { calls.push(['start', id, trigger]); return {status: 'started', run_id}; }, + async stop(id) { calls.push(['stop', id]); }, + get_snapshot(id) { calls.push(['snapshot', id]); return id === run_id ? snapshot : undefined; }, + get_snapshots() { calls.push(['snapshots']); return [snapshot]; }, + }; + const context = Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat() {}}, workflow_manager: manager}); + let current = context; + const observers = new Set(); + const source = {get: () => current, observe(_id, listener) { observers.add(listener); return () => observers.delete(listener); }}; + const stream = new BotStream(1, {source, now: () => now}); + t.after(() => stream.dispose()); + return {stream, source, context, manager, calls, snapshot, run_id, + set(context) { current = context; for (const listener of observers) listener(context); }, + time(value) { now = value; }, + }; +} + +test('workflow protocol accepts exact start, stop and optional snapshot payloads', () => { + for (const [method, payload] of [ + ['workflow.start', {workflow_id: 'gather-1_test'}], + ['workflow.start', {workflow_id: 'x'.repeat(100), trigger: {nested: {items: [1, true, null, 'value']}}}], + ['workflow.stop', {run_id: randomUUID()}], + ['workflow.snapshot', {}], + ['workflow.snapshot', {run_id: randomUUID()}], + ]) { + const valid = request(method, payload); + assert.equal(validate_rpc(valid), valid); + fails(() => validate_rpc({...valid, payload: {...payload, extra: true}})); + } + for (const workflow_id of ['', '../x', 'a/b', 'a.b', 'a b', 'x'.repeat(101), '__proto__', 'prototype', 'constructor', 1]) + fails(() => validate_rpc(request('workflow.start', {workflow_id}))); + for (const method of ['workflow.stop', 'workflow.snapshot']) + for (const run_id of ['', 'not-a-uuid', null, undefined, 1]) fails(() => validate_rpc(request(method, {run_id}))); + fails(() => validate_rpc(request('workflow.start', {}))); + fails(() => validate_rpc(request('workflow.stop', {}))); + fails(() => validate_rpc(request('workflow.delete', {}))); +}); + +test('workflow triggers require bounded plain JSON without accessors, cycles or unsafe keys', () => { + let reads = 0; + const getter = Object.defineProperty({}, 'secret', {enumerable: true, get() { reads++; return 1; }}); + const cycle = {}; cycle.self = cycle; + let deep = {}; + for (let index = 0; index < 66; index++) deep = {next: deep}; + const sparse = []; sparse.length = 2; + for (const trigger of [null, [], 'text', undefined, new Date(), Object.create({inherited: true}), + getter, cycle, deep, {sparse}, {value: NaN}, {value: Infinity}, {value: 1n}, {value() {}}, + {value: undefined}, {huge: 'x'.repeat(1_048_576)}, {values: Array(100_001).fill(0)}, + JSON.parse('{"__proto__":1}'), {nested: {constructor: 'unsafe'}}, {[Symbol('key')]: 1}, + Object.defineProperty({}, 'hidden', {value: 1})]) { + fails(() => validate_rpc(request('workflow.start', {workflow_id: 'test', trigger}))); + } + assert.equal(reads, 0); + const trigger = Object.assign(Object.create(null), {safe: {values: [null, 2]}}); + assert.equal(validate_rpc(request('workflow.start', {workflow_id: 'test', trigger})).payload.trigger, trigger); +}); + +test('stream start preserves the manager result and copies its trigger input and result', t => { + const app = fixture(t); + const trigger = {data: {value: 1}}; + const result = {status: 'queued', request_id: randomUUID()}; + app.manager.start = (id, input) => { app.calls.push([id, input]); input.data.value = 2; return result; }; + let checks = 0; + const actual = app.stream.workflow_start({workflow_id: 'gather', trigger}, 200, () => checks++); + assert.deepEqual(actual, result); + assert.notEqual(actual, result); + assert.equal(trigger.data.value, 1); + assert.equal(app.calls[0][0], 'gather'); + assert.equal(app.calls[0][1].data.value, 2); + assert.ok(checks >= 2); + app.manager.start = () => ({status: 'skipped'}); + assert.deepEqual(app.stream.workflow_start({workflow_id: 'gather'}, 200), {status: 'skipped'}); +}); + +test('stream workflow guards reject invalid, expired, missing, offline and stale starts before manager calls', t => { + const app = fixture(t); + fails(() => app.stream.workflow_start({workflow_id: '../x'}, 200)); + for (const deadline of [100, 99, NaN, Infinity, 1.5]) + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, deadline), 'TIMEOUT'); + app.context.bot_status = 'offline'; + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'BOT_NOT_ONLINE'); + app.context.bot_status = 'online'; + app.context.bot_instance = null; + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'BOT_NOT_ONLINE'); + app.context.bot_instance = {chat() {}}; + app.context.workflow_manager = null; + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'INTERNAL_ERROR'); + app.context.workflow_manager = app.manager; + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200, () => { + app.context.workflow_manager = {}; + }), 'CANCELLED'); + app.set(undefined); + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'NO_CONTEXT'); + app.stream.dispose(); + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'CANCELLED'); + assert.deepEqual(app.calls, []); +}); + +test('workflow transport and post-start context checks prevent stale responses', t => { + const app = fixture(t); + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200, () => { + throw Object.assign(Error('stale'), {code: 'SESSION_STALE'}); + }), 'SESSION_STALE'); + assert.deepEqual(app.calls, []); + app.manager.start = () => { app.set(Object.assign(new EventEmitter(), app.context)); return {status: 'skipped'}; }; + fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'CANCELLED'); +}); + +test('snapshots are deeply copied, available offline and return null for an unknown run', t => { + const app = fixture(t); + app.context.bot_status = 'offline'; + app.context.bot_instance = null; + const all = app.stream.workflow_snapshot({}, 200); + const one = app.stream.workflow_snapshot({run_id: app.run_id}, 200); + const {branches, trigger, ...summary} = app.snapshot; + const expected_trigger = structuredClone(trigger); + assert.deepEqual(all, [summary]); + assert.equal(Object.hasOwn(all[0], 'trigger'), false); + assert.equal(Object.hasOwn(all[0], 'branches'), false); + assert.deepEqual(one, app.snapshot); + all[0].active_nodes.push('summary mutation'); + one.branches[0].vars.value = 2; + one.trigger.args[3].nested.push('detail mutation'); + one.active_nodes.push('extra'); + assert.equal(app.snapshot.branches[0].vars.value, 1); + assert.deepEqual(app.snapshot.trigger, expected_trigger); + assert.deepEqual(app.snapshot.active_nodes, ['node']); + assert.equal(app.stream.workflow_snapshot({run_id: randomUUID()}, 200), null); + delete app.snapshot.trigger; + assert.equal(app.stream.workflow_snapshot({}, 200)[0].trigger_available, false); + app.manager.get_snapshots = () => { app.time(201); return []; }; + fails(() => app.stream.workflow_snapshot({}, 200), 'TIMEOUT'); +}); + +test('workflow stop waits for drain, works offline and rejects changed contexts or expired results', async t => { + for (const change of ['none', 'deadline', 'context', 'instance', 'manager', 'disposed', 'transport']) { + await t.test(change, async t => { + const app = fixture(t); + app.context.bot_status = 'offline'; + const drain = deferred(); + let settled = false; + let stale = false; + app.manager.stop = id => { app.calls.push(id); return drain.promise; }; + const result = app.stream.workflow_stop({run_id: app.run_id}, 200, () => { + if (stale) throw Object.assign(Error('stale'), {code: 'SESSION_STALE'}); + }); + const expected = {deadline: 'TIMEOUT', context: 'CANCELLED', instance: 'CANCELLED', manager: 'CANCELLED', + disposed: 'CANCELLED', transport: 'SESSION_STALE'}[change]; + const checked = expected ? rejects(result, expected) : result.then(value => assert.deepEqual(value, {stopped: true})); + void result.then(() => {settled = true;}, () => {settled = true;}); + await turn(); + assert.equal(settled, false); + assert.deepEqual(app.calls, [app.run_id]); + if (change === 'deadline') app.time(201); + if (change === 'context') app.set(undefined); + if (change === 'instance') app.context.bot_instance = {}; + if (change === 'manager') app.context.workflow_manager = {}; + if (change === 'disposed') app.stream.dispose(); + if (change === 'transport') stale = true; + drain.resolve(); + await checked; + }); + } +}); + +async function relay(t, stream) { + const command = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'session', node_id: 1, bot_id: 1, + url: 'http://127.0.0.1:1234', path: '/socket.io', namespace: '/', token: 'opaque', + expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000}; + const socket = new EventEmitter(); + socket.auth = {}; + socket.io = {engine: {writeBuffer: []}}; + socket.volatile = {emit() {}}; + socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); }; + socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); }; + socket.timeout = () => ({emit(event, _payload, ack) { + const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command; + const data = event === 'forward_node_ready' ? {node_ready: true} + : {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at, role: 'node', node_ready: false}; + queueMicrotask(() => ack(null, {status: 'success', data})); + }}); + const manager = new NodeForwardManager(1, 'session', {socket_factory: () => socket, stream_factory: () => stream}); + t.after(() => manager.stop()); + assert.equal((await manager.ensure(command)).status, 'success'); + return {socket, manager, rpc: (method, payload, deadline = Date.now() + 1000) => new Promise(resolve => + socket.emit('relay_request', request(method, payload, command.forward_session_id, deadline), resolve))}; +} + +test('Node dispatches workflow RPCs through stream guards and retains function RPC callbacks', async t => { + const app = fixture(t); + // Relay deadlines use the wall clock while direct stream tests use a fixed clock. + app.time(Date.now()); + let before = 0; + app.context.function_manager = {async run(path, name, args, check) { + check(); before++; assert.deepEqual([path, name, args], ['bot', 'chat', ['hello']]); return true; + }}; + const channel = await relay(t, app.stream); + assert.deepEqual(await channel.rpc('workflow.start', {workflow_id: 'gather'}), + {status: 'success', data: {status: 'started', run_id: app.run_id}}); + assert.deepEqual(await channel.rpc('workflow.snapshot', {run_id: app.run_id}), {status: 'success', data: app.snapshot}); + const {branches, trigger, ...summary} = app.snapshot; + assert.deepEqual(await channel.rpc('workflow.snapshot', {}), {status: 'success', data: [summary]}); + assert.deepEqual(await channel.rpc('workflow.stop', {run_id: app.run_id}), {status: 'success', data: {stopped: true}}); + assert.deepEqual(await channel.rpc('execute_function', {function: 'bot', name: 'chat', args: ['hello']}), + {status: 'success', data: true}); + assert.equal(before, 1); + assert.equal((await channel.rpc('workflow.start', {workflow_id: '../bad'})).data.code, 'INVALID_REQUEST'); + assert.equal((await channel.rpc('workflow.start', {workflow_id: 'gather'}, Date.now() - 1)).data.code, 'TIMEOUT'); +}); + +test('Node returns stop TIMEOUT without waiting for drain and safely contains late rejection', async t => { + const app = fixture(t); + const drain = deferred(); + app.manager.stop = () => drain.promise; + const channel = await relay(t, app.stream); + const response = await channel.rpc('workflow.stop', {run_id: app.run_id}, Date.now() + 30); + assert.equal(response.status, 'error'); + assert.equal(response.data.code, 'TIMEOUT'); + drain.reject(Error('SECRET late failure')); + await turn(); + assert.equal((await channel.rpc('workflow.snapshot', {})).status, 'success'); +}); + +test('Node rejects stopped transport results and sanitizes workflow manager failures', async t => { + const app = fixture(t); + const drain = deferred(); + app.manager.stop = () => drain.promise; + const channel = await relay(t, app.stream); + app.manager.start = () => { throw Error('SECRET workflow error'); }; + const failed = await channel.rpc('workflow.start', {workflow_id: 'gather'}); + assert.equal(failed.data.code, 'INTERNAL_ERROR'); + assert.equal(JSON.stringify(failed).includes('SECRET'), false); + const pending = channel.rpc('workflow.stop', {run_id: app.run_id}); + channel.socket.disconnect(); + drain.resolve(); + assert.equal((await pending).data.code, 'SESSION_STALE'); +}); + +test('workflow state updates retain reliable transport emission', async t => { + const app = fixture(t); + app.context.info_manager = {}; + const channel = await relay(t, app.stream); + const reliable = []; + const volatile = []; + channel.socket.on('info_update', packet => reliable.push(packet)); + channel.socket.volatile.emit = (event, packet) => volatile.push([event, packet]); + const update = {name: 'workflow_states', value: [{run_id: app.run_id, status: 'completed'}]}; + app.context.emit('info_update', update); + assert.equal(reliable.length, 1); + assert.deepEqual(reliable[0].data, update); + assert.deepEqual(volatile, []); +}); diff --git a/tests/workflow_trigger_history.test.mjs b/tests/workflow_trigger_history.test.mjs new file mode 100644 index 0000000..6abbcc9 --- /dev/null +++ b/tests/workflow_trigger_history.test.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire, isBuiltin} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import test from 'node:test'; +import {build} from 'esbuild'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const compiled = await build({ + stdin: {contents: `export * from './bot/workflow/runtime/states_manager.ts'; + export * from './bot/workflow/runtime/summary.ts'; export * from './bot/forward/stream.ts';`, + resolveDir: root, loader: 'ts'}, + bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, + plugins: [{name: 'isolated-registry', setup(build) { + build.onResolve({filter: /^\.\.\/instance\/index\.ts$/}, () => ({path: 'registry', namespace: 'mock'})); + build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({loader: 'js', contents: + 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};'})); + }}], +}); +const module = {exports: {}}; +const require = createRequire(import.meta.url); +new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => { + assert.ok(isBuiltin(specifier) || specifier === 'xstate', `Unexpected workflow history dependency: ${specifier}`); + return require(specifier); +}, module, module.exports); +const {create_workflow_manager, summarize_run, BotStream} = module.exports; +const turn = () => new Promise(resolve => setImmediate(resolve)); +async function until(check) { + for (let index = 0; index < 100; index++) { + if (check()) return; + await turn(); + } + assert.fail('Expected workflow history state was not reached'); +} +const ended = snapshot => snapshot?.ended_at !== null && snapshot?.ended_at !== undefined; +const log = id => ({id, type: 'log', config: {level: 'info', message: {kind: 'literal', value: 'done'}}}); +const sleep = id => ({id, type: 'sleep', config: {mode: 'ticks', value: 1}}); +function definition(id = 'one', extra = {}) { + return {schema_version: 1, id, name: id, enabled: true, + start: {type: 'common', auto_start: 'never'}, entry: 'entry', nodes: [log('entry')], edges: [], + policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}, ...extra}; +} +function fixture(t, configs = [definition()]) { + const bot = Object.assign(new EventEmitter(), {entity: {id: 1}, chat() {}}); + const context = Object.assign(new EventEmitter(), {bot_instance: bot, bot_status: 'online', + info_manager: null, function_manager: {run: async () => true}, logger: null, log() {}}); + const manager = create_workflow_manager(context, configs); + context.workflow_manager = manager; + const stream = new BotStream(1, {source: {get: () => context, observe: () => () => {}}}); + t.after(async () => {stream.dispose(); await manager.dispose();}); + const detail = run_id => stream.workflow_snapshot({run_id}, Date.now() + 5000); + return {bot, context, manager, stream, detail}; +} + +test('completed chat and message runs retain their actual normalized trigger values', async t => { + const app = fixture(t, ['chat', 'message', 'custom'].map(event => definition(event, + {start: {type: 'event', event}}))); + const chat_args = ['Alice', 'hello', {nested: [false, 0, null, {value: 'original'}]}]; + class ChatMessage {constructor() {this.json = {text: ' message body'};}} + app.context.emit('chat', ...chat_args); + app.context.emit('message', new ChatMessage(), 'chat', 'sender-id', false); + app.context.emit('custom', false, 0, null, {nested: [false, 0, null]}); + await until(() => app.manager.get_snapshots().length === 3 && app.manager.get_snapshots().every(ended)); + for (const snapshot of app.manager.get_snapshots()) { + const expected = { + chat: {event: 'chat', args: chat_args, username: 'Alice', message: 'hello'}, + message: {event: 'message', username: 'Bob', message: 'message body', plainText: ' message body', + position: 'chat', sender: 'sender-id', verified: false}, + custom: {event: 'custom', args: [false, 0, null, {nested: [false, 0, null]}]}, + }[snapshot.workflow_id]; + assert.equal(snapshot.status, 'completed'); + const detail = app.detail(snapshot.run_id); + assert.deepEqual(detail.trigger, expected); + assert.deepEqual(detail.branches[0].trigger, expected); + assert.equal(detail.trigger_available, true); + assert.deepEqual(JSON.parse(JSON.stringify(detail.trigger)), expected); + } + const summaries = app.stream.workflow_snapshot({}, Date.now() + 5000); + assert.equal(summaries.length, 3); + for (const summary of summaries) { + assert.equal(summary.trigger_available, true); + assert.equal(Object.hasOwn(summary, 'trigger'), false); + assert.equal(Object.hasOwn(summary, 'branches'), false); + } +}); + +test('queued chat events keep their original argument objects after the emitter mutates them', async t => { + const app = fixture(t, [definition('chat', {start: {type: 'event', event: 'chat'}, nodes: [sleep('entry')]})]); + app.context.emit('chat', 'Alice', 'first', {nested: {value: 1}}); + const first = app.manager.get_snapshots()[0]; + const args = ['Bob', 'queued', {nested: {value: 0}, values: [false, null]}]; + const original = structuredClone(args); + app.context.emit('chat', ...args); + assert.equal(app.manager.get_runtime_snapshot().workflows[0].queued_count, 1); + args[0] = 'mutated'; args[1] = 'changed'; args[2].nested.value = 99; args[2].values.push(true); + await app.manager.stop(first.run_id); + await until(() => app.manager.get_snapshots().length === 2 && app.bot.listenerCount('physicsTick') === 1); + const queued = app.manager.get_snapshots().find(snapshot => snapshot.run_id !== first.run_id); + const expected = {event: 'chat', args: original, username: 'Bob', message: 'queued'}; + assert.deepEqual(app.detail(queued.run_id).trigger, expected); + app.bot.emit('physicsTick'); + await until(() => ended(app.manager.get_snapshot(queued.run_id))); + assert.deepEqual(app.detail(queued.run_id).trigger, expected); + assert.deepEqual(app.detail(queued.run_id).branches[0].trigger, expected); +}); + +test('failed and cancelled runs retain triggers even with no terminal branches', async t => { + for (const status of ['failed', 'cancelled']) { + await t.test(status, async t => { + const app = fixture(t, [definition('one', {nodes: [sleep('entry'), log('next')], + edges: [{id: 'next', source: 'entry', port: 'success', target: 'next'}], + policy: {on_busy: 'queue', max_queue: 10, max_steps: 1, timeout_ms: 5000}})]); + const trigger = {event: 'custom', args: [false, 0, null, {nested: ['original']}]}; + const run = app.manager.start('one', trigger); + const running = app.detail(run.run_id); + assert.deepEqual(running.branches, []); + assert.deepEqual(running.trigger, trigger); + assert.equal(running.trigger_available, true); + await until(() => app.bot.listenerCount('physicsTick') === 1); + if (status === 'cancelled') await app.manager.stop(run.run_id); + else app.bot.emit('physicsTick'); + await until(() => ended(app.manager.get_snapshot(run.run_id))); + const detail = app.detail(run.run_id); + assert.equal(detail.status, status); + assert.deepEqual(detail.branches, []); + assert.deepEqual(detail.trigger, trigger); + assert.equal(detail.trigger_available, true); + if (status === 'failed') assert.equal(detail.error.code, 'STEP_LIMIT_EXCEEDED'); + }); + } +}); + +test('trigger inputs, observer events, run details and branch triggers never share mutations', async t => { + const app = fixture(t, [definition('one', {nodes: [sleep('entry')]})]); + const trigger = {nested: {values: [false, 0, null]}}; + const original = structuredClone(trigger); + const observed = []; + app.manager.on_update(event => { + if (event.type === 'run_updated') event.snapshot.trigger.nested.values.push('observer mutation'); + }); + app.manager.on_update(event => {if (event.type === 'run_updated') observed.push(event.snapshot);}); + const run = app.manager.start('one', trigger); + trigger.nested.values.push('input mutation'); + const running = app.manager.get_snapshot(run.run_id); + running.trigger.nested.values.push('snapshot mutation'); + assert.deepEqual(app.manager.get_snapshot(run.run_id).trigger, original); + await until(() => app.bot.listenerCount('physicsTick') === 1); + app.bot.emit('physicsTick'); + await until(() => ended(app.manager.get_snapshot(run.run_id))); + await turn(); + assert.ok(observed.length > 0); + assert.ok(observed.every(snapshot => JSON.stringify(snapshot.trigger) === JSON.stringify(original))); + const detail = app.detail(run.run_id); + detail.trigger.nested.values.push('detail mutation'); + assert.deepEqual(detail.branches[0].trigger, original); + detail.branches[0].trigger.nested.values.push('branch mutation'); + const list = app.manager.get_snapshots(); + list[0].trigger.nested.values.push('list mutation'); + assert.deepEqual(app.detail(run.run_id).trigger, original); + assert.deepEqual(app.detail(run.run_id).branches[0].trigger, original); +}); + +test('summaries mark actual trigger presence including empty input and legacy missing triggers', () => { + const legacy = {run_id: 'legacy', branches: [{trigger: {old: true}}], trigger_available: true}; + assert.deepEqual(summarize_run(legacy), {run_id: 'legacy', trigger_available: false}); + assert.deepEqual(summarize_run({...legacy, trigger: {}, trigger_available: false}), + {run_id: 'legacy', trigger_available: true}); +}); + +test('history keeps the latest 20 completed details while evicted run ids return null', async t => { + const app = fixture(t, [definition(), definition('active', {nodes: [sleep('entry')]})]); + const active = app.manager.start('active', {active: true}); + const ids = []; + for (let index = 0; index < 25; index++) { + const run = app.manager.start('one', {index, values: [false, 0, null]}); + ids.push(run.run_id); + await until(() => ended(app.manager.get_snapshot(run.run_id))); + await turn(); + } + const completed = app.manager.get_snapshots().filter(ended); + assert.deepEqual(completed.map(snapshot => snapshot.run_id), ids.slice(-20)); + assert.equal(app.detail(active.run_id).status, 'running'); + for (const id of ids.slice(0, 5)) assert.equal(app.detail(id), null); + for (let index = 5; index < ids.length; index++) assert.deepEqual(app.detail(ids[index]).trigger, + {index, values: [false, 0, null]}); + await app.manager.stop(active.run_id); + assert.equal(app.manager.get_snapshots().length, 20); + assert.equal(app.detail(ids[5]), null); + assert.deepEqual(app.detail(active.run_id).trigger, {active: true}); +}); + +test('the 5 MiB history budget evicts older details before reaching the run-count limit', async t => { + const app = fixture(t, [definition('one', {initial_vars: {payload: 'x'.repeat(350_000)}})]); + const ids = []; + for (let index = 0; index < 19; index++) { + const run = app.manager.start('one', {index}); + ids.push(run.run_id); + await until(() => ended(app.manager.get_snapshot(run.run_id))); + await turn(); + } + const retained = app.manager.get_snapshots(); + assert.ok(retained.length > 0 && retained.length < 19); + assert.ok(retained.reduce((bytes, snapshot) => bytes + Buffer.byteLength(JSON.stringify(snapshot)), 0) <= 5 * 1024 * 1024); + assert.deepEqual(retained.map(snapshot => snapshot.run_id), ids.slice(-retained.length)); + for (const id of ids.slice(0, -retained.length)) assert.equal(app.detail(id), null); + assert.deepEqual(app.detail(ids.at(-1)).trigger, {index: 18}); +}); + +test('oversized terminal history strips branches but preserves its full trigger detail', async t => { + const leaves = Array.from({length: 6}, (_, index) => log(`leaf_${index}`)); + const app = fixture(t, [definition('one', {initial_vars: {payload: 'x'.repeat(900_000)}, + nodes: [log('entry'), ...leaves], edges: leaves.map(node => + ({id: node.id, source: 'entry', port: 'success', target: node.id}))})]); + const trigger = {event: 'custom', args: [false, 0, null, {nested: ['original']}]}; + let terminal; + app.manager.on_update(event => { + if (event.type === 'run_updated' && ended(event.snapshot)) terminal = event.snapshot; + }); + const run = app.manager.start('one', trigger); + await until(() => terminal); + await turn(); + assert.equal(terminal.branches.length, 6); + assert.ok(Buffer.byteLength(JSON.stringify(terminal)) > 5 * 1024 * 1024); + const detail = app.detail(run.run_id); + assert.equal(detail.status, 'completed'); + assert.deepEqual(detail.branches, []); + assert.deepEqual(detail.trigger, trigger); + assert.equal(detail.trigger_available, true); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1614594 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2025", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "strictNullChecks": false, + "skipLibCheck": true, + "noEmit": true, + "preserveSymlinks": false, + "types": ["node"], + "allowImportingTsExtensions": true, + "allowJs": true, + "lib": [ + "es2025", + "es2015.proxy" + ] + } +} diff --git a/types/bot/function/function.ts b/types/bot/function/function.ts new file mode 100644 index 0000000..08e4295 --- /dev/null +++ b/types/bot/function/function.ts @@ -0,0 +1,32 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {data_type} from '../instance/bot_runtime.ts'; +import type {WorkflowManager} from '../workflow/runtime.ts'; + +export interface FunctionI18n { + lang: string; + name: string; + description: string; +} + +export interface FunctionArgument { + name: string; + type: data_type; + i18n: FunctionI18n; + /** A trailing boolean argument with a default may be omitted by callers. */ + default?: boolean; +} + +export interface FunctionExecutionContext { + workflow?: Pick; +} + +/** Function implementations declare their metadata and validate arguments before operating on a Bot. */ +export interface BaseFunction { + readonly name: string; + readonly i18n: FunctionI18n; + readonly args: readonly FunctionArgument[]; + /** Internal dispatch metadata; never included in function discovery responses. */ + readonly execution?: 'workflow_control'; + /** Returns true on success and false on execution failure. */ + run(bot: Bot, args: Args, executionContext?: FunctionExecutionContext): Promise; +} diff --git a/types/bot/instance/bot_runtime.ts b/types/bot/instance/bot_runtime.ts new file mode 100755 index 0000000..140285b --- /dev/null +++ b/types/bot/instance/bot_runtime.ts @@ -0,0 +1,31 @@ +import { ProxyOptions } from "@wp2508/mineflayer"; +import { supportedVersions } from '@wp2508/minecraft-protocol' +import {bot_config_plugin} from "../plugins/plugin.ts"; +import {workflow_config} from "../workflow/workflow.ts"; + +export type bot_status = 'offline' | 'ready' | 'connecting' | 'online' + | 'disconnecting' | 'reconnecting'; + +export interface bot_config { + instance_config: bot_instance_config; + plugins: bot_config_plugin[]; + workflow: workflow_config[]; +} + +export interface bot_instance_config { + username: string; + host: string; + port?: number | null; + version: string; + user_id: number; + bot_id: number; + config_revision?: number; + plugins?: bot_config_plugin[]; + workflow?: workflow_config[]; + proxy?: ProxyOptions | null; +} + +export type data_type = 'string' | 'boolean' | 'number' | 'datetime'| + 'string-select' | 'number-select' | + 'string-multi-select' | 'number-multi-select' | + 'string-array' | 'number-array' diff --git a/types/bot/plugins/plugin.ts b/types/bot/plugins/plugin.ts new file mode 100644 index 0000000..07917d0 --- /dev/null +++ b/types/bot/plugins/plugin.ts @@ -0,0 +1,68 @@ +import type {data_type} from "../instance/bot_runtime"; +import {isDeepStrictEqual} from 'node:util'; +import type {bot_instance_context} from '../../../bot/instance/bot_instance_init.ts'; + +export type PluginContext = Pick, + 'on' | 'off' | 'log' | 'logger' | 'schedule_reconnect' | 'cancel_reconnect'>; + +/** Constructors only define metadata; resources belong to init/dispose. */ +export abstract class BasePlugin, States = unknown> { + private states?: States; + private readonly state_listeners = new Set<() => void>(); + abstract readonly name: string; + abstract readonly config_template: plugin; + abstract init(context: PluginContext, config: Config): Promise; + get_status(): States | undefined { return structuredClone(this.states); } + protected set_states(states: States): void { + if (isDeepStrictEqual(this.states, states)) return; + this.states = structuredClone(states); + for (const listener of Array.from(this.state_listeners)) { + if (!this.state_listeners.has(listener)) continue; + try { listener(); } catch {} + } + } + on_states_update(callback: () => void): () => void { + const listener = () => callback(); + this.state_listeners.add(listener); + return () => { this.state_listeners.delete(listener); }; + } + abstract dispose(): Promise; +} + +export interface bot_config_plugin { + name: string; + path: string; + config: T; +} + +export interface plugin { + name: string; + i18n: { + lang: string; + name: string; + description: string; + }[], + version: string; + config: plugin_config[]; +} + +export interface plugin_config { + name: string; + i18n: { + lang: string; + name: string; + description: string; + }[] + required: boolean; + default: any; + type: data_type; +} + +export interface base_define { + path: string; + i18n: { + lang: string; + name: string; + description: string; + }[] +} diff --git a/types/bot/workflow/metadata.ts b/types/bot/workflow/metadata.ts new file mode 100644 index 0000000..acc6fce --- /dev/null +++ b/types/bot/workflow/metadata.ts @@ -0,0 +1,71 @@ +import type {Json, WorkflowNode, WorkflowStart} from './workflow.ts'; + +export interface WorkflowI18n {lang: string; name: string; description: string} +export interface WorkflowField { + name: string; + i18n: WorkflowI18n[]; + required: boolean; + nullable?: boolean; + type: 'string' | 'number' | 'boolean' | 'select' | 'object' | 'array' | 'map' + | 'value_source' | 'condition' | 'code'; + default?: Json; + min?: number; + max?: number; + integer?: boolean; + max_length?: number; + min_length?: number; + options?: {value: string | number; i18n: WorkflowI18n[]}[]; + fields?: WorkflowField[]; + item?: WorkflowField; + provider?: 'function' | 'event' | 'query' | 'info'; + language?: 'javascript'; +} +export interface WorkflowOutputTemplate { + i18n: WorkflowI18n[]; + type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array' | 'json'; + nullable?: boolean; + optional?: boolean; + properties?: Record; + items?: WorkflowOutputTemplate; + additional_properties?: WorkflowOutputTemplate; +} +export interface WorkflowPortTemplate {name: string; i18n: WorkflowI18n[]} +interface TemplateBase { + name: string; + version: string; + i18n: WorkflowI18n[]; + config: WorkflowField[]; + output: WorkflowOutputTemplate; +} +export interface WorkflowNodeTemplate extends TemplateBase { + name: WorkflowNode['type']; + ports: WorkflowPortTemplate[]; + provider?: 'function' | 'query'; + availability?: {available: boolean; reason?: string}; +} +export interface WorkflowStartTemplate extends TemplateBase { + name: WorkflowStart['type']; +} +export interface WorkflowEventTemplate extends TemplateBase { + readiness: 'spawned'; + custom?: boolean; +} +export interface WorkflowQueryTemplate extends TemplateBase {provider?: 'info'} +export interface WorkflowEventDefinition { + config_template: WorkflowEventTemplate; + normalize(args: unknown[]): Record | undefined; +} +export interface WorkflowCatalog { + catalog_version: 1; + nodes: WorkflowNodeTemplate[]; + start_nodes: WorkflowStartTemplate[]; + events: WorkflowEventTemplate[]; + queries: WorkflowQueryTemplate[]; + common: { + node_fields: WorkflowField[]; + policy_fields: WorkflowField[]; + result: WorkflowOutputTemplate; + edge_input: WorkflowOutputTemplate; + references: {root: string; i18n: WorkflowI18n[]}[]; + }; +} diff --git a/types/bot/workflow/runtime.ts b/types/bot/workflow/runtime.ts new file mode 100644 index 0000000..166096b --- /dev/null +++ b/types/bot/workflow/runtime.ts @@ -0,0 +1,108 @@ +import type {Condition, Json, WorkflowNode, workflow_config} from './workflow.ts'; + +export interface WorkflowError {code: string; message: string; node_id: string} +export interface NodeResult { + status: 'success' | 'failure'; + output: Json; + error?: WorkflowError; +} +export interface OutputRecord extends NodeResult {execution: number} +export interface BranchState { + trigger: Record; + vars: Record; + outputs: Record; + last_result: (NodeResult & {node_id: string}) | null; + inputs: Record; + retry_counts: Record; +} +export interface NodeReturn { + port: string; + result: NodeResult; + vars?: Record; + retry_counts?: Record; +} +export interface WorkflowDependencies { + run_function(path: string, name: string, args: Json[], signal: AbortSignal): Promise; + query(source: string, args: Record, signal: AbortSignal): Promise; + sleep(mode: 'milliseconds' | 'ticks', value: number, signal: AbortSignal): Promise; + log(level: 'info' | 'warning' | 'error', message: string): void; + execute_code?(source: string, state: BranchState, signal: AbortSignal): Promise; +} +export type NodeHandler = (node: WorkflowNode, state: BranchState, signal: AbortSignal, + deps: WorkflowDependencies) => Promise; +export type RunStatus = 'running' | 'stopping' | 'completed' | 'failed' | 'cancelled' | 'timed_out'; +export type StopReason = 'manual' | 'disconnect' | 'timeout' | 'disposed' | 'updated' | 'reloaded'; +export type WorkflowAction = 'run_once' | 'trigger_start' | 'trigger_stop' | 'stop' | 'stop_run' | 'reload'; +export interface WorkflowControl { + command_id: string; + action: WorkflowAction; + workflow_id?: string; + run_id?: string; + expected_generation?: number; + input?: Record; +} +export interface WorkflowCommand { + command_id: string; + action: WorkflowAction; + workflow_id: string | null; + state: 'accepted' | 'completed' | 'failed'; + result?: StartResult | {stopped: true} | {generation: number}; + error?: {code: string; message: string}; +} +export interface WorkflowRuntimeState { + workflow_id: string; + generation: number; + enabled: boolean; + start_type: 'common' | 'event' | 'timer'; + trigger_state: 'stopped' | 'listening' | 'scheduled' | 'exhausted' | 'reloading' | 'error'; + accepted_count: number; + max_runs: number | null; + next_fire_at: number | null; + active_run_ids: string[]; + queued_count: number; + last_error: {code: string; message: string} | null; +} +export interface WorkflowRuntimeSnapshot { + source_config_revision: number; + workflows: WorkflowRuntimeState[]; + commands: WorkflowCommand[]; +} +export interface RunSnapshot { + run_id: string; + workflow_id: string; + status: RunStatus; + steps: number; + active_nodes: string[]; + started_at: number; + ended_at: number | null; + stop_reason: StopReason | null; + error: WorkflowError | null; + trigger?: Record; + trigger_available?: boolean; + branches: BranchState[]; +} +export type WorkflowRunEvent = {type: 'run_updated'; snapshot: RunSnapshot} + | {type: 'node_started'; run_id: string; node_id: string; execution: number} + | {type: 'node_finished'; run_id: string; node_id: string; execution: number; result: NodeResult} + | {type: 'runtime_updated'; snapshot: WorkflowRuntimeSnapshot}; +export interface CompiledWorkflow { + definition: workflow_config; + nodes: Map; + outgoing: Map; + incoming: Map; + join_owners: Map; +} +export interface StartResult {status: 'started' | 'queued' | 'skipped'; run_id?: string; request_id?: string} +export interface WorkflowManager { + start(workflow_id: string, trigger?: Record): StartResult; + stop(run_id: string, reason?: StopReason): Promise; + get_snapshot(run_id: string): RunSnapshot | undefined; + get_snapshots(): RunSnapshot[]; + apply(configs: workflow_config[], source_revision?: number): Promise; + control(request: WorkflowControl): WorkflowCommand; + get_command(command_id: string): WorkflowCommand | undefined; + get_runtime_snapshot(): WorkflowRuntimeSnapshot; + on_update(callback: (event: WorkflowRunEvent) => void): () => void; + dispose(): Promise; +} +export type {Condition}; diff --git a/types/bot/workflow/workflow.ts b/types/bot/workflow/workflow.ts new file mode 100644 index 0000000..43d44f1 --- /dev/null +++ b/types/bot/workflow/workflow.ts @@ -0,0 +1,56 @@ +export type Json = null | boolean | number | string | Json[] | {[key: string]: Json}; + +export type ValueSource = {kind: 'literal'; value: Json} | { + kind: 'ref'; + root: 'trigger' | 'vars' | 'outputs' | 'last_result' | 'inputs'; + path: (string | number)[]; +}; + +export type Condition = { + op: 'eq' | 'contains' | 'gt' | 'lt'; left: ValueSource; right: ValueSource; +} | {op: 'and' | 'or'; items: Condition[]} | {op: 'not'; item: Condition}; + +export type WorkflowStart = {type: 'common'; auto_start: 'never' | 'spawn'} + | {type: 'event'; event: string; condition?: Condition; auto_start?: boolean; mode?: 'continuous' | 'once'} + | {type: 'timer'; interval_ms: number; auto_start?: boolean; mode?: 'once' | 'repeat'; max_runs?: number | null}; + +interface NodeBase {id: string; name?: string; timeout_ms?: number} + +export type WorkflowNode = NodeBase & ( + {type: 'function'; config: {path: string; name: string; args: ValueSource[]}} + | {type: 'query'; config: {source: string; args: Record; save_as?: string}} + | {type: 'condition'; config: {condition: Condition}} + | {type: 'sleep'; config: {mode: 'milliseconds' | 'ticks'; value: number}} + | {type: 'error'; config: {codes?: string[]}} + | {type: 'goto'; config: {max_retries: number; delay_ms: number}} + | {type: 'merge'; config: {mode: 'all' | 'any'}} + | {type: 'log'; config: {level: 'info' | 'warning' | 'error'; message: ValueSource}} + | {type: 'code'; config: {source: string}} +); + +export interface WorkflowEdge { + id: string; + source: string; + port: string; + target: string; +} + +export interface workflow_config { + schema_version: 1; + id: string; + name: string; + enabled: boolean; + start: WorkflowStart; + entry: string; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + initial_vars?: Record; + policy: { + /** Concurrent instances of this definition; defaults to one. */ + max_concurrent_runs?: number; + on_busy: 'skip' | 'queue'; + max_queue: number; + max_steps: number; + timeout_ms: number; + }; +} diff --git a/types/forward.ts b/types/forward.ts new file mode 100644 index 0000000..93baa0f --- /dev/null +++ b/types/forward.ts @@ -0,0 +1,63 @@ +// Node owns these wire DTOs independently of the relay and Main implementations. +import type {Json} from './bot/workflow/workflow.ts'; + +export type ForwardErrorCode = 'INVALID_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' + | 'NODE_OFFLINE' | 'RELAY_UNAVAILABLE' | 'SESSION_STALE' | 'TOKEN_EXPIRED' + | 'TIMEOUT' | 'CANCELLED' | 'NO_CONTEXT' | 'BOT_NOT_ONLINE' | 'RATE_LIMITED' + | 'RESYNC_REQUIRED' | 'INTERNAL_ERROR'; + +export type ForwardAck = {status: 'success'; data: T} | { + status: 'error'; data: {code: ForwardErrorCode; message: string; retryable: boolean}; +}; + +export interface EnsureForward { + forward_id: number; + forward_session_id: string; + node_session_id: string; + node_id: number; + bot_id: number; + url: string; + path: string; + namespace: string; + token: string; + expires_at: number; + deadline_at: number; +} + +export type ForwardScope = Pick; + +export type ForwardReady = ForwardScope & {node_ready: true; lease_expires_at: number}; + +export interface JoinReady { + forward_id: number; + forward_session_id: string; + node_session_id: string; + node_id: number; + bot_id: number; + role: 'node'; + node_ready: boolean; + expires_at: number; +} + +export type RelayMethod = 'bot.snapshot' | 'bot.logs' | 'bot.chat' | 'get_log_history' | 'tab_complete' + | 'get_context_info' | 'execute_function' | 'get_data_file_list' | 'get_file_data' | keyof WorkflowPayloads; + +export interface WorkflowPayloads { + 'workflow.start': {workflow_id: string; trigger?: Record}; + 'workflow.stop': {run_id: string}; + 'workflow.snapshot': {run_id?: string; include_branches?: boolean}; + 'workflow.catalog': {}; + 'workflow.runtime': {}; + 'workflow.control': import('./bot/workflow/runtime.ts').WorkflowControl & {context_generation: string}; + 'workflow.command': {command_id: string; context_generation: string}; +} + +export interface RelayRequest { + request_id: string; + correlation_id: string; + forward_session_id: string; + deadline_at: number; + method: RelayMethod; + payload: Record; +} diff --git a/types/utils/logger.ts b/types/utils/logger.ts new file mode 100644 index 0000000..496f364 --- /dev/null +++ b/types/utils/logger.ts @@ -0,0 +1,10 @@ +export type font_style = 'normal' | 'italic' | 'bold' | 'light' |'underline' | 'url'; + +export interface log { + message: any; + font_color: string; + font_style: font_style[]; + hoverEvent?: {action: 'show_text' | 'show_item' | 'show_entity'; value: string; segments?: Array<{message: string; font_color: string; font_style: font_style[]}>}; + clickEvent?: {action: string; value: string}; + insertion?: string; +} diff --git a/types/utils/socket_response.ts b/types/utils/socket_response.ts new file mode 100644 index 0000000..5bbb680 --- /dev/null +++ b/types/utils/socket_response.ts @@ -0,0 +1,4 @@ +export interface SocketResponse { + status: "success" | "error"; + data: any; +} diff --git a/utils/bot_config_apply_handler.ts b/utils/bot_config_apply_handler.ts new file mode 100644 index 0000000..3a63d6e --- /dev/null +++ b/utils/bot_config_apply_handler.ts @@ -0,0 +1,216 @@ +import {createHash} from 'node:crypto'; +import {types} from 'node:util'; +import type {Socket} from 'socket.io-client'; +import type {bot_instance_context} from '../bot/instance/bot_instance_init.ts'; +import {get_bot_context} from '../bot/instance/index.ts'; +import type {bot_config_plugin} from '../types/bot/plugins/plugin.ts'; +import type {workflow_config} from '../types/bot/workflow/workflow.ts'; +import {compile_workflow} from '../bot/workflow/runtime/compile.ts'; +import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from './plugin_validation.ts'; +import {console_print} from './console_print.ts'; + +type Context = bot_instance_context & {readonly is_disposed?: boolean}; +interface Request { + command_id: string; + payload_version: 1; + node_session_id: string; + bot_id: number; + config_revision: number; + plugins: bot_config_plugin[]; + workflows: workflow_config[]; +} +type Reply = {status: 'success'; data: Pick & { + state: 'completed' | 'processing' | 'no_context'; +}} | {status: 'error'; data: {code: string; message: string}}; +type State = {revision: number; fingerprint?: string; applied_revision: number; applied_fingerprint?: string; tail: Promise}; +type Job = {fingerprint: string; context: Context | undefined; reply: Reply; pending: boolean; failure_reported?: boolean}; +const max_bytes = 256 * 1024; +const fields = ['command_id', 'payload_version', 'node_session_id', 'bot_id', 'config_revision', 'plugins', 'workflows']; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +const failure = (code: string, message: string): Reply => ({status: 'error', data: {code, message}}); +class ApplyGuardError extends Error { + constructor(readonly code: string, message: string) { super(message); } +} + +// Reject accessors, proxies, sparse arrays and unsafe keys before hashing or cloning input. +function bounded_json(value: unknown): void { + let bytes = 0, entries = 0; + const ancestors = new Set(); + const visit = (item: unknown, depth: number): void => { + if (depth > 64 || ++entries > 200000) throw new Error('Invalid JSON'); + if (item === null || typeof item === 'boolean' || typeof item === 'number' && Number.isFinite(item)) { + bytes += JSON.stringify(item).length; + } else if (typeof item === 'string') { + if (item.length > max_bytes) throw new Error('Invalid JSON'); + bytes += Buffer.byteLength(JSON.stringify(item)); + } else if (item && typeof item === 'object') { + if (types.isProxy(item) || ancestors.has(item)) throw new Error('Invalid JSON'); + const array = Array.isArray(item); + if (!array && !plain_plugin_object(item)) throw new Error('Invalid JSON'); + const keys = Reflect.ownKeys(item); + if (keys.length > 200000 || array && keys.length !== item.length + 1) throw new Error('Invalid JSON'); + ancestors.add(item); + bytes += 2; + let count = 0; + for (const key of keys) { + if (array && key === 'length') continue; + if (typeof key !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(key)) throw new Error('Invalid JSON'); + const descriptor = Object.getOwnPropertyDescriptor(item, key)!; + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) throw new Error('Invalid JSON'); + if (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= item.length)) throw new Error('Invalid JSON'); + if (!array) bytes += Buffer.byteLength(JSON.stringify(key)) + 1; + if (count++) bytes++; + visit(descriptor.value, depth + 1); + } + ancestors.delete(item); + } else throw new Error('Invalid JSON'); + if (bytes > max_bytes) throw new Error('Invalid JSON'); + }; + visit(value, 0); +} + +function fingerprint(value: unknown): string { + const canonical = (item: any): any => Array.isArray(item) ? item.map(canonical) + : item && typeof item === 'object' ? Object.fromEntries(Object.keys(item).sort().map(key => [key, canonical(item[key])])) : item; + return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex'); +} + +function parse(value: unknown): Request { + bounded_json(value); + if (!plain_plugin_object(value) || Object.keys(value).length !== fields.length + || !fields.every(field => Object.hasOwn(value, field)) || typeof value.command_id !== 'string' || !uuid.test(value.command_id) + || value.payload_version !== 1 || typeof value.node_session_id !== 'string' || !value.node_session_id.trim() + || Buffer.byteLength(value.node_session_id) > 256 || !positive(value.bot_id) || !positive(value.config_revision) + || !Array.isArray(value.plugins) || !Array.isArray(value.workflows)) throw new Error('Invalid payload'); + const names = new Set(); + for (const item of value.plugins) { + if (!plain_plugin_object(item) || Object.keys(item).length !== 3 || !valid_plugin_name(item.name) + || !valid_plugin_path(item.path) || !valid_plugin_config(item.config) || names.has(item.name)) throw new Error('Invalid plugins'); + names.add(item.name); + } + // Compile the entire graph set before any plugin mutation, including duplicate IDs. + const ids = new Set(); + for (const item of value.workflows) { + const {definition} = compile_workflow(item); + if (ids.has(definition.id)) throw new Error('Duplicate workflow'); + ids.add(definition.id); + } + return structuredClone(value) as unknown as Request; +} + +export function create_bot_config_apply_handler(lookup: (bot_id: number) => Context | undefined = get_bot_context) { + const jobs = new Map(); + const states = new WeakMap(); + let pending = 0; + const trim = () => { + let completed = jobs.size - pending; + for (const [id, job] of jobs) { + if (completed <= 100) break; + if (!job.pending) { jobs.delete(id); completed--; } + } + }; + const success = (request: Request, state: 'completed' | 'processing' | 'no_context'): Reply => ({status: 'success', data: { + command_id: request.command_id, bot_id: request.bot_id, config_revision: request.config_revision, state, + }}); + const current = (request: Request, context: Context) => lookup(request.bot_id) === context && !context.is_disposed; + const session = (socket: Socket, request: Request) => socket.connected && socket.id === request.node_session_id; + + return async (socket: Socket, data: unknown, callback: unknown): Promise => { + if (typeof callback !== 'function') return; + const ack = (reply: Reply) => { try { callback(structuredClone(reply)); } catch {} }; + let request: Request; + try { request = parse(data); } + catch { ack(failure('INVALID_PAYLOAD', '配置应用请求格式无效')); return; } + if (!session(socket, request)) { ack(failure('SESSION_MISMATCH', '节点会话已失效')); return; } + const content = fingerprint({plugins: request.plugins, workflows: request.workflows}); + const identity = fingerprint({bot_id: request.bot_id, config_revision: request.config_revision, content}); + const previous = jobs.get(request.command_id); + if (previous) { + if (previous.fingerprint !== identity) { ack(failure('COMMAND_CONFLICT', '命令标识已用于其他配置')); return; } + if (previous.context && !current(request, previous.context)) { ack(failure('CONTEXT_CHANGED', '机器人上下文已失效')); return; } + const retryable = !previous.pending && previous.reply.status === 'error' + && ['APPLY_FAILED', 'PARTIAL_APPLY_FAILED', 'SESSION_MISMATCH'].includes(previous.reply.data.code); + if (!retryable || !previous.failure_reported) { + ack(previous.reply); + // Report the settled failure once, then allow the same command to retry safely. + if (retryable) previous.failure_reported = true; + return; + } + } + const context = lookup(request.bot_id); + if (!context) { + const reply = success(request, 'no_context'); + jobs.set(request.command_id, {fingerprint: identity, context, reply, pending: false}); + trim(); ack(reply); return; + } + if (!current(request, context)) { ack(failure('CONTEXT_CHANGED', '机器人上下文已失效')); return; } + if (!context.plugin_manager || !context.workflow_manager) { + ack(failure('MANAGER_UNAVAILABLE', '机器人配置管理器尚未就绪')); return; + } + let state = states.get(context); + if (!state) { + const config = context.bot_config; + const revision = positive(config.config_revision) ? config.config_revision : 0; + const initial = Array.isArray(config.plugins) && Array.isArray(config.workflow) + ? fingerprint({plugins: config.plugins, workflows: config.workflow}) : undefined; + state = {revision, fingerprint: initial, applied_revision: revision, applied_fingerprint: initial, tail: Promise.resolve()}; + states.set(context, state); + } + if (request.config_revision < state.revision) { ack(failure('STALE_REVISION', '配置版本已过期')); return; } + if (request.config_revision === state.revision && state.fingerprint && state.fingerprint !== content) { + ack(failure('REVISION_CONFLICT', '相同配置版本的内容不一致')); return; + } + if (pending >= 32) { ack(failure('APPLY_BUSY', '配置应用队列已满')); return; } + state.revision = request.config_revision; + state.fingerprint = content; + const job: Job = {fingerprint: identity, context, reply: success(request, 'processing'), pending: true}; + jobs.set(request.command_id, job); + pending++; + const installed_state = state; + const manager = context.workflow_manager; + const guard = () => { + if (!current(request, context)) throw new ApplyGuardError('CONTEXT_CHANGED', '机器人上下文已失效'); + if (!session(socket, request)) throw new ApplyGuardError('SESSION_MISMATCH', '节点会话已失效'); + if (context.workflow_manager !== manager) throw new ApplyGuardError('CONTEXT_CHANGED', '机器人配置管理器已失效'); + }; + state.tail = state.tail.then(async () => { + let plugin_applied = false; + try { + guard(); + if (request.config_revision < installed_state.revision) throw new ApplyGuardError('STALE_REVISION', '配置版本已过期'); + if (installed_state.applied_revision !== request.config_revision || installed_state.applied_fingerprint !== content) { + await context.plugin_manager.apply_desired(request.plugins); + plugin_applied = true; + guard(); + await manager.apply(request.workflows, request.config_revision); + guard(); + context.bot_config.plugins = structuredClone(request.plugins); + context.bot_config.workflow = structuredClone(request.workflows); + context.bot_config.config_revision = request.config_revision; + installed_state.applied_revision = request.config_revision; + installed_state.applied_fingerprint = content; + } + job.reply = success(request, 'completed'); + const message = `机器人 ${request.bot_id} 配置版本 ${request.config_revision} 应用完成`; + try { console_print.log(console_print.success(message)); } catch {} + try { if (context.logger) context.log([context.logger.info(message)]); } catch {} + } catch (error) { + // Transport replies and logs must never contain thrown plugin parameters or code. + job.reply = error instanceof ApplyGuardError ? failure(error.code, error.message) + : failure(plugin_applied ? 'PARTIAL_APPLY_FAILED' : 'APPLY_FAILED', + plugin_applied ? '插件已应用,工作流应用失败,请重试完整配置' : '配置应用失败,请重试完整配置'); + const message = `机器人 ${request.bot_id} 配置版本 ${request.config_revision} 应用失败`; + try { console_print.log(console_print.error(message)); } catch {} + try { if (context.logger) context.log([context.logger.error(message)]); } catch {} + } finally { + job.pending = false; + pending--; + trim(); + } + }); + ack(job.reply); + }; +} + +export const bot_config_apply_handler = create_bot_config_apply_handler(); diff --git a/utils/command_completion.ts b/utils/command_completion.ts new file mode 100644 index 0000000..2501652 --- /dev/null +++ b/utils/command_completion.ts @@ -0,0 +1,63 @@ +interface CommandNode { + flags?: {command_node_type?: number}; + children?: number[]; + redirectNode?: number; + extraNodeData?: {name?: string; parser?: string; properties?: {type?: number | string}}; +} +interface CommandTree {nodes: CommandNode[]; rootIndex: number} +const trees = new WeakMap(); + +export function remember_command_tree(instance: object, packet: unknown): void { + const tree = packet as CommandTree; + if (tree && Array.isArray(tree.nodes) && Number.isSafeInteger(tree.rootIndex) + && tree.nodes.length <= 100_000 && tree.nodes[tree.rootIndex]) trees.set(instance, tree); +} + +export function local_command_completion(instance: object, input: string, players: string[]): {matches: string[]; start: number; length: number} { + const start = input.search(/\S*$/); + const offset = start === 0 && input.startsWith('/') ? 1 : start; + const result = {matches: [] as string[], start: offset, length: input.length - offset}; + const tree = trees.get(instance); + if (!tree || !input.startsWith('/')) return result; + // Walk complete arguments; only the final (possibly empty) token is being completed. + const tokens = input.slice(1, start || 1).trim().split(/\s+/).filter(Boolean); + let parents = [tree.rootIndex]; + const children = (index: number): number[] => { + const node = tree.nodes[index]; + const redirect = node?.redirectNode; + return Number.isSafeInteger(redirect) ? tree.nodes[redirect!]?.children ?? [] : node?.children ?? []; + }; + for (const token of tokens) { + const next: number[] = []; + for (const parent of parents) { + for (const index of children(parent)) { + const node = tree.nodes[index]; + if (node?.flags?.command_node_type === 1 && node.extraNodeData?.name === token) next.push(index); + else if (node?.flags?.command_node_type === 2) { + const parser = node.extraNodeData?.parser; + const kind = node.extraNodeData?.properties?.type; + // Quoted and greedy strings need Brigadier parsing; leave their suggestions to the server. + if (token.includes('"') || parser === 'minecraft:message' || parser === 'brigadier:string' && (kind === 2 || kind === 'greedy_phrase')) continue; + next.push(index); + } + } + } + parents = [...new Set(next)].slice(0, 256); + } + const prefix = input.slice(offset).toLowerCase(); + for (const parent of parents) { + for (const index of children(parent)) { + const node = tree.nodes[index]; + const data = node?.extraNodeData; + const suggestions = node?.flags?.command_node_type === 1 ? [data?.name] + : data?.parser === 'brigadier:bool' ? ['true', 'false'] + : data?.parser === 'minecraft:entity' || data?.parser === 'minecraft:game_profile' ? players : []; + for (const match of suggestions) { + if (typeof match === 'string' && match.length && match.length <= 256 + && match.toLowerCase().startsWith(prefix) && !result.matches.includes(match)) result.matches.push(match); + if (result.matches.length >= 100) return result; + } + } + } + return result; +} diff --git a/utils/console_print.ts b/utils/console_print.ts new file mode 100644 index 0000000..9f06a86 --- /dev/null +++ b/utils/console_print.ts @@ -0,0 +1,254 @@ +import * as readline from 'node:readline'; +import { EventEmitter } from 'node:events'; +import type { log, font_style } from '../types/utils/logger.ts'; + +// 将 font_style 转为 ANSI 参数;normal 不产生参数 +function style_codes(font_style: font_style[]): number[] { + const codes: number[] = []; + for (const style of font_style ?? []) { + switch (style) { + case 'bold': + codes.push(1); + break; + case 'light': + codes.push(2); + break; + case 'italic': + codes.push(3); + break; + case 'underline': + case 'url': + codes.push(4); + break; + case 'normal': + default: + break; + } + } + return codes; +} + +// 解析 #rgb / #rrggbb,失败返回 null +function parse_hex_color(color: string): [number, number, number] | null { + if (typeof color !== 'string') { + return null; + } + const value = color.trim().replace(/^#/, ''); + if (/^[0-9a-fA-F]{3}$/.test(value)) { + return [ + parseInt(value[0] + value[0], 16), + parseInt(value[1] + value[1], 16), + parseInt(value[2] + value[2], 16), + ]; + } + if (/^[0-9a-fA-F]{6}$/.test(value)) { + return [ + parseInt(value.slice(0, 2), 16), + parseInt(value.slice(2, 4), 16), + parseInt(value.slice(4, 6), 16), + ]; + } + return null; +} + +// 将一段 log 渲染为带颜色的字符串 +function to_ansi(segment: log): string { + const message = segment.message === undefined + ? 'undefined' + : typeof segment.message === 'string' + ? segment.message + : String(segment.message); + + const codes = style_codes(segment.font_style ?? []); + const rgb = parse_hex_color(segment.font_color); + if (rgb !== null) { + codes.push(38, 2, rgb[0], rgb[1], rgb[2]); + } + if (codes.length === 0) { + return message; + } + return `\x1b[${codes.join(';')}m${message}\x1b[0m`; +} + +export type console_segment = log | string | number | boolean | null | undefined; +export type console_input = console_segment | console_segment[]; + +export interface _ConsolePrint { + info(message: any): log; + success(message: any): log; + warning(message: any): log; + error(message: any): log; + tip(message: any): log; + custom(message: any, font_color: string, font_style: font_style[]): log; + log(input: console_input): void; + on(event: string | symbol, listener: (...args: any[]) => void): this; + once(event: string | symbol, listener: (...args: any[]) => void): this; + enable_input(prompt?: string): void; + disable_input(): void; + set_prompt(prompt: string): void; +} + +export class ConsolePrint extends EventEmitter implements _ConsolePrint { + private rl: readline.Interface | null = null; + private prompt = '> '; + private input_enabled = false; + // 输入行提交后是否需要重新显示提示符(write_line 已恢复时置为 false) + private need_prompt = false; + + // 监听 input 时自动开启控制台输入 + public on(event: string | symbol, listener: (...args: any[]) => void): this { + if (event === 'input') { + this.enable_input(); + } + return super.on(event, listener); + } + + public once(event: string | symbol, listener: (...args: any[]) => void): this { + if (event === 'input') { + this.enable_input(); + } + return super.once(event, listener); + } + + public info(message: any): log { // 白色 + return { + message, + font_color: '#ffffff', + font_style: ['normal'] + }; + } + + public success(message: any): log { // 柔和绿 + return { + message, + font_color: '#8fb98f', + font_style: ['normal'] + }; + } + + public warning(message: any): log { // 柔和沙黄 + return { + message, + font_color: '#d1b478', + font_style: ['normal'] + }; + } + + public error(message: any): log { // 柔和玫红 + return { + message, + font_color: '#cf8e8e', + font_style: ['normal'] + }; + } + + public tip(message: any): log { // 柔和钢蓝 + return { + message, + font_color: '#8fb0c9', + font_style: ['normal'] + }; + } + + public custom(message: any, font_color: string, font_style: font_style[]): log { + return { + message, + font_color, + font_style + }; + } + + // 多段拼接后在同一行输出,每段可拥有独立的颜色与样式;支持单段或数组 + public log(input: console_input): void { + const segments: console_segment[] = Array.isArray(input) ? input : [input]; + if (segments.length === 0) { + return; + } + const body = segments.map(segment => this.format(segment)).join(''); + this.write_line(this.format(this.timestamp()) + body); + } + + // 自动前缀:[month_day hh:mm:ss],统一使用 success 色 + private timestamp(): log { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hour = String(now.getHours()).padStart(2, '0'); + const minute = String(now.getMinutes()).padStart(2, '0'); + const second = String(now.getSeconds()).padStart(2, '0'); + return this.success(`[${month}-${day} ${hour}:${minute}:${second}] `); + } + + private format(segment: log | string | number | boolean | null | undefined): string { + if (segment !== null && segment !== undefined && typeof segment === 'object' && 'message' in segment) { + return to_ansi(segment as log); + } + return String(segment); + } + + // 有输入时先清掉当前输入行,打印日志后再把输入行恢复,避免输入被打断 + private write_line(text: string): void { + if (this.rl !== null && this.input_enabled && process.stdout.isTTY === true) { + readline.cursorTo(process.stdout, 0); + readline.clearLine(process.stdout, 0); + process.stdout.write(text + '\n'); + this.rl.prompt(true); + this.need_prompt = false; + return; + } + process.stdout.write(text + '\n'); + } + + // 开启控制台输入,按回车后通过 'input' 事件抛出整行内容 + public enable_input(prompt?: string): void { + if (prompt !== undefined) { + this.prompt = prompt; + } + if (this.rl !== null) { + this.rl.setPrompt(this.prompt); + return; + } + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: process.stdout.isTTY === true + }); + this.rl.setPrompt(this.prompt); + this.rl.on('line', (line: string) => { + // 若输入处理期间没有输出日志,readline 已在换行后清空输入行,这里补回提示符; + // 若期间输出过日志,write_line 已恢复提示符,避免重复 + this.need_prompt = true; + this.emit('input', line); + if (this.rl !== null && this.need_prompt) { + this.rl.prompt(); + } + this.need_prompt = false; + }); + this.rl.on('close', () => { + this.rl = null; + this.input_enabled = false; + }); + this.input_enabled = true; + this.rl.prompt(); + } + + public disable_input(): void { + if (this.rl === null) { + return; + } + const rl = this.rl; + this.rl = null; + this.input_enabled = false; + rl.close(); + } + + public set_prompt(prompt: string): void { + this.prompt = prompt; + this.rl?.setPrompt(prompt); + if (this.rl !== null && this.input_enabled) { + this.rl.prompt(true); + } + } +} + +export const console_print = new ConsolePrint(); diff --git a/utils/forward_protocol.ts b/utils/forward_protocol.ts new file mode 100644 index 0000000..81f0557 --- /dev/null +++ b/utils/forward_protocol.ts @@ -0,0 +1,219 @@ +import type {EnsureForward, ForwardAck, ForwardErrorCode, JoinReady, RelayRequest, WorkflowPayloads} from '../types/forward.ts'; +import {assert_json} from '../bot/workflow/runtime/value.ts'; + +const errors: Record = { + INVALID_REQUEST: ['Invalid request', false], + UNAUTHORIZED: ['Authorization required', false], + FORBIDDEN: ['Operation forbidden', false], + NODE_OFFLINE: ['Node unavailable', true], + RELAY_UNAVAILABLE: ['Relay unavailable', true], + SESSION_STALE: ['Session is no longer current', false], + TOKEN_EXPIRED: ['Authorization expired', false], + TIMEOUT: ['Request deadline exceeded', true], + CANCELLED: ['Request cancelled', false], + NO_CONTEXT: ['Bot context unavailable', false], + BOT_NOT_ONLINE: ['Bot is not online', false], + RATE_LIMITED: ['Request limit exceeded', true], + RESYNC_REQUIRED: ['Synchronization required', true], + INTERNAL_ERROR: ['Operation failed', false], +}; + +export class ForwardFailure extends Error { + constructor(public readonly code: ForwardErrorCode) { + super(errors[code][0]); + } +} + +export function error_ack(code: ForwardErrorCode): ForwardAck { + const [message, retryable] = errors[code]; + return {status: 'error', data: {code, message, retryable}}; +} + +export function failure_code(error: unknown): ForwardErrorCode { + if (error !== null && typeof error === 'object' && 'code' in error + && typeof error.code === 'string' && Object.hasOwn(errors, error.code)) { + return error.code as ForwardErrorCode; + } + return 'INTERNAL_ERROR'; +} + +export function object(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === null || Object.getPrototypeOf(prototype) === null; +} + +export function exact(value: unknown, required: readonly string[], optional: readonly string[] = []): value is Record { + return object(value) && required.every(key => Object.hasOwn(value, key)) + && Reflect.ownKeys(value).every(key => { + if (typeof key !== 'string' || (!required.includes(key) && !optional.includes(key))) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + return descriptor.enumerable && 'value' in descriptor; + }); +} + +export const uuid = (value: unknown): value is string => typeof value === 'string' + && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +export const integer = (value: unknown, min = 0): value is number => Number.isSafeInteger(value) && (value as number) >= min; +const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= max; + +export function validate_command(value: unknown): EnsureForward { + if (!exact(value, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', + 'url', 'path', 'namespace', 'token', 'expires_at', 'deadline_at']) + || !integer(value.forward_id) || !integer(value.node_id, 1) || !integer(value.bot_id, 1) + || !uuid(value.forward_session_id) || !text(value.node_session_id, 256) || !value.node_session_id.trim() + || Buffer.byteLength(value.node_session_id) > 256 + || !text(value.token, 8192) || Buffer.byteLength(value.token) > 8192 + || !integer(value.expires_at, 1) || !integer(value.deadline_at, 1) + || !text(value.url, 2048) || !text(value.path, 256) || !text(value.namespace, 256) + || !/^\/[a-zA-Z0-9_./-]*$/.test(value.path) + || !/^\/[a-zA-Z0-9_/-]*$/.test(value.namespace)) { + throw new ForwardFailure('INVALID_REQUEST'); + } + try { + const url = new URL(value.url); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || url.search + || (url.pathname !== '' && url.pathname !== '/')) throw new Error(); + } catch { + throw new ForwardFailure('INVALID_REQUEST'); + } + return value as unknown as EnsureForward; +} + +export function success_data(value: unknown): unknown { + if (!exact(value, ['status', 'data'])) throw new ForwardFailure('RELAY_UNAVAILABLE'); + if (value.status === 'error') { + if (exact(value.data, ['code', 'message', 'retryable']) && typeof value.data.code === 'string' + && Object.hasOwn(errors, value.data.code) && typeof value.data.message === 'string' + && typeof value.data.retryable === 'boolean') { + throw new ForwardFailure(value.data.code as ForwardErrorCode); + } + throw new ForwardFailure('RELAY_UNAVAILABLE'); + } + if (value.status !== 'success') throw new ForwardFailure('RELAY_UNAVAILABLE'); + return value.data; +} + +export function validate_join(value: unknown, command: EnsureForward, joining: boolean): JoinReady { + const data = success_data(value); + if (!exact(data, ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id', 'role', 'node_ready', 'expires_at']) + || data.forward_id !== command.forward_id || data.forward_session_id !== command.forward_session_id + || data.node_session_id !== command.node_session_id + || data.node_id !== command.node_id || data.bot_id !== command.bot_id || data.role !== 'node' + || typeof data.node_ready !== 'boolean' || (joining && data.node_ready !== false) + || data.expires_at !== command.expires_at || (data.expires_at as number) <= Date.now()) { + throw new ForwardFailure('SESSION_STALE'); + } + return data as unknown as JoinReady; +} + +export function validate_rpc(value: unknown): RelayRequest { + if (!exact(value, ['request_id', 'method', 'payload', 'correlation_id', 'forward_session_id', 'deadline_at']) + || !uuid(value.request_id) || !uuid(value.correlation_id) || !uuid(value.forward_session_id) + || !integer(value.deadline_at, 1)) throw new ForwardFailure('INVALID_REQUEST'); + switch (value.method) { + case 'workflow.start': + case 'workflow.stop': + case 'workflow.snapshot': + case 'workflow.catalog': + case 'workflow.runtime': + case 'workflow.control': + case 'workflow.command': + validate_workflow_payload(value.method, value.payload); + break; + case 'execute_function': + if (!exact(value.payload, ['function', 'name', 'args']) + || typeof value.payload.function !== 'string' + || !/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(value.payload.function) + || typeof value.payload.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(value.payload.name) + || !Array.isArray(value.payload.args)) throw new ForwardFailure('INVALID_REQUEST'); + break; + case 'bot.snapshot': + case 'get_log_history': + case 'get_context_info': + case 'get_data_file_list': + if (!exact(value.payload, [])) throw new ForwardFailure('INVALID_REQUEST'); + break; + case 'get_file_data': { + const payload = value.payload; + if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id) + || (Object.hasOwn(payload, 'offset') && !integer(payload.offset)) + || (Object.hasOwn(payload, 'length') && (!integer(payload.length, 1) || payload.length > 262_144))) { + throw new ForwardFailure('INVALID_REQUEST'); + } + break; + } + case 'bot.logs': { + const payload = value.payload; + if (!exact(payload, ['limit'], ['stream_id', 'after_seq']) || !integer(payload.limit, 1) || payload.limit > 200 + || (Object.hasOwn(payload, 'stream_id') && !uuid(payload.stream_id)) + || (Object.hasOwn(payload, 'after_seq') && (!uuid(payload.stream_id) || !integer(payload.after_seq)))) { + throw new ForwardFailure('INVALID_REQUEST'); + } + break; + } + case 'bot.chat': + if (!exact(value.payload, ['message']) || !text(value.payload.message, 256) + || !value.payload.message.trim() || /[\r\n\u0000]/.test(value.payload.message)) throw new ForwardFailure('INVALID_REQUEST'); + break; + case 'tab_complete': + if (!exact(value.payload, ['text']) || typeof value.payload.text !== 'string' + || value.payload.text.length > 256 || /[\r\n\u0000]/.test(value.payload.text)) throw new ForwardFailure('INVALID_REQUEST'); + break; + default: + throw new ForwardFailure('INVALID_REQUEST'); + } + return value as unknown as RelayRequest; +} + +export function validate_workflow_payload(method: M, payload: unknown): WorkflowPayloads[M] { + if (method === 'workflow.start') { + if (!exact(payload, ['workflow_id'], ['trigger']) || typeof payload.workflow_id !== 'string' + || !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id) + || ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id)) { + throw new ForwardFailure('INVALID_REQUEST'); + } + if (Object.hasOwn(payload, 'trigger')) { + try { + if (!object(payload.trigger)) throw new Error(); + assert_json(payload.trigger); + } catch { throw new ForwardFailure('INVALID_REQUEST'); } + } + } else if (method === 'workflow.catalog' || method === 'workflow.runtime') { + if (!exact(payload, [])) throw new ForwardFailure('INVALID_REQUEST'); + } else if (method === 'workflow.command') { + if (!exact(payload, ['command_id', 'context_generation']) || !uuid(payload.command_id) + || !uuid(payload.context_generation)) throw new ForwardFailure('INVALID_REQUEST'); + } else if (method === 'workflow.control') { + try {assert_json(payload);} catch {throw new ForwardFailure('INVALID_REQUEST');} + if (!object(payload) || !uuid(payload.command_id) || !uuid(payload.context_generation) + || !['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'].includes(payload.action as string)) { + throw new ForwardFailure('INVALID_REQUEST'); + } + const run = payload.action === 'stop_run'; + if (!exact(payload, ['command_id', 'context_generation', 'action', run ? 'run_id' : 'workflow_id'], + [...(!run ? ['expected_generation'] : []), ...(payload.action === 'run_once' ? ['input'] : [])]) + || run && !uuid(payload.run_id) + || !run && (typeof payload.workflow_id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(payload.workflow_id) + || ['__proto__', 'prototype', 'constructor'].includes(payload.workflow_id)) + || Object.hasOwn(payload, 'expected_generation') && !integer(payload.expected_generation, 1)) { + throw new ForwardFailure('INVALID_REQUEST'); + } + if (Object.hasOwn(payload, 'input')) { + try { + if (!object(payload.input)) throw new Error(); + assert_json(payload.input); + if (Buffer.byteLength(JSON.stringify(payload.input)) > 65_536) throw new Error(); + } catch {throw new ForwardFailure('INVALID_REQUEST');} + } + } else if (method === 'workflow.stop' || method === 'workflow.snapshot') { + if (!exact(payload, method === 'workflow.stop' ? ['run_id'] : [], method === 'workflow.snapshot' ? ['run_id', 'include_branches'] : []) + || Object.hasOwn(payload, 'run_id') && !uuid(payload.run_id) + || Object.hasOwn(payload, 'include_branches') && (!Object.hasOwn(payload, 'run_id') || typeof payload.include_branches !== 'boolean')) { + throw new ForwardFailure('INVALID_REQUEST'); + } + } else { + throw new ForwardFailure('INVALID_REQUEST'); + } + return payload as WorkflowPayloads[M]; +} diff --git a/utils/inventory_item.ts b/utils/inventory_item.ts new file mode 100644 index 0000000..66b3be1 --- /dev/null +++ b/utils/inventory_item.ts @@ -0,0 +1,266 @@ +import type {Bot} from '@wp2508/mineflayer'; +import type {log} from '../types/utils/logger.ts'; +import zhCn from '../data/runtime/language/zh_cn.json'; +import {MinecraftJsonParser} from './message_json_parser.ts'; + +export type ItemValue = null | boolean | number | string | ItemValue[] | {[key: string]: ItemValue}; +export interface InventoryItem { + slot: number | null; + type: number; + name: string; + i18n: {lang: string; name: string; description: string}[]; + count: number; + metadata: number; + displayName: log[]; + lore: log[][]; + enchants: {id?: number; name?: string; i18n: InventoryItem['i18n']; level: number; hidden: boolean}[]; + durability: {used: number; max: number; remaining: number} | null; + unbreakable: boolean; + glint: boolean | null; + book: {title: string; author: string; pages: log[][]} | null; + details: Record; + truncated: boolean; +} + +const tags = new Set(['compound', 'list', 'string', 'byte', 'short', 'int', 'long', 'float', 'double', 'byteArray', 'intArray', 'longArray']); +const record = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) + ? value as Record : {}; +const array = (value: unknown): unknown[] => Array.isArray(value) ? value : []; +const number = (value: unknown, fallback = 0): number => typeof value === 'number' && Number.isFinite(value) ? value : fallback; +const identifier = (value: unknown): string => typeof value === 'string' ? value.replace(/^minecraft:/, '') : ''; + +/** Projects both legacy NBT and modern components; never returns raw tag wrappers or live Item objects. */ +export function parse_inventory_item(input: unknown, slot: number | null, registry: Bot['registry']): InventoryItem | null { + if (!input || typeof input !== 'object') return null; + let truncated = false; + let nodes = 0; + let text_left = 24_576; + const ancestors = new Set(); + const normalize = (value: unknown, depth = 0): ItemValue => { + if (++nodes > 8192 || depth > 32) { truncated = true; return null; } + if (value == null) return null; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'string') { + let text = ''; + for (const character of value) { + const bytes = Buffer.byteLength(character); + if (bytes > text_left) break; + text += character; + text_left -= bytes; + } + if (text !== value) truncated = true; + return text; + } + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'boolean') return value; + if (typeof value !== 'object') { truncated = true; return null; } + if (ancestors.has(value)) { truncated = true; return null; } + ancestors.add(value); + try { + if (Array.isArray(value) || Buffer.isBuffer(value)) { + if (value.length > 1024) truncated = true; + return Array.from(value as unknown[]).slice(0, 1024).map(entry => normalize(entry, depth + 1)); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const type = descriptors.type?.value; + if (typeof type === 'string' && tags.has(type) && descriptors.value && 'value' in descriptors.value + && Object.keys(descriptors).every(key => ['type', 'value', 'name'].includes(key))) { + return normalize(descriptors.value.value, depth + 1); + } + const entries = Object.entries(descriptors).filter(([, descriptor]) => descriptor.enumerable); + if (entries.length > 256) truncated = true; + return Object.fromEntries(entries.slice(0, 256).map(([key, descriptor]) => { + if (!('value' in descriptor)) { truncated = true; return [key, null]; } + return [key, normalize(descriptor.value, depth + 1)]; + })); + } finally { ancestors.delete(value); } + }; + const text = (value: unknown): log[] => { + let component = value; + if (typeof component === 'string' && /^[\s]*[\[{\"]/.test(component)) { + try { component = JSON.parse(component); } catch { /* Plain item names need not be JSON. */ } + } + const logs = MinecraftJsonParser.toLog(component as Parameters[0], false); + const colors = ['#000000', '#0000AA', '#00AA00', '#00AAAA', '#AA0000', '#AA00AA', '#FFAA00', '#AAAAAA', + '#555555', '#5555FF', '#55FF55', '#55FFFF', '#FF5555', '#FF55FF', '#FFFF55', '#FFFFFF']; + return logs.flatMap(segment => { + let color = segment.font_color; + let styles = segment.font_style.filter(style => style !== 'normal'); + const result: log[] = []; + const pieces = String(segment.message).split(/(§[0-9a-fk-or])/gi); + for (const piece of pieces) { + if (/^§[0-9a-fk-or]$/i.test(piece)) { + const code = piece[1].toLowerCase(); + const color_index = '0123456789abcdef'.indexOf(code); + if (color_index >= 0) { color = colors[color_index]; styles = []; } + else if (code === 'r') { color = '#FFFFFF'; styles = []; } + else { + const style = ({l: 'bold', o: 'italic', n: 'underline'} as const)[code as 'l' | 'o' | 'n']; + if (style && !styles.includes(style)) styles = [...styles, style]; + } + } else if (piece) result.push({message: piece, font_color: color, font_style: styles.length ? [...styles] : ['normal']}); + } + return result; + }); + }; + const parse = (raw: unknown, item_slot: number | null, nesting: number): InventoryItem | null => { + const item = record(raw); + const raw_id = item.type ?? item.itemId ?? item.id; + const lookup = typeof raw_id === 'string' ? registry?.itemsByName?.[identifier(raw_id)] + : registry?.items?.[number(raw_id, -1)]; + const type = typeof raw_id === 'number' ? raw_id : lookup?.id; + const count = number(item.count ?? item.itemCount ?? item.Count, 1); + if (type == null || type < 0 || count <= 0) return null; + const legacy = record(normalize(item.nbt ?? item.tag)); + const raw_components = new Map(); + for (const entry of array(item.components)) { + const component = record(entry); + const key = identifier(component.type); + if (key) raw_components.set(key, component.data); + } + // Item setters can update componentMap before rebuilding the components array. + if (item.componentMap instanceof Map) for (const [key, component] of item.componentMap) { + raw_components.set(identifier(key), record(component).data); + } + const components = Object.fromEntries(Array.from(raw_components, ([key, value]) => [key, normalize(value)])); + const display = record(legacy.display); + const name = typeof item.name === 'string' ? item.name : lookup?.name ?? (identifier(raw_id) || 'unknown'); + let displayName: log[] = []; + for (const candidate of [components.custom_name, display.Name, components.item_name, item.displayName, lookup?.displayName, name]) { + if (candidate == null) continue; + displayName = text(candidate); + if (displayName.length) break; + } + const lore = array(components.lore ?? display.Lore).slice(0, 128).map(text); + if (array(components.lore ?? display.Lore).length > 128) truncated = true; + const flags = number(legacy.HideFlags); + const tooltip = record(components.tooltip_display); + const hidden_components = array(tooltip.hiddenComponents); + const component_types = registry?.protocol?.types?.SlotComponentType as unknown as [string, {mappings?: Record}] | undefined; + const hidden_names = hidden_components.map(value => typeof value === 'number' + ? component_types?.[1]?.mappings?.[String(value)] ?? value : identifier(value)); + const hidden_tooltip = Object.hasOwn(components, 'hide_tooltip') || tooltip.hideTooltip === true; + const enchants: InventoryItem['enchants'] = []; + const enchant_sources = [ + {source: components.enchantments ?? legacy.Enchantments ?? legacy.ench, hidden: Boolean(flags & 1)}, + {source: components.stored_enchantments ?? legacy.StoredEnchantments, hidden: Boolean(flags & 32)}, + ]; + for (const {source, hidden} of enchant_sources) { + const data = record(source); + const entries = Array.isArray(source) ? source : Array.isArray(data.enchantments) ? data.enchantments + : Object.entries(record(data.levels)).map(([id, level]) => ({id, level})); + for (const entry of entries) { + const enchant = record(entry); + const raw_id = enchant.id ?? enchant.name; + const id = typeof raw_id === 'number' ? raw_id : undefined; + const name = typeof raw_id === 'string' ? identifier(raw_id) : registry?.enchantments?.[id ?? -1]?.name; + enchants.push({...(id === undefined ? {} : {id}), ...(name ? {name} : {}), + i18n: [{lang: 'zh-CN', name: (zhCn as Record)[`enchantment.minecraft.${name}`] + || name || String(id ?? 'unknown'), description: ''}], + level: number(enchant.level ?? enchant.lvl), + hidden: hidden_tooltip || hidden || hidden_names.includes('enchantments') || hidden_names.includes('stored_enchantments') + || data.showTooltip === false || data.showInTooltip === false}); + } + } + const max = number(components.max_damage ?? item.maxDurability ?? lookup?.maxDurability); + let used = number(components.damage ?? legacy.Damage ?? item.metadata); + if (!Object.hasOwn(components, 'damage') && legacy.Damage == null && max > 0) { + try { used = number(item.durabilityUsed, used); } catch { truncated = true; } + } + const book_data = record(components.written_book_content ?? components.writable_book_content + ?? (['written_book', 'writable_book'].includes(name) ? legacy : null)); + const written = name === 'written_book' || Object.hasOwn(components, 'written_book_content'); + const book = Array.isArray(book_data.pages) ? { + title: String(book_data.rawTitle ?? book_data.title ?? ''), author: String(book_data.author ?? ''), + pages: book_data.pages.slice(0, 100).map(page => { + const content = record(page).content ?? page; + // Writable books contain literal text, even when a page looks like JSON. + return written ? text(content) : text({text: String(content ?? '')}); + }), + } : null; + if (array(book_data.pages).length > 100) truncated = true; + const details: Record = {}; + if (components.potion_contents != null || legacy.Potion != null || legacy.CustomPotionEffects != null) { + const potion = record(components.potion_contents); + details.potion = { + type: (potion.potionId ?? legacy.Potion ?? null) as ItemValue, + color: (potion.customColor ?? legacy.CustomPotionColor ?? null) as ItemValue, + effects: array(potion.customEffects ?? legacy.CustomPotionEffects).map(entry => { + const effect = record(entry); + const id = effect.id ?? effect.Id; + const data = record(effect.details ?? effect); + const name = typeof id === 'string' ? identifier(id) : registry?.effects?.[number(id, -1)]?.name; + return {id: id as ItemValue ?? null, name: name ?? null, amplifier: number(data.amplifier ?? data.Amplifier), + duration: number(data.duration ?? data.Duration), ambient: Boolean(data.ambient ?? data.Ambient), + showParticles: (data.showParticles ?? data.ShowParticles ?? true) as ItemValue}; + }), + }; + } + if (components.attribute_modifiers != null || legacy.AttributeModifiers != null) { + const modifiers = record(components.attribute_modifiers); + details.attributes = {hidden: hidden_tooltip || Boolean(flags & 2) || hidden_names.includes('attribute_modifiers') || modifiers.showTooltip === false, + modifiers: array(Array.isArray(components.attribute_modifiers) ? components.attribute_modifiers : modifiers.attributes ?? legacy.AttributeModifiers).map(entry => { + const attribute = record(entry); + return { + type: (attribute.typeId ?? attribute.type ?? attribute.AttributeName ?? null) as ItemValue, + name: String(attribute.name ?? attribute.Name ?? attribute.id ?? ''), + amount: number(attribute.value ?? attribute.amount ?? attribute.Amount), + operation: (attribute.operation ?? attribute.Operation ?? null) as ItemValue, + slot: (attribute.slot ?? attribute.Slot ?? null) as ItemValue, + hidden: record(attribute.display).type === 'hidden', + displayName: text(record(attribute.display).component) as unknown as ItemValue, + }; + })}; + } + const nested = (entries: unknown[], preserve_slot: boolean): ItemValue[] => { + if (nesting >= 4) { truncated = true; return []; } + if (entries.length > 256) truncated = true; + return entries.slice(0, 256).map((entry, index) => { + const wrapper = record(entry); + const raw = wrapper.item ?? entry; + return parse(raw, preserve_slot ? number(wrapper.slot ?? wrapper.Slot, index) : null, nesting + 1) as unknown as ItemValue; + }); + }; + const container = components.container ?? record(legacy.BlockEntityTag).Items; + if (container != null) details.container = nested(array(record(container).contents ?? container), true); + if (components.bundle_contents != null || name === 'bundle' && legacy.Items != null) { + const contents = components.bundle_contents ?? legacy.Items; + details.bundle = nested(array(record(contents).contents ?? contents), false); + } + const known_components = new Set(['custom_name', 'item_name', 'lore', 'enchantments', 'stored_enchantments', 'damage', 'max_damage', + 'unbreakable', 'enchantment_glint_override', 'written_book_content', 'writable_book_content', 'potion_contents', 'attribute_modifiers', + 'container', 'bundle_contents', 'tooltip_display', 'hide_tooltip', 'hide_additional_tooltip']); + const known_legacy = new Set(['display', 'Enchantments', 'ench', 'StoredEnchantments', 'Damage', 'Unbreakable', 'HideFlags', + 'Potion', 'CustomPotionEffects', 'CustomPotionColor', 'AttributeModifiers']); + if (book) for (const key of ['pages', 'title', 'author']) known_legacy.add(key); + if (name === 'bundle') known_legacy.add('Items'); + const extra_legacy = Object.fromEntries(Object.entries(legacy).filter(([key]) => !known_legacy.has(key))); + if (container != null) { + const block = {...record(extra_legacy.BlockEntityTag)}; + delete block.Items; + if (Object.keys(block).length) extra_legacy.BlockEntityTag = block; + else delete extra_legacy.BlockEntityTag; + } + const extra_display = Object.fromEntries(Object.entries(display).filter(([key]) => !['Name', 'Lore'].includes(key))); + if (Object.keys(extra_display).length) extra_legacy.display = extra_display; + const extra: Record = Object.fromEntries(Object.entries(components).filter(([key]) => !known_components.has(key))); + if (Object.keys(extra_legacy).length) extra.legacy = extra_legacy; + if (Object.keys(extra).length) details.extra = extra as ItemValue; + if (flags || hidden_tooltip || Object.hasOwn(components, 'hide_additional_tooltip') || hidden_components.length) { + details.tooltip = {hideAll: hidden_tooltip, hideAdditional: Object.hasOwn(components, 'hide_additional_tooltip'), + hiddenComponents: hidden_names as ItemValue[], legacyFlags: flags}; + } + return { + slot: item_slot, type, name, count, metadata: number(item.metadata), displayName, lore, enchants, + i18n: [{lang: 'zh-CN', name: (zhCn as Record)[`block.minecraft.${name}`] + || (zhCn as Record)[`item.minecraft.${name}`] || name, description: ''}], + durability: max > 0 ? {used, max, remaining: Math.max(0, max - used)} : null, + unbreakable: Object.hasOwn(components, 'unbreakable') ? components.unbreakable !== false : Boolean(legacy.Unbreakable), + glint: typeof components.enchantment_glint_override === 'boolean' ? components.enchantment_glint_override : null, + book, details, truncated, + }; + }; + const result = parse(input, slot, 0); + if (result) result.truncated = truncated; + return result; +} diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..abd861d --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,417 @@ +import { WriteStream } from "fs"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import {log, font_style} from "../types/utils/logger"; + +export interface LogHistoryRecord { + id: string; + timestamp: number; + log: log[]; +} + +export interface _Logger { + info(msg: string): void; + success(msg: string): void; + error(msg: string): void; + warning(msg: string): void; + tip(msg: string): void; + custom(msg: string, font_color: string, font_style: font_style[]): void; + log(log: log[]): void; + get_recent_log(count: number): log[][]; + read_recent_log(count: number): Promise; + close(): Promise; +} + +export class Logger implements _Logger { + // 单个日志文件的最大字节数(50M) + private static readonly MAX_FILE_SIZE = 50 * 1024 * 1024; + // 同一目录下保留的最大日志文件数 + private static readonly MAX_FILES = 10; + // 日志文件名格式:year-month-day_log.log / year-month-day_log_{index}.log + private static readonly LOG_FILE_REGEX = /^\d{4}-\d{2}-\d{2}_log(_\d+)?\.log$/; + // 重建写入流的最大失败次数与重试延时 + private static readonly MAX_RECREATE_FAILURES = 10; + private static readonly RECREATE_DELAY = 1000; + + private readonly log_dir: string; + private writer!: WriteStream; + // 当前日志所属日期(year-month-day) + private day = ''; + // 当天的文件序号,0 为当天首个文件 + private index = 0; + private size = 0; + private write_queue: string[] = []; + private in_flight_chunk: string | undefined; + private writing = false; + private recreate_failures = 0; + private recreate_timer: NodeJS.Timeout | null = null; + private stopped = false; + private close_task: Promise | undefined; + constructor(log_dir: string) { + this.log_dir = log_dir; + this.day = this.format_day(new Date()); + this.open_writer(); + } + + private bind_writer_error(): void { + this.writer.on('error', (err: NodeJS.ErrnoException) => { + this.writing = false; + // 仅处理文件不存在,重建写入流;其他错误交由后续写入重试 + if (err.code === 'ENOENT') { + this.schedule_recreate(); + } + }); + } + + // 合并并发错误,避免同一时刻重复创建 + private schedule_recreate(): void { + if (this.stopped || this.recreate_timer !== null) { + return; + } + this.recreate_timer = setTimeout(() => { + this.recreate_timer = null; + this.recreate_writer(); + }, Logger.RECREATE_DELAY); + this.recreate_timer.unref(); + } + + // 重新创建写入流;累计失败达到上限后停止创建 + private recreate_writer(): void { + if (this.stopped) { + return; + } + this.recreate_failures++; + if (this.recreate_failures >= Logger.MAX_RECREATE_FAILURES) { + this.stopped = true; + return; + } + try { + this.open_writer(); + this.recreate_failures = 0; + this.flush(); + } catch { + // 创建失败,排到下一次重试 + this.schedule_recreate(); + } + } + + private format_day(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + // 当天首个文件为 year-month-day_log.log,超出大小后追加序号 + private build_filename(day: string, index: number): string { + return index === 0 ? `${day}_log.log` : `${day}_log_${index}.log`; + } + + // 打开(或创建)当前应写入的文件,并接管已有大小 + private open_writer(): void { + if (!fs.existsSync(this.log_dir)) { + fs.mkdirSync(this.log_dir, { recursive: true }); + } + const filename = this.build_filename(this.day, this.index); + const file_path = path.join(this.log_dir, filename); + try { + this.size = fs.statSync(file_path).size; + } catch { + this.size = 0; + } + const broken = this.writer; + if (broken) { + try { + broken.destroy(); + } catch { + // 忽略已销毁流抛出的异常 + } + } + this.writer = fs.createWriteStream(file_path, {flags: 'a'}); + this.bind_writer_error(); + this.prune_files(filename); + } + + // 超出保留数量时删除最旧的日志文件(文件名按时间递增,保留当前文件) + private prune_files(keep_filename: string): void { + let entries: string[]; + try { + entries = fs.readdirSync(this.log_dir) + .filter(name => Logger.LOG_FILE_REGEX.test(name)) + .sort(); + } catch { + return; + } + let excess = entries.length - Logger.MAX_FILES; + for (const name of entries) { + if (excess <= 0) break; + if (name === keep_filename) continue; + try { + fs.rmSync(path.join(this.log_dir, name)); + excess--; + } catch { + // 删除失败则跳过,保留文件 + } + } + } + + // 跨天或写满时切换到新文件 + private ensure_writer(): void { + const today = this.format_day(new Date()); + if (today !== this.day) { + this.day = today; + this.index = 0; + this.open_writer(); + return; + } + if (this.size >= Logger.MAX_FILE_SIZE) { + this.index++; + this.open_writer(); + } + } + + public info(message: any): log { // 白色 + return { + message, + font_color: '#ffffff', + font_style: ['normal'] + }; + } + + public success(message: any): log { // 柔和绿 + return { + message, + font_color: '#8fb98f', + font_style: ['normal'] + }; + } + + public warning(message: any): log { // 柔和沙黄 + return { + message, + font_color: '#d1b478', + font_style: ['normal'] + }; + } + + public error(message: any): log { // 柔和玫红 + return { + message, + font_color: '#cf8e8e', + font_style: ['normal'] + }; + } + + public tip(message: any): log { // 柔和钢蓝 + return { + message, + font_color: '#8fb0c9', + font_style: ['normal'] + }; + } + + public custom(message: any, font_color: string, font_style: font_style[]): log { + return { + message, + font_color, + font_style + }; + } + + public log(log: log[]): void { + if (this.stopped || log.length === 0) { + return; + } + const data = { + id: randomUUID(), + timestamp: Date.now(), + log + } + this.write_queue.push(`${JSON.stringify(data)}\n`); + this.flush(); + } + + // 串行写入:上一条落盘后才写下一批,保证大量日志的顺序 + private flush(): void { + if (this.writing || this.stopped) { + return; + } + const chunk = this.write_queue.shift(); + if (chunk === undefined) { + return; + } + this.ensure_writer(); + this.writing = true; + this.in_flight_chunk = chunk; + this.writer.write(chunk, (err) => { + if (this.in_flight_chunk === chunk) this.in_flight_chunk = undefined; + this.writing = false; + if (err) { + if (this.stopped) return; + // 写入失败则回队,等待重建写入流后重试,避免丢日志 + this.write_queue.unshift(chunk); + return; + } + this.size += Buffer.byteLength(chunk); + this.flush(); + }); + } + + public close(): Promise { + if (this.close_task) return this.close_task; + this.stopped = true; + if (this.recreate_timer) clearTimeout(this.recreate_timer); + this.recreate_timer = null; + this.close_task = new Promise(resolve => { + if (this.writer.closed) { + resolve(); + return; + } + this.writer.once('close', () => { + this.write_queue.length = 0; + resolve(); + }); + this.writer.end(this.write_queue.join('')); + }); + return this.close_task; + } + + private static parse_history_record(line: string): LogHistoryRecord | undefined { + try { + const record = JSON.parse(line); + if (record && typeof record.id === 'string' && + typeof record.timestamp === 'number' && Number.isFinite(record.timestamp) && + Array.isArray(record.log)) { + return record; + } + } catch { + // Ignore corrupt or incomplete records. + } + return undefined; + } + + public static async read_recent_log(log_dir: string, count: number): Promise { + if (!Number.isFinite(count) || count < 1) return []; + count = Math.floor(count); + let names: string[]; + try { + names = (await fs.promises.readdir(log_dir)) + .filter(name => Logger.LOG_FILE_REGEX.test(name)) + .sort((a, b) => { + const date_order = a.slice(0, 10).localeCompare(b.slice(0, 10)); + return date_order || Number(a.match(/_(\d+)\.log$/)?.[1] ?? 0) - + Number(b.match(/_(\d+)\.log$/)?.[1] ?? 0); + }).reverse(); + } catch { + return []; + } + const records: LogHistoryRecord[] = []; + const max_line_bytes = 1024 * 1024; + let budget = 16 * max_line_bytes; + for (const name of names) { + if (records.length >= count || budget <= 0) break; + let file: fs.promises.FileHandle | undefined; + try { + file = await fs.promises.open(path.join(log_dir, name), 'r'); + let position = (await file.stat()).size; + let pending = Buffer.alloc(0); + let skipping = false; + const accept = (line: Buffer) => { + if (line.length > max_line_bytes) return; + const record = Logger.parse_history_record(line.toString('utf8')); + if (record) records.push(record); + }; + while (position > 0 && records.length < count && budget > 0) { + const length = Math.min(position, 64 * 1024, budget); + position -= length; + budget -= length; + const chunk = Buffer.allocUnsafe(length); + const {bytesRead} = await file.read(chunk, 0, length, position); + const data = Buffer.concat([chunk.subarray(0, bytesRead), pending]); + let end = data.length; + let newline: number; + while ((newline = data.lastIndexOf(10, end - 1)) >= 0 && end > 0) { + if (!skipping) accept(data.subarray(newline + 1, end)); + skipping = false; + end = newline; + if (records.length >= count) break; + } + // Discard oversized lines until their preceding newline is found. + if (skipping || end > max_line_bytes) { + pending = Buffer.alloc(0); + skipping = true; + } else { + pending = Buffer.from(data.subarray(0, end)); + } + } + if (position === 0 && !skipping && records.length < count) accept(pending); + } catch { + // Rotation or removal can race a history read; continue with older files. + } finally { + if (file) await file.close(); + } + } + return records.reverse(); + } + + public async read_recent_log(count: number): Promise { + if (!Number.isFinite(count) || count < 1) return []; + count = Math.floor(count); + const snapshot = [this.in_flight_chunk, ...this.write_queue] + .filter((chunk): chunk is string => chunk !== undefined) + .map(chunk => Logger.parse_history_record(chunk)) + .filter((record): record is LogHistoryRecord => record !== undefined); + const snapshot_ids = new Set(snapshot.map(record => record.id)); + const records = (await Logger.read_recent_log(this.log_dir, count)) + .filter(record => !snapshot_ids.has(record.id)); + const ids = new Set(records.map(record => record.id)); + for (const record of snapshot) { + if (ids.has(record.id)) continue; + ids.add(record.id); + records.push(record); + } + return records.slice(-count); + } + + // 读取最近的 count 条日志,跨文件时自动按时间顺序拼接 + public get_recent_log(count: number): log[][] { + if (count <= 0) { + return []; + } + let names: string[]; + try { + names = fs.readdirSync(this.log_dir) + .filter(name => Logger.LOG_FILE_REGEX.test(name)) + .sort() + .reverse(); + } catch { + return []; + } + const lines: string[] = []; + for (const name of names) { + let content: string; + try { + content = fs.readFileSync(path.join(this.log_dir, name), 'utf-8'); + } catch { + continue; + } + const file_lines = content.split('\n').filter(line => line.length > 0); + for (let i = file_lines.length - 1; i >= 0 && lines.length < count; i--) { + lines.push(file_lines[i]); + } + if (lines.length >= count) { + break; + } + } + // 收集顺序为从新到旧,反转恢复为时间正序 + lines.reverse(); + return lines.map(line => { + try { + return JSON.parse(line); + } catch { + return line; + } + }); + } +} diff --git a/utils/message_json_parser.ts b/utils/message_json_parser.ts new file mode 100644 index 0000000..2a5ada4 --- /dev/null +++ b/utils/message_json_parser.ts @@ -0,0 +1,291 @@ +import zhCn from '../data/runtime/language/zh_cn.json'; +import type {font_style, log} from '../types/utils/logger'; + +type TextComponent = MinecraftTextComponent | NbtTag | string | number | boolean | TextComponent[]; +interface NbtTag { + type: string; + value: unknown; +} +type Format = 'bold' | 'italic' | 'underlined' | 'strikethrough' | 'obfuscated'; +const formats: Format[] = ['bold', 'italic', 'underlined', 'strikethrough', 'obfuscated']; +const maxDepth = 64; + +interface MinecraftTextComponent { + text?: string; + translate?: string; + fallback?: string; + with?: TextComponent[]; + color?: string; + bold?: boolean | number; + italic?: boolean | number; + underlined?: boolean | number; + strikethrough?: boolean | number; + obfuscated?: boolean | number; + extra?: TextComponent[]; + hoverEvent?: HoverEvent; + hover_event?: HoverEvent; + clickEvent?: ClickEvent; + click_event?: ClickEvent; + insertion?: string; + font?: string; + json?: TextComponent; + warn?: Function; +} + +interface HoverEvent { + action: 'show_text' | 'show_item' | 'show_entity'; + contents?: TextComponent | Record; + value?: TextComponent | Record; +} + +interface ClickEvent { + action: 'open_url' | 'open_file' | 'run_command' | 'suggest_command' | 'change_page' | 'copy_to_clipboard' | 'show_dialog' | 'custom'; + value?: string; + command?: string; + url?: string; + page?: string | number; + path?: string; + dialog?: string; + id?: string; + payload?: string; + text?: string; +} + +interface ParsedMessage { + username: string; + message: string; + log: log[]; + normalized: MinecraftTextComponent | null; + plainText: string; +} + +const colorMap: Record = { + black: '#000000', dark_blue: '#0000AA', dark_green: '#00AA00', dark_aqua: '#00AAAA', + dark_red: '#AA0000', dark_purple: '#AA00AA', gold: '#FFAA00', gray: '#AAAAAA', + dark_gray: '#555555', blue: '#5555FF', green: '#55FF55', aqua: '#55FFFF', + red: '#FF5555', light_purple: '#FF55FF', yellow: '#FFFF55', white: '#FFFFFF' +}; + +/** Minecraft / prismarine-chat 文本组件解析器。 */ +export class MinecraftJsonParser { + static parse(jsonData: TextComponent | null | undefined): ParsedMessage { + const normalized = this.normalizeJson(jsonData); + const entries = this.toLog(normalized); + const plainText = entries.map(entry => entry.message).join(''); + return { + ...this.parseUsernameAndMessage(plainText), + plainText, + log: entries, + normalized + }; + } + + static normalizeJson(node: TextComponent | null | undefined, depth = 0): MinecraftTextComponent | null { + if (node == null || depth > maxDepth) return null; + if (typeof node !== 'object') return {text: String(node)}; + if (!Array.isArray(node) && 'type' in node && 'value' in node) { + const unwrap = (tag: unknown, level: number): unknown => { + if (level > maxDepth || tag == null) return null; + if (Array.isArray(tag)) return tag.map(value => unwrap(value, level + 1)); + if (typeof tag !== 'object') return tag; + const value = tag as NbtTag; + if (value.type === 'compound' && value.value && typeof value.value === 'object') { + return Object.fromEntries(Object.entries(value.value).map(([key, child]) => [key, unwrap(child, level + 1)])); + } + if (value.type === 'list' && value.value && typeof value.value === 'object') { + const list = value.value as {type: string; value: unknown[]}; + return Array.isArray(list.value) ? list.value.map(child => unwrap({type: list.type, value: child}, level + 1)) : []; + } + if ('value' in value) return value.value; + return tag; + }; + return this.normalizeJson(unwrap(node, depth) as TextComponent, depth + 1); + } + if (Array.isArray(node)) { + // Minecraft 数组中的后续组件继承第一个组件的样式。 + const [first, ...rest] = node; + const root = this.normalizeJson(first, depth + 1); + if (!root) return null; + const extra = rest.map(child => this.normalizeJson(child, depth + 1)) + .filter((child): child is MinecraftTextComponent => child !== null); + if (extra.length) root.extra = [...(root.extra ?? []), ...extra]; + return root; + } + + // ChatMessage 的原始协议组件保留了 font、事件和完整的 Unicode 文本。 + if (node.json != null && typeof node.warn === 'function') { + return this.normalizeJson(node.json, depth + 1); + } + + const result: MinecraftTextComponent = {}; + if (node.text !== undefined) result.text = String(node.text ?? ''); + if (node.translate !== undefined) result.translate = node.translate; + if (node.fallback !== undefined) result.fallback = node.fallback; + if (node.color) result.color = node.color; + if (node.font) result.font = node.font; + for (const format of formats) { + // false / 0 必须保留,用于关闭父组件继承下来的样式。 + if (node[format] !== undefined) result[format] = !!node[format]; + } + const hoverEvent = this.extractHoverEvent(node); + if (hoverEvent) result.hoverEvent = hoverEvent; + const clickEvent = this.extractClickEvent(node); + if (clickEvent) result.clickEvent = clickEvent; + const insertion = node.insertion ?? (node.json as MinecraftTextComponent)?.insertion; + if (insertion !== undefined) result.insertion = insertion; + + if (Array.isArray(node.with)) { + result.with = node.with.map(child => this.normalizeJson(child, depth + 1) ?? {text: ''}); + } + if (Array.isArray(node.extra)) { + result.extra = node.extra.map(child => this.normalizeJson(child, depth + 1)) + .filter((child): child is MinecraftTextComponent => child !== null); + } + return Object.keys(result).length ? result : null; + } + + static extractHoverEvent(node: MinecraftTextComponent): HoverEvent | undefined { + const raw = node.json as MinecraftTextComponent | undefined; + return node.hoverEvent ?? node.hover_event ?? raw?.hoverEvent ?? raw?.hover_event; + } + + static extractClickEvent(node: MinecraftTextComponent): ClickEvent | undefined { + const raw = node.json as MinecraftTextComponent | undefined; + const event = node.clickEvent ?? node.click_event ?? raw?.clickEvent ?? raw?.click_event; + if (!event || typeof event.action !== 'string') return undefined; + const value = event.value ?? (() => { + switch (event.action) { + case 'open_url': return event.url; + case 'run_command': + case 'suggest_command': return event.command; + case 'change_page': return event.page; + case 'open_file': return event.path; + case 'show_dialog': return event.dialog; + case 'custom': return event.payload ?? event.id; + case 'copy_to_clipboard': return event.text; + default: return undefined; + } + })(); + if (typeof value === 'string') return {action: event.action, value}; + if (typeof value === 'number' && Number.isFinite(value)) return {action: event.action, value: String(value)}; + return undefined; + } + + static expandTranslate(node: MinecraftTextComponent): MinecraftTextComponent | null { + if (node.translate === undefined) return null; + const template = (zhCn as Record)[node.translate] ?? node.fallback ?? node.translate; + const params = node.with ?? []; + const extra: TextComponent[] = []; + let lastIndex = 0; + let seqIndex = 0; + for (const match of template.matchAll(/%%|%(?:(\d+)\$)?s/g)) { + if (match.index > lastIndex) extra.push({text: template.slice(lastIndex, match.index)}); + if (match[0] === '%%') { + extra.push({text: '%'}); + } else { + const index = match[1] ? Number(match[1]) - 1 : seqIndex++; + extra.push(params[index] ?? {text: match[0]}); + } + lastIndex = match.index + match[0].length; + } + if (lastIndex < template.length) extra.push({text: template.slice(lastIndex)}); + // 在同一父组件下展开,保留事件、样式继承以及翻译文本后的 extra。 + const {translate, fallback, with: withParams, ...base} = node; + return {...base, text: '', extra: [...extra, ...(node.extra ?? [])]}; + } + + static extractPlainText(node: TextComponent | null | undefined): string { + const visit = (component: MinecraftTextComponent | null, depth: number): string => { + if (!component || depth > maxDepth) return ''; + const current = component.translate !== undefined ? this.expandTranslate(component)! : component; + return (current.text ?? '') + (current.extra ?? []) + .map(child => visit(child as MinecraftTextComponent, depth + 1)).join(''); + }; + return visit(this.normalizeJson(node), 0); + } + + static parseUsernameAndMessage(plainText: string): {username: string; message: string} { + const text = plainText.trim(); + const vanilla = text.match(/^<([^>]+)>\s*([\s\S]*)$/); + if (vanilla) return {username: vanilla[1], message: vanilla[2]}; + const arrowIndex = text.indexOf('»'); + if (arrowIndex === -1) return {username: '', message: text}; + const beforeArrow = text.slice(0, arrowIndex).trim(); + const username = beforeArrow.match(/(\S+)\s*$/)?.[1] ?? ''; + return {username, message: text.slice(arrowIndex + 1).trim()}; + } + + /** 返回可直接传给 Logger.log() 的日志片段。 */ + static toLog(node: TextComponent | null | undefined, includeEvents = true): log[] { + const result: log[] = []; + const visit = (component: MinecraftTextComponent | null, inherited: MinecraftTextComponent, depth: number): void => { + if (!component || depth > maxDepth) return; + const current = component.translate !== undefined ? this.expandTranslate(component)! : component; + const style: MinecraftTextComponent = { + color: current.color ?? inherited.color, + clickEvent: current.clickEvent ?? inherited.clickEvent, + hoverEvent: current.hoverEvent ?? inherited.hoverEvent, + insertion: current.insertion ?? inherited.insertion + }; + for (const format of formats) style[format] = current[format] ?? inherited[format]; + if (current.text) { + const font_style: font_style[] = []; + if (style.bold) font_style.push('bold'); + if (style.italic) font_style.push('italic'); + if (style.underlined) font_style.push('underline'); + if (style.clickEvent?.action === 'open_url') font_style.push('url'); + if (!font_style.length) font_style.push('normal'); + const font_color = this.normalizeColor(style.color); + const entry: log = {message: current.text, font_color, font_style}; + if (includeEvents && style.clickEvent) entry.clickEvent = {...style.clickEvent} as log['clickEvent']; + if (includeEvents && style.insertion !== undefined) entry.insertion = style.insertion; + if (includeEvents && style.hoverEvent) { + const {action} = style.hoverEvent; + const content = style.hoverEvent.contents ?? style.hoverEvent.value; + if (content !== undefined && ['show_text', 'show_item', 'show_entity'].includes(action)) { + let value: string | undefined; + if (action === 'show_text') value = this.extractPlainText(content as TextComponent); + else { + try { value = typeof content === 'string' ? content : JSON.stringify(content); } + catch { /* Ignore malformed hover payloads without losing message text. */ } + } + if (value !== undefined) { + entry.hoverEvent = {action, value}; + if (action === 'show_text') { + let remaining = 4096; + entry.hoverEvent.segments = this.toLog(content as TextComponent, false).slice(0, 64) + .map(({message, font_color, font_style}) => { + const text = String(message).slice(0, remaining); + remaining -= text.length; + return {message: text, font_color, font_style}; + }).filter(segment => segment.message.length > 0); + } + } + } + } + const previous = result[result.length - 1]; + if (previous && previous.font_color === font_color + && previous.font_style.join(',') === font_style.join(',') + && JSON.stringify(previous.hoverEvent) === JSON.stringify(entry.hoverEvent) + && JSON.stringify(previous.clickEvent) === JSON.stringify(entry.clickEvent) + && previous.insertion === entry.insertion) { + previous.message += current.text; + } else { + result.push(entry); + } + } + for (const child of current.extra ?? []) visit(child as MinecraftTextComponent, style, depth + 1); + }; + visit(this.normalizeJson(node), {}, 0); + return result; + } + + static normalizeColor(color: string | undefined): string { + if (color && /^#[\da-f]{6}$/i.test(color)) return color; + return color && Object.hasOwn(colorMap, color) ? colorMap[color] : '#FFFFFF'; + } + + static debug(jsonData: TextComponent | null | undefined): MinecraftTextComponent | null { + return this.normalizeJson(jsonData); + } +} diff --git a/utils/path_utils.ts b/utils/path_utils.ts new file mode 100755 index 0000000..6296d3a --- /dev/null +++ b/utils/path_utils.ts @@ -0,0 +1,17 @@ +import * as path from 'path' +import { fileURLToPath } from 'url' +import * as fs from "node:fs"; + +export function get_project_root_path() { + // @ts-ignore + const __filename = fileURLToPath(import.meta.url) + const __dirname = path.dirname(__filename) + return path.resolve(__dirname, '..') +} + + +export function get_path_dir_list(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }) + .filter(item => item.isDirectory()) + .map(item => path.join(dir, item.name)); +} \ No newline at end of file diff --git a/utils/plugin_event_handler.ts b/utils/plugin_event_handler.ts new file mode 100644 index 0000000..d4a3f5e --- /dev/null +++ b/utils/plugin_event_handler.ts @@ -0,0 +1,46 @@ +import type {Socket} from 'socket.io-client'; +import {get_bot_context} from '../bot/instance/index.ts'; +import {PluginError, type PluginErrorReason} from '../bot/plugins/error.ts'; +import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from './plugin_validation.ts'; +import {validate_code_map} from './validate_code_map.ts'; + +const codes = validate_code_map.plugin; +const errors: Record = { + INVALID_CONFIG: codes.invalid_config, ALREADY_INSTALLED: codes.already_installed, + NOT_INSTALLED: codes.not_installed, MANAGER_DISPOSED: codes.manager_disposed, + LOAD_FAILED: codes.load_failed, INIT_FAILED: codes.init_failed, DISPOSE_FAILED: codes.dispose_failed, +}; + +export async function plugin_event_handler(operation: 'inject' | 'uninject' | 'update_config', socket: Socket, data: unknown, callback: unknown) { + if (typeof callback !== 'function') { + socket.emit('error', {error: validate_code_map.web.method_not_allowed}); + return; + } + let code: number = validate_code_map.web.invalid_parameter; + try { + const fields = operation === 'inject' ? ['bot_id', 'path', 'name', 'config'] + : operation === 'update_config' ? ['bot_id', 'name', 'config'] : ['bot_id', 'name']; + if (plain_plugin_object(data) && Object.keys(data).length === fields.length + && fields.every(field => Object.hasOwn(data, field)) + && typeof data.bot_id === 'number' && Number.isSafeInteger(data.bot_id) && data.bot_id > 0 + && valid_plugin_name(data.name) + && (operation !== 'inject' || valid_plugin_path(data.path)) + && (operation === 'uninject' || valid_plugin_config(data.config))) { + const context = get_bot_context(data.bot_id); + const manager = context?.plugin_manager; + if (!context) code = codes.context_missing; + else if (!manager) code = codes.manager_missing; + else { + if (operation === 'inject') await manager.inject({name: data.name, path: data.path as string, config: data.config}); + else if (operation === 'uninject') await manager.uninject(data.name); + else await manager.update_config(data.name, data.config as Record); + code = codes.ok; + } + } + } catch (error) { + code = error instanceof PluginError ? errors[error.reason] ?? codes.error : codes.error; + } + // A transport callback can throw; never retry it or expose its exception to the dispatcher. + try { callback({status: code === codes.ok ? 'success' : 'error', data: code}); } + catch {} +} diff --git a/utils/plugin_validation.ts b/utils/plugin_validation.ts new file mode 100644 index 0000000..d541fba --- /dev/null +++ b/utils/plugin_validation.ts @@ -0,0 +1,41 @@ +import {types} from 'node:util'; + +export const valid_plugin_name = (value: unknown): value is string => + typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value); +export const valid_plugin_path = (value: unknown): value is string => + typeof value === 'string' && /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(value); + +export function plain_plugin_object(value: unknown): value is Record { + if (!value || typeof value !== 'object' || types.isProxy(value)) return false; + const prototype = Object.getPrototypeOf(value); + if (!prototype || types.isProxy(prototype)) return false; + const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor'); + // Accept ordinary objects from other realms, but never custom prototypes. + if (Object.getPrototypeOf(prototype) !== null || !constructor + || typeof constructor.value !== 'function' + || types.isProxy(constructor.value) || constructor.value.prototype !== prototype + || Function.prototype.toString.call(constructor.value) !== 'function Object() { [native code] }') return false; + return Reflect.ownKeys(value).every(key => typeof key === 'string' + && Object.getOwnPropertyDescriptor(value, key)?.enumerable + && Object.hasOwn(Object.getOwnPropertyDescriptor(value, key)!, 'value')); +} + +export function valid_plugin_config(value: unknown): value is Record { + if (!plain_plugin_object(value)) return false; + return Object.values(value).every(item => { + if (item === null || typeof item === 'string' || typeof item === 'boolean') return true; + if (typeof item === 'number') return Number.isFinite(item); + if (!Array.isArray(item) || types.isProxy(item)) return false; + const prototype = Object.getPrototypeOf(item); + if (!prototype || types.isProxy(prototype)) return false; + const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor'); + if (!constructor || typeof constructor.value !== 'function' + || types.isProxy(constructor.value) || constructor.value.prototype !== prototype + || Function.prototype.toString.call(constructor.value) !== 'function Array() { [native code] }') return false; + const keys = Reflect.ownKeys(item); + return keys.length === item.length + 1 && keys.every(key => key === 'length' + || typeof key === 'string' && /^(0|[1-9]\d*)$/.test(key) + && Object.hasOwn(Object.getOwnPropertyDescriptor(item, key)!, 'value')) + && Array.from(item).every(value => typeof value === 'string' || typeof value === 'number' && Number.isFinite(value)); + }); +} diff --git a/utils/socket_response_handler.ts b/utils/socket_response_handler.ts new file mode 100644 index 0000000..91544e2 --- /dev/null +++ b/utils/socket_response_handler.ts @@ -0,0 +1,10 @@ +import {SocketResponse} from "../types/utils/socket_response.ts"; +import {console_print} from "./console_print.ts"; + +export function handle_socket_response(response: SocketResponse) :{next: boolean, data?: any} { + if (response.status === "error") { + console_print.log(console_print.error(response.data)); + return {next: false, data: response.data}; + } + return {next: true, data: response.data}; +} \ No newline at end of file diff --git a/utils/token_utils.ts b/utils/token_utils.ts new file mode 100644 index 0000000..5d2aa8e --- /dev/null +++ b/utils/token_utils.ts @@ -0,0 +1,31 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +const IV_LEN = 12; +const TAG_LEN = 16; + +function derive_key(secret: string): Buffer { + return createHash('sha256').update(secret).digest(); +} + +export function encode_token(secret: string, data: unknown): string { + const key = derive_key(secret); + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const plain = Buffer.from(JSON.stringify(data), 'utf8'); + const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ciphertext]).toString('base64url'); +} + +export function decode_token(secret: string, token: string): T { + const key = derive_key(secret); + const raw = Buffer.from(token, 'base64url'); + if (raw.length < IV_LEN + TAG_LEN) throw new Error('invalid token'); + const iv = raw.subarray(0, IV_LEN); + const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN); + const ciphertext = raw.subarray(IV_LEN + TAG_LEN); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return JSON.parse(plain.toString('utf8')) as T; +} diff --git a/utils/validate.ts b/utils/validate.ts new file mode 100755 index 0000000..35b04be --- /dev/null +++ b/utils/validate.ts @@ -0,0 +1,86 @@ +import minecraftProtocol from "@wp2508/minecraft-protocol"; +import type { ProxyOptions } from '@wp2508/mineflayer'; +import { + validate_code_map, + type validate_username_code, + type validate_host_code, + type validate_port_code, + type validate_version_code, + type validate_proxy_code, +} from './validate_code_map'; + +const supportedVersions = minecraftProtocol.supportedVersions; + +const username_code = validate_code_map.username; +const host_code = validate_code_map.host; +const port_code = validate_code_map.port; +const version_code = validate_code_map.version; +const proxy_code = validate_code_map.proxy; + +// 检查 username 是否符合 Minecraft 用户名规则,返回错误码;0 表示通过 +export function validate_username(username: string): validate_username_code { + if (typeof username !== 'string') return username_code.invalid_type; + if (username.length === 0) return username_code.empty; + if (username.length < 3) return username_code.too_short; + if (username.length > 16) return username_code.over_length; + if (!/^[A-Za-z0-9_]+$/.test(username)) return username_code.invalid_char; + return username_code.ok; +} + +const IPV4_REGEX = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const DOMAIN_REGEX = /^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/; + +function is_valid_ipv6(host: string): boolean { + const value = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + if (!value.includes(':')) return false; + try { + const url = new URL(`http://[${value}]`); + return url.hostname.length > 0; + } catch { + return false; + } +} + +// 检查是否为 ip 地址或者域名,返回错误码;0 表示通过 +export function validate_host(host: string): validate_host_code { + if (typeof host !== 'string') return host_code.invalid_type; + if (host.length === 0) return host_code.empty; + if (host.length > 253) return host_code.over_length; + if (IPV4_REGEX.test(host)) return host_code.ok; + if (is_valid_ipv6(host)) return host_code.ok; + if (DOMAIN_REGEX.test(host)) return host_code.ok; + return host_code.invalid_format; +} + +// 检查端口是否在 0-65535 之间,返回错误码;0 表示通过 +export function validate_port(port: number): validate_port_code { + if (typeof port !== 'number') return port_code.invalid_type; + if (Number.isNaN(port)) return port_code.not_number; + if (!Number.isInteger(port)) return port_code.not_integer; + if (port < 0 || port > 65535) return port_code.out_of_range; + return port_code.ok; +} + +// 检查版本是否在版本列表中,返回错误码;0 表示通过 +export function validate_version(version: string): validate_version_code { + if (typeof version !== 'string') return version_code.invalid_type; + if (version.length === 0) return version_code.empty; + if (!supportedVersions.includes(version)) return version_code.unsupported; + return version_code.ok; +} + +const PROXY_TYPES = ['socks5', 'sock5', 'http', 'https'] as const; + +// 检查代理配置是否合理,返回错误码;0 表示通过 +export function validate_proxy(proxy: ProxyOptions): validate_proxy_code { + if (typeof proxy !== 'object' || proxy === null) return proxy_code.invalid_type; + if (!proxy.type) return proxy_code.missing_type; + if (!PROXY_TYPES.includes(proxy.type)) return proxy_code.invalid_type_value; + if (!proxy.host) return proxy_code.missing_host; + if (validate_host(proxy.host) !== host_code.ok) return proxy_code.invalid_host; + if (proxy.port === undefined || proxy.port === null) return proxy_code.missing_port; + if (validate_port(proxy.port) !== port_code.ok) return proxy_code.invalid_port; + if (proxy.username != null && typeof proxy.username !== 'string') return proxy_code.invalid_username; + if (proxy.password != null && typeof proxy.password !== 'string') return proxy_code.invalid_password; + return proxy_code.ok; +} diff --git a/utils/validate_code_map.ts b/utils/validate_code_map.ts new file mode 100755 index 0000000..7ae1156 --- /dev/null +++ b/utils/validate_code_map.ts @@ -0,0 +1,124 @@ +// 校验错误码映射;每个分组的 ok 均为 0,表示校验通过 +export const validate_code_map = { + username: { + ok: 0, + over_length: 400, + too_short: 401, + invalid_char: 402, + invalid_type: 403, + empty: 404, + }, + host: { + ok: 0, + invalid_type: 410, + empty: 411, + over_length: 412, + invalid_ip: 413, + invalid_format: 414, + }, + port: { + ok: 0, + invalid_type: 420, + not_integer: 421, + not_number: 422, + out_of_range: 423, + }, + version: { + ok: 0, + invalid_type: 430, + empty: 431, + unsupported: 432, + }, + proxy: { + ok: 0, + invalid_type: 440, + missing_type: 441, + invalid_type_value: 442, + missing_host: 443, + invalid_host: 444, + missing_port: 445, + invalid_port: 446, + invalid_username: 447, + invalid_password: 448, + }, + bot_status: { + online: 0, + offline: 449, + connecting: 450 + }, + bot_context: { + ok: 0, + instance_created: 460, + instance_end_by_user: 461, + instance_connected: 462, + instance_connecting: 463, + context_created: 464, + processing: 465, + error: 466, + disposed: 467, + cancelled: 468, + }, + forward: { + ok: 0, + invalid_token: 470, + missing_key: 471, + }, + plugin: { + ok: 0, + context_missing: 500, + manager_missing: 501, + invalid_config: 502, + already_installed: 503, + not_installed: 504, + manager_disposed: 505, + load_failed: 506, + init_failed: 507, + dispose_failed: 508, + error: 509, + }, + web: { + ok: 0, + database_error: 480, + method_not_allowed: 481, + invalid_json: 482, + missing_parameter: 483, + invalid_parameter: 484, + not_found: 485, + internal_server_error: 486, + spam: 487, + timeout: 488, + account: { + ok: 0, + invalid_username: 489, + invalid_password: 490, + invalid_register_code: 491, + invalid_email: 492, + invalid_code: 493, + invalid_minecraft_username: 494, + same_username: 495, + same_email: 496, + same_minecraft_username: 497, + not_login: 498, + } + } +} as const; + +type value_of = T[keyof T]; + +export type validate_username_code = value_of; +export type validate_host_code = value_of; +export type validate_port_code = value_of; +export type validate_version_code = value_of; +export type validate_proxy_code = value_of; +export type bot_status_code = value_of; + +// 所有校验错误码的联合类型 +export type validate_code = + | validate_username_code + | validate_host_code + | validate_port_code + | validate_version_code + | validate_proxy_code + | bot_status_code; + +export type bot_operation_code = validate_code | value_of;