From 33f906bb439cdca65635b4924b1d855b03f7ce15 Mon Sep 17 00:00:00 2001 From: nick Date: Mon, 26 Oct 2026 23:28:14 +0800 Subject: [PATCH 1/3] feat(guest-room): add authoritative guest lobby example --- config/guest_room.app.toml | 23 ++ config/guest_room.bus.toml | 21 ++ docs/guides/examples.md | 1 + docs/guides/examples_CN.md | 1 + examples/CMakeLists.txt | 5 + examples/guest_room/CMakeLists.txt | 28 ++ examples/guest_room/README.md | 37 ++ examples/guest_room/README_CN.md | 37 ++ examples/guest_room/guest_room_module.cpp | 291 +++++++++++++++ examples/guest_room/guest_room_module.hpp | 60 +++ examples/guest_room/guest_room_plugin.cpp | 32 ++ examples/guest_room/guest_room_plugin.hpp | 15 + examples/guest_room/guest_room_protocol.hpp | 136 +++++++ examples/guest_room/protocol.md | 21 ++ examples/guest_room/protocol_CN.md | 21 ++ .../resource/meta/config_class.config | 1 + .../resource/meta/entity_class.config | 1 + .../resource/meta/guest_player.config | 5 + examples/guest_room/run.sh | 26 ++ examples/guest_room/stage.cmake | 32 ++ examples/guest_room/wire-vectors.json | 8 + tests/ci/package-dependencies.test.mjs | 7 +- tests/core/CMakeLists.txt | 4 + tests/guest_room/codec.cpp | 161 +++++++++ tests/guest_room/server.cpp | 342 ++++++++++++++++++ vcpkg.json | 6 + 26 files changed, 1319 insertions(+), 3 deletions(-) create mode 100644 config/guest_room.app.toml create mode 100644 config/guest_room.bus.toml create mode 100644 examples/guest_room/CMakeLists.txt create mode 100644 examples/guest_room/README.md create mode 100644 examples/guest_room/README_CN.md create mode 100644 examples/guest_room/guest_room_module.cpp create mode 100644 examples/guest_room/guest_room_module.hpp create mode 100644 examples/guest_room/guest_room_plugin.cpp create mode 100644 examples/guest_room/guest_room_plugin.hpp create mode 100644 examples/guest_room/guest_room_protocol.hpp create mode 100644 examples/guest_room/protocol.md create mode 100644 examples/guest_room/protocol_CN.md create mode 100644 examples/guest_room/resource/meta/config_class.config create mode 100644 examples/guest_room/resource/meta/entity_class.config create mode 100644 examples/guest_room/resource/meta/guest_player.config create mode 100755 examples/guest_room/run.sh create mode 100644 examples/guest_room/stage.cmake create mode 100644 examples/guest_room/wire-vectors.json create mode 100644 tests/guest_room/codec.cpp create mode 100644 tests/guest_room/server.cpp diff --git a/config/guest_room.app.toml b/config/guest_room.app.toml new file mode 100644 index 000000000..93b8b94c5 --- /dev/null +++ b/config/guest_room.app.toml @@ -0,0 +1,23 @@ +[application] +plugin_library_dir = "bin/" +plugin_config_dir = "conf/" + +[[plugins]] +name = "ark_utility_plugin" +config = "" + +[[plugins]] +name = "ark_kernel_plugin" +config = "kernel.plugin.conf" + +[[plugins]] +name = "ark_bus_plugin" +config = "guest_room.bus.toml" + +[[plugins]] +name = "ark_net_plugin" +config = "" + +[[plugins]] +name = "guest_room_plugin" +config = "" diff --git a/config/guest_room.bus.toml b/config/guest_room.bus.toml new file mode 100644 index 000000000..bbaaa56f1 --- /dev/null +++ b/config/guest_room.bus.toml @@ -0,0 +1,21 @@ +[bus] +relations = [] + +[[bus.applications]] +name = "game" +type = 31 + +[[bus.processes]] +bus_id = "1.1.31.1" +server_endpoint = "tcp://127.0.0.1:19100" +intranet_endpoint = "" +max_connections = 32 +threads = 1 + +# Required by the current Bus schema; no registration or external service is used. +[bus.registry] +host = "127.0.0.1" +port = 0 +service_name = "unused" +check_interval = "10s" +check_timeout = "3s" diff --git a/docs/guides/examples.md b/docs/guides/examples.md index 0b69c5471..c8663568c 100644 --- a/docs/guides/examples.md +++ b/docs/guides/examples.md @@ -7,6 +7,7 @@ The bundled examples use capability names. Configure with applications and examp | `examples/timers` / `timers` | `timers_plugin` | Utility timers and module lifecycle; enabled, participates in updates | | `examples/module_lifecycle` / `module_lifecycle` | `module_lifecycle_plugin` | Lifecycle and a small data-list demonstration; enabled, no update override. The callback demonstration remains commented out | | `examples/entity_operations` / `entity_operations` | `entity_operations_plugin` | Kernel entity/data/table operations; enabled, no update override. Startup performs a bounded functional demonstration; the [old timed workload](../../benchmarks/ENTITY_OPERATIONS.md) runs only through an explicit benchmark command | +| `examples/guest_room` / `guest_room` | `guest_room_plugin` | Optional 2D guest lobby with Kernel entities and the real Net transport; enable `ARK_BUILD_GUEST_ROOM_EXAMPLE`. [Build, client protocol and native two-peer verification](../../examples/guest_room/README.md) | | `examples/mysql_sample` / `mysql_sample` | `mysql_sample_plugin` | Native MySQL coroutine example; disabled in the application example list, assembly checked by database tests with SSL enabled | | `examples/redis_sample` / `redis_sample` | `redis_sample_plugin` | Native Redis coroutine example; disabled in the application example list, assembly checked by database tests | | `examples/physics_scene` / `physics_scene` | `physics_scene_plugin` | Disabled; requires unsupported legacy PhysX 3. The checked-in sources and manifest do not establish a runnable integration | diff --git a/docs/guides/examples_CN.md b/docs/guides/examples_CN.md index f87505828..bd86b45a4 100644 --- a/docs/guides/examples_CN.md +++ b/docs/guides/examples_CN.md @@ -7,6 +7,7 @@ | `examples/timers`/`timers` | `timers_plugin` | Utility 定时器与模块生命周期;启用,参与 update | | `examples/module_lifecycle`/`module_lifecycle` | `module_lifecycle_plugin` | 生命周期与少量 data-list 演示;启用,无 update 覆写。回调演示代码仍被注释 | | `examples/entity_operations`/`entity_operations` | `entity_operations_plugin` | Kernel 实体、数据和表操作;启用,无 update 覆写。启动仅做有界功能演示;[旧计时负载](../../benchmarks/ENTITY_OPERATIONS_CN.md)只能通过显式基准命令运行 | +| `examples/guest_room`/`guest_room` | `guest_room_plugin` | 可选二维访客大厅,使用 Kernel 实体与真实 Net 传输;通过 `ARK_BUILD_GUEST_ROOM_EXAMPLE` 启用。[构建、客户端协议与原生双客户端验证](../../examples/guest_room/README_CN.md) | | `examples/mysql_sample`/`mysql_sample` | `mysql_sample_plugin` | 原生 MySQL 协程示例;应用示例列表中仍关闭,开启 SSL 的数据库测试检查其装配 | | `examples/redis_sample`/`redis_sample` | `redis_sample_plugin` | 原生 Redis 协程示例;应用示例列表中仍关闭,数据库测试检查其装配 | | `examples/physics_scene`/`physics_scene` | `physics_scene_plugin` | 关闭,依赖不支持的旧 PhysX 3;保留源码与清单不代表集成可运行 | diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index a50c9aa2b..e94c79451 100755 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -52,3 +52,8 @@ add_subdirectory(entity_operations) #add_subdirectory(http_client) #add_subdirectory(mysql_sample) + +option(ARK_BUILD_GUEST_ROOM_EXAMPLE "Build the optional JSON guest-room client/server example" OFF) +if(ARK_BUILD_GUEST_ROOM_EXAMPLE) + add_subdirectory(guest_room) +endif() diff --git a/examples/guest_room/CMakeLists.txt b/examples/guest_room/CMakeLists.txt new file mode 100644 index 000000000..2ec4c14e5 --- /dev/null +++ b/examples/guest_room/CMakeLists.txt @@ -0,0 +1,28 @@ +find_package(nlohmann_json CONFIG REQUIRED) +BUILD_PLUGIN_MACRO("guest_room_plugin") +set_property(TARGET guest_room_plugin APPEND PROPERTY LINK_LIBRARIES nlohmann_json::nlohmann_json ark::kernel ark::net) + +add_executable(ark_guest_room_codec_tests ../../tests/core/main.cpp ../../tests/guest_room/codec.cpp) +set_property(TARGET ark_guest_room_codec_tests PROPERTY LINK_LIBRARIES ark::net nlohmann_json::nlohmann_json) +add_custom_target(check-guest-room-codec + COMMAND $ "--test-case=*" + DEPENDS ark_guest_room_codec_tests USES_TERMINAL VERBATIM) +target_compile_definitions(ark_guest_room_codec_tests PRIVATE + ARK_GUEST_ROOM_VECTORS="${CMAKE_CURRENT_SOURCE_DIR}/wire-vectors.json") + +if(UNIX) + add_executable(ark_guest_room_server_tests ../../tests/guest_room/server.cpp) + set_property(TARGET ark_guest_room_server_tests PROPERTY LINK_LIBRARIES ark::net nlohmann_json::nlohmann_json) + add_custom_target(check-guest-room-server + COMMAND ${CMAKE_COMMAND} "-DBIN_DIR=$" + "-DRUNTIME_DIR=${CMAKE_CURRENT_BINARY_DIR}/native-runtime" -DHOST=127.0.0.1 -DPORT=19191 + -P "${CMAKE_CURRENT_SOURCE_DIR}/stage.cmake" + COMMAND $ "$" + "${CMAKE_CURRENT_BINARY_DIR}/native-runtime" 19191 + DEPENDS app guest_room_plugin ark_kernel_plugin ark_utility_plugin ark_bus_plugin ark_net_plugin + ark_guest_room_server_tests + USES_TERMINAL VERBATIM) + add_custom_target(check-guest-room DEPENDS check-guest-room-codec check-guest-room-server) +else() + add_custom_target(check-guest-room DEPENDS check-guest-room-codec) +endif() diff --git a/examples/guest_room/README.md b/examples/guest_room/README.md new file mode 100644 index 000000000..aa2e81dff --- /dev/null +++ b/examples/guest_room/README.md @@ -0,0 +1,37 @@ +# Guest room example + +[中文](README_CN.md) + +`guest_room_plugin` runs a small 2D guest lobby through the real ARK launcher, Utility, Kernel, Bus and Net plugins. A joined session owns a `GuestPlayer` Kernel entity. Movement updates the server entity; snapshots read its custom name, position and sequence fields. The sequence uses the supported Kernel custom int64 setter while preserving the protocol's unsigned 32-bit bounds. Net callbacks and all business mutations execute on the launcher owner thread. Login and the default CS/SS framing are unchanged. No Registry, Consul, database, account or persistence service is required. + +## Build and run + +Use the repository's normal native dependencies, including the existing optional `nlohmann_json` CMake package. Enable applications, examples and this optional example in the same build: + +```sh +cmake -S . -B app-build -DARK_BUILD_APPLICATIONS=ON -DARK_BUILD_EXAMPLES=ON -DARK_BUILD_GUEST_ROOM_EXAMPLE=ON +cmake --build app-build --target app guest_room_plugin ark_kernel_plugin ark_utility_plugin ark_bus_plugin ark_net_plugin --parallel 1 +./examples/guest_room/run.sh --bin-dir "$PWD/app-build/bin" --runtime-dir "$PWD/run/guest_room" --host 127.0.0.1 --port 19100 +``` + +Supply the normal dependency toolchain/prefix arguments when configuring; select the `guest-room` vcpkg manifest feature when using vcpkg. Add `--debug` to the run command for a Debug build (`app_d` and `_d` plugins). The script stages a complete matching binary directory, the TOML manifests and the example's metadata. `--host` is a local bind address/hostname; `--port` must be 1..65535. Use a fresh runtime directory. Stop with Ctrl+C. The launcher retains resources and reports a failure if its five-second shutdown deadline is exceeded. + +The equivalent platform-neutral staging command is: + +```sh +cmake -DBIN_DIR=/absolute/app-build/bin -DRUNTIME_DIR=/absolute/run/guest_room -DHOST=127.0.0.1 -DPORT=19100 -P examples/guest_room/stage.cmake +cd /absolute/run/guest_room +./bin/app --busid=1.1.31.1 --name=guest_room --app_conf=conf/guest_room.app.toml --log_conf=conf/log.conf --shutdown_timeout_ms=5000 +``` + +Use `app.exe` / `app_d.exe` on Windows. Connect the C# Godot demo to the same host/port; send movement at about 10 Hz. The client must join again after reconnecting, clear old frame/state buffers and accept a new player ID. Detailed wire fields and errors are in the [protocol](protocol.md); [wire-vectors.json](wire-vectors.json) mirrors the C# test fixture. + +## Native verification + +```sh +cmake --build app-build --target check-guest-room --parallel 1 +``` + +`check-guest-room-codec` checks the shared golden frames, every single split point, coalesced frames, the 4096/4097-byte boundary, invalid magic/flags, fresh parser state and numeric/name bounds using the real Net session parser. On POSIX systems, `check-guest-room-server` launches the real `app`, loads the actual plugin DSOs and exercises two native TCP peers: join, both directions of authoritative movement, stale and malformed requests, bounded errors, disconnect, reduced roster, fresh rejoin, invalid-frame close and normal process shutdown. It stages an isolated runtime and uses local port 19191; run one instance at a time. The process test uses POSIX child-process control and is not a Windows runtime acceptance check. + +The application bounds frames to 4096 bytes, connections to 32, joined players to 16, business callbacks to 64 per tick, per-peer messages to 32 per second and its own pending output to eight frames per peer. Exceeding work/output limits closes the affected peer. JSON nesting is limited to eight levels. The sample retains the underlying transport's send-queue limit and closes peers whose sends are rejected. These are demonstration limits; no comprehensive hostile-client hardening, production-capacity, public deployment or Godot visual acceptance is implied by native tests. diff --git a/examples/guest_room/README_CN.md b/examples/guest_room/README_CN.md new file mode 100644 index 000000000..348bf988b --- /dev/null +++ b/examples/guest_room/README_CN.md @@ -0,0 +1,37 @@ +# 访客房间示例 + +[English](README.md) + +`guest_room_plugin` 通过真实 ARK 启动器及 Utility、Kernel、Bus、Net 插件运行一个二维访客大厅。每个已加入的会话拥有一个 `GuestPlayer` Kernel 实体;移动更新服务端实体,快照读取实体的名称、位置和序列号自定义字段。序列号使用 Kernel 已支持的自定义 int64 setter,协议仍限制为无符号 32 位范围。Net 回调及业务状态修改均在启动器所属线程执行。现有 Login 和默认 CS/SS 帧格式不变,无需 Registry、Consul、数据库、账号或持久化服务。 + +## 构建与运行 + +准备仓库的常规原生依赖,以及现有可选依赖 `nlohmann_json` 的 CMake 包。在同一次构建中启用 applications、examples 和本示例: + +```sh +cmake -S . -B app-build -DARK_BUILD_APPLICATIONS=ON -DARK_BUILD_EXAMPLES=ON -DARK_BUILD_GUEST_ROOM_EXAMPLE=ON +cmake --build app-build --target app guest_room_plugin ark_kernel_plugin ark_utility_plugin ark_bus_plugin ark_net_plugin --parallel 1 +./examples/guest_room/run.sh --bin-dir "$PWD/app-build/bin" --runtime-dir "$PWD/run/guest_room" --host 127.0.0.1 --port 19100 +``` + +配置时仍需传入正常的依赖工具链或前缀参数;使用 vcpkg 时选择 `guest-room` 清单特性。Debug 构建的运行命令增加 `--debug`,使用 `app_d` 和 `_d` 插件。脚本复制同次构建的完整产物目录、TOML 清单和示例元数据。`--host` 指定本机绑定地址或主机名,`--port` 范围为 `1..65535`。使用新的运行目录,以 Ctrl+C 停服。停服超过五秒时,启动器保留资源并报告失败。 + +跨平台配置步骤如下: + +```sh +cmake -DBIN_DIR=/absolute/app-build/bin -DRUNTIME_DIR=/absolute/run/guest_room -DHOST=127.0.0.1 -DPORT=19100 -P examples/guest_room/stage.cmake +cd /absolute/run/guest_room +./bin/app --busid=1.1.31.1 --name=guest_room --app_conf=conf/guest_room.app.toml --log_conf=conf/log.conf --shutdown_timeout_ms=5000 +``` + +Windows 使用 `app.exe`/`app_d.exe`。C# Godot 示例连接相同主机和端口,移动发送频率约为 10 Hz。断线重连后,客户端须清空旧帧和房间状态,重新加入并接收新的玩家 ID。字段和错误约定见[协议](protocol_CN.md);[wire-vectors.json](wire-vectors.json) 与 C# 测试使用相同的线格式向量。 + +## 原生验证 + +```sh +cmake --build app-build --target check-guest-room --parallel 1 +``` + +`check-guest-room-codec` 使用真实 Net 会话解析器,检查共享线格式向量、每个单一拆分位置、粘包、4096/4097 字节边界、错误标记和 flags、全新解析状态,以及数字和名称边界。POSIX 系统上的 `check-guest-room-server` 启动真实 `app` 并加载插件动态库,通过两个原生 TCP 客户端检查加入、双向权威移动、过期与非法请求、有界错误、断线后的名单缩减、重新加入、非法帧断开及正常停服。测试使用独立运行目录和本机端口 19191;同一时间仅运行一份。进程测试采用 POSIX 子进程控制,不构成 Windows 运行验收。 + +示例将帧正文限制为 4096 字节,连接数限制为 32,房间人数限制为 16;每轮最多处理 64 次业务回调,每个连接每秒最多 32 条消息,每个连接的业务待发送队列最多保留八帧。超过工作量或输出队列限制时关闭对应连接。JSON 嵌套最多八层。示例沿用底层传输的发送队列限制,发送被拒绝时关闭对应连接。这些是演示限制,原生测试不代表完整的恶意客户端加固、生产容量、公网部署或 Godot 视觉验收。 diff --git a/examples/guest_room/guest_room_module.cpp b/examples/guest_room/guest_room_module.cpp new file mode 100644 index 000000000..da533d3cf --- /dev/null +++ b/examples/guest_room/guest_room_module.cpp @@ -0,0 +1,291 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include + +#include "ark/kernel/data_list_impl.hpp" +#include "ark/kernel/map_module.hpp" +#include "ark/net/net_service_manager_module.hpp" +#include "guest_room_module.hpp" + +namespace ark::guest_room { + +void room_module::owner_thread() const { + if (std::this_thread::get_id() != owner_) + { + throw std::logic_error("guest_room state accessed outside the launcher owner thread"); + } +} + +bool room_module::init() { + owner_ = std::this_thread::get_id(); + kernel_ = find_module(); + return kernel_ != nullptr; +} + +bool room_module::post_init() { + owner_thread(); + auto* maps = find_module(); + auto* network = find_module(); + if (maps == nullptr || network == nullptr || !maps->create_map(1) || + !network->create_server(60000, header(true), header(true))) + { + return false; + } + server_ = network->get_inter_net_server(); + if (server_ == nullptr) + { + return false; + } + for (const auto id : {join, move, ping, unsupported}) + { + if (!server_->reg_msg_callback(id, this, &room_module::on_message)) + { + return false; + } + } + server_->reg_net_event_callback(this, &room_module::on_event); + ARK_LOG_INFO("guest_room ready: lobby, 16 players, ARK1 protocol"); + return true; +} + +void room_module::enqueue(peer& connection, msg_id_t id, const json& body) { + if (connection.closing || stopping_) + { + return; + } + auto text = body.dump(); + if (text.size() > max_body || connection.pending.size() >= 8) + { + connection.closing = true; + connection.pending.clear(); + return; + } + connection.pending.push_back({id, std::move(text)}); +} + +void room_module::reject(peer& connection, const std::string& code, const std::string& reason) { + enqueue(connection, error, {{"code", code}, {"message", reason}}); +} + +void room_module::broadcast() { + json players = json::array(); + for (const auto& [session, connection] : peers_) + { + if (connection.player != nullptr && !connection.closing) + { + const auto& player = *connection.player; + players.push_back({{"id", connection.player_id}, {"name", player.get_custom_string("name")}, + {"x", player.get_custom_double("x")}, {"y", player.get_custom_double("y")}, + {"seq", player.get_custom_int64("seq")}}); + } + } + const json state{{"room", "lobby"}, {"players", std::move(players)}}; + for (auto& [session, connection] : peers_) + { + if (connection.player != nullptr) + { + enqueue(connection, snapshot, state); + } + } +} + +void room_module::join_room(peer& connection, const json& body) { + if (connection.player != nullptr) + { + reject(connection, "already_joined", "This session already joined the lobby"); + return; + } + if (!body.contains("name") || !body["name"].is_string() || !valid_name(body["name"].get()) || + !body.contains("room") || body["room"] != "lobby") + { + reject(connection, "invalid_message", "Expected lobby and a name of 1..64 UTF-8 bytes without controls"); + return; + } + const auto count = + std::count_if(peers_.begin(), peers_.end(), [](const auto& entry) { return entry.second.player != nullptr; }); + if (count >= 16) + { + reject(connection, "room_full", "The lobby has 16 players"); + return; + } + auto player = kernel_->create_entity(null_guid, 1, 0, "GuestPlayer", 0, data_list_impl()); + if (player == nullptr) + { + throw std::runtime_error("guest_room Kernel entity creation failed"); + } + player->add_custom_string("name", body["name"].get()); + player->add_custom_double("x", 80.0 + static_cast((next_player_ - 1) % 8) * 100.0); + player->add_custom_double("y", 100.0 + static_cast(((next_player_ - 1) / 8) % 2) * 200.0); + player->add_custom_int64("seq", 0); + connection.player_id = std::to_string(next_player_++); + connection.player = std::move(player); + enqueue(connection, accepted, + {{"player_id", connection.player_id}, {"name", body["name"]}, {"room", "lobby"}, {"width", 960}, + {"height", 540}}); + broadcast(); +} + +void room_module::move_player(peer& connection, const json& body) { + if (connection.player == nullptr) + { + reject(connection, "not_joined", "Join the lobby before moving"); + return; + } + std::uint32_t sequence = 0; + if (!body.contains("seq") || !uint32_value(body["seq"], sequence) || !body.contains("x") || + !body["x"].is_number() || !body.contains("y") || !body["y"].is_number()) + { + reject(connection, "invalid_message", "Expected an unsigned sequence and finite coordinates"); + return; + } + const auto x = body["x"].get(); + const auto y = body["y"].get(); + if (!std::isfinite(x) || !std::isfinite(y) || x < 0 || x > 960 || y < 0 || y > 540) + { + reject(connection, "invalid_message", "Coordinates must be within 0..960 and 0..540"); + return; + } + if (sequence <= connection.player->get_custom_int64("seq")) + { + reject(connection, "stale_sequence", "Movement sequence must strictly increase from 1"); + return; + } + connection.player->set_custom_double("x", x); + connection.player->set_custom_double("y", y); + connection.player->set_custom_int64("seq", sequence); + broadcast(); +} + +void room_module::on_message(const net_msg* message, conv_id_t session) { + owner_thread(); + auto found = peers_.find(session); + if (stopping_ || found == peers_.end() || found->second.closing) + { + return; + } + auto& connection = found->second; + const auto now = std::chrono::steady_clock::now(); + if (now - connection.window >= std::chrono::seconds(1)) + { + connection.window = now; + connection.messages = 0; + } + if (++connection.messages > 32 || ++tick_messages_ > 64) + { + connection.closing = true; + return; + } + bool excessive_depth = false; + const auto body = json::parse( + message->get_msg_data(), message->get_msg_data() + message->get_msg_length(), + [&excessive_depth](int depth, json::parse_event_t, json&) { + excessive_depth = excessive_depth || depth > 8; + return depth <= 8; + }, + false); + if (excessive_depth || body.is_discarded() || !body.is_object()) + { + reject(connection, "invalid_message", "Expected a UTF-8 JSON object of at most eight nesting levels"); + return; + } + switch (message->get_msg_id()) + { + case join: + join_room(connection, body); + break; + case move: + move_player(connection, body); + break; + case ping: + { + std::uint32_t nonce = 0; + if (!body.contains("nonce") || !uint32_value(body["nonce"], nonce)) + { + reject(connection, "invalid_message", "Expected an unsigned 32-bit nonce"); + } + else + { + enqueue(connection, pong, {{"nonce", nonce}}); + } + break; + } + default: + reject(connection, "invalid_message", "Unsupported client message ID"); + break; + } +} + +void room_module::on_event(const net_event* event) { + owner_thread(); + if (stopping_) + { + return; + } + if (event->get_type() == net_event_type::connect) + { + peers_.try_emplace(event->get_id()); + } + else if (event->get_type() == net_event_type::disconnect) + { + const auto found = peers_.find(event->get_id()); + if (found != peers_.end()) + { + const bool joined = found->second.player != nullptr; + if (joined) + { + kernel_->destroy_entity(found->second.player->get_id()); + } + peers_.erase(found); + if (joined) + { + broadcast(); + } + } + } +} + +bool room_module::update() { + owner_thread(); + tick_messages_ = 0; + for (auto& [session, connection] : peers_) + { + if (connection.closing) + { + server_->get_net()->close_session(session); + continue; + } + while (!connection.pending.empty()) + { + auto pending = std::move(connection.pending.front()); + connection.pending.pop_front(); + header response; + response.set_message_id(pending.id); + response.set_message_length(static_cast(pending.body.size())); + if (!server_->get_net()->send_msg(&response, pending.body.data(), session)) + { + connection.closing = true; + connection.pending.clear(); + break; + } + } + } + return true; +} + +bool room_module::pre_shut() { + owner_thread(); + stopping_ = true; + for (auto& [session, connection] : peers_) + { + if (connection.player != nullptr) + { + kernel_->destroy_entity(connection.player->get_id()); + } + } + peers_.clear(); + server_.reset(); + return true; +} + +} // namespace ark::guest_room diff --git a/examples/guest_room/guest_room_module.hpp b/examples/guest_room/guest_room_module.hpp new file mode 100644 index 000000000..91de0135c --- /dev/null +++ b/examples/guest_room/guest_room_module.hpp @@ -0,0 +1,60 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#pragma once + +#include +#include +#include +#include +#include + +#include "ark/kernel/kernel_module.hpp" +#include "ark/module_base.hpp" +#include "ark/net/net_server_service.hpp" +#include "guest_room_protocol.hpp" + +namespace ark::guest_room { + +class room_module final : public module_base +{ +public: + bool init() override; + bool post_init() override; + bool update() override; + bool pre_shut() override; + +private: + struct output + { + msg_id_t id; + std::string body; + }; + + struct peer + { + std::shared_ptr player; + std::string player_id; + std::deque pending; + std::chrono::steady_clock::time_point window{std::chrono::steady_clock::now()}; + unsigned messages{0}; + bool closing{false}; + }; + + void on_message(const net_msg* message, conv_id_t session); + void on_event(const net_event* event); + void join_room(peer& connection, const json& body); + void move_player(peer& connection, const json& body); + void enqueue(peer& connection, msg_id_t id, const json& body); + void reject(peer& connection, const std::string& code, const std::string& reason); + void broadcast(); + void owner_thread() const; + + kernel_module* kernel_{nullptr}; + std::shared_ptr server_; + std::map peers_; + std::thread::id owner_; + std::uint64_t next_player_{1}; + unsigned tick_messages_{0}; + bool stopping_{false}; +}; + +} // namespace ark::guest_room diff --git a/examples/guest_room/guest_room_plugin.cpp b/examples/guest_room/guest_room_plugin.cpp new file mode 100644 index 000000000..22873b30e --- /dev/null +++ b/examples/guest_room/guest_room_plugin.cpp @@ -0,0 +1,32 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include + +#include "guest_room_module.hpp" +#include "guest_room_plugin.hpp" + +namespace ark::guest_room { + +ARK_EXPORT_FUNC void DllEntryPlugin(plugin_manager* manager, const std::string& name, logger* log) { + logger::init(log); + manager->register_plugin(name); +} + +ARK_EXPORT_FUNC void DllExitPlugin(plugin_manager* manager) { + manager->unregister_plugin(); +} + +void plugin::install() { + if (!register_module(std::make_unique(), module_update::enabled)) + { + throw std::runtime_error("guest_room module registration rejected"); + } +} + +void plugin::uninstall() { + if (!unregister_module()) + { + throw std::runtime_error("guest_room module unregistration rejected"); + } +} + +} // namespace ark::guest_room diff --git a/examples/guest_room/guest_room_plugin.hpp b/examples/guest_room/guest_room_plugin.hpp new file mode 100644 index 000000000..aea120fb0 --- /dev/null +++ b/examples/guest_room/guest_room_plugin.hpp @@ -0,0 +1,15 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#pragma once + +#include "ark/plugin_base.hpp" + +namespace ark::guest_room { + +class plugin final : public plugin_base +{ +public: + void install() override; + void uninstall() override; +}; + +} // namespace ark::guest_room diff --git a/examples/guest_room/guest_room_protocol.hpp b/examples/guest_room/guest_room_protocol.hpp new file mode 100644 index 000000000..f84e9883a --- /dev/null +++ b/examples/guest_room/guest_room_protocol.hpp @@ -0,0 +1,136 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#pragma once + +#include +#include +#include +#include + +#include + +#include "ark/net/net_msg.hpp" + +namespace ark::guest_room { + +inline constexpr msg_id_t join = 60000; +inline constexpr msg_id_t accepted = 60001; +inline constexpr msg_id_t move = 60002; +inline constexpr msg_id_t snapshot = 60003; +inline constexpr msg_id_t error = 60004; +inline constexpr msg_id_t ping = 60005; +inline constexpr msg_id_t pong = 60006; +inline constexpr msg_id_t unsupported = 60007; // Internal dispatch key, never emitted. +inline constexpr std::uint32_t max_body = 4096; +using json = nlohmann::json; + +class header final : public msg_header +{ +public: + explicit header(bool client_requests_only = false) + : client_requests_only_(client_requests_only) { + } + + std::uint32_t header_length() const override { + return 12; + } + + void parse_from_array(const void* source) override { + const auto* bytes = static_cast(source); + id_ = (std::uint16_t(bytes[4]) << 8) | bytes[5]; + length_ = (std::uint32_t(bytes[8]) << 24) | (std::uint32_t(bytes[9]) << 16) | (std::uint32_t(bytes[10]) << 8) | + bytes[11]; + if (std::memcmp(bytes, "ARK1", 4) != 0 || bytes[6] != 0 || bytes[7] != 0 || length_ > max_body) + { + // Net rejects this size before allocating a message or invoking callbacks. + length_ = max_body + 1; + } + if (client_requests_only_ && id_ != join && id_ != move && id_ != ping) + { + // Keep unsupported/reserved IDs out of Net's own registration/heartbeat dispatch. + id_ = unsupported; + } + } + + void serialize_to_string(std::string& output) override { + output.assign("ARK1", 4); + output.push_back(static_cast(id_ >> 8)); + output.push_back(static_cast(id_)); + output.append(2, '\0'); + for (int shift = 24; shift >= 0; shift -= 8) + { + output.push_back(static_cast(length_ >> shift)); + } + } + + msg_id_t message_id() const override { + return id_; + } + + std::uint32_t message_length() const override { + return length_; + } + + void set_message_id(msg_id_t value) override { + id_ = value; + } + + void set_message_length(std::uint32_t value) override { + length_ = value; + } + + msg_header* create() const override { + return new header(client_requests_only_); + } + + msg_header* clone() const override { + return new header(*this); + } + +private: + bool client_requests_only_; + msg_id_t id_{0}; + std::uint32_t length_{0}; +}; + +inline bool uint32_value(const json& value, std::uint32_t& result) { + if (value.is_number_unsigned()) + { + const auto number = value.get(); + if (number <= std::numeric_limits::max()) + { + result = static_cast(number); + return true; + } + } + else if (value.is_number_integer()) + { + const auto number = value.get(); + if (number >= 0 && number <= std::numeric_limits::max()) + { + result = static_cast(number); + return true; + } + } + return false; +} + +inline bool valid_name(const std::string& name) { + if (name.empty() || name.size() > 64) + { + return false; + } + // JSON parsing validates UTF-8. Reject Unicode C0/C1 control code points too. + for (std::size_t i = 0; i < name.size(); ++i) + { + const auto byte = static_cast(name[i]); + if (byte < 0x20 || byte == 0x7f || + (byte == 0xc2 && i + 1 < name.size() && static_cast(name[i + 1]) >= 0x80 && + static_cast(name[i + 1]) <= 0x9f)) + { + return false; + } + } + return true; +} + +} // namespace ark::guest_room diff --git a/examples/guest_room/protocol.md b/examples/guest_room/protocol.md new file mode 100644 index 000000000..ee69072d0 --- /dev/null +++ b/examples/guest_room/protocol.md @@ -0,0 +1,21 @@ +# ARK guest-room protocol v1 + +[中文](protocol_CN.md) + +TCP frames have a fixed 12-byte header: ASCII `ARK1` at bytes 0..3, unsigned big-endian 16-bit message ID at 4..5, zero 16-bit flags at 6..7 and unsigned big-endian 32-bit body byte length at 8..11. Body length excludes the header and is at most 4096. The body is a UTF-8 JSON object. There is no compression. Bad magic, nonzero flags or oversized bodies close the connection before message allocation/business dispatch. Partial headers/bodies are retained; coalesced frames are parsed separately. Each connection owns fresh parser state. + +| ID | Direction | Body | +| --- | --- | --- | +| 60000 | Client → server: join | `{"name":"Alice","room":"lobby"}` | +| 60001 | Server → joining client: accepted | `{"player_id":"1","name":"Alice","room":"lobby","width":960,"height":540}` | +| 60002 | Client → server: move | `{"seq":1,"x":120.0,"y":100.0}` | +| 60003 | Server → joined peers: full snapshot | `{"room":"lobby","players":[{"id":"1","name":"Alice","x":120.0,"y":100.0,"seq":1}]}` | +| 60004 | Server → client: error | `{"code":"invalid_message","message":"bounded reason"}` | +| 60005 | Client → server: ping | `{"nonce":1}` | +| 60006 | Server → client: pong | `{"nonce":1}` | + +There is one `lobby`, at most 16 joined players and no account authentication. Player IDs are opaque strings assigned uniquely within the running example process. A name contains 1..64 UTF-8 encoded bytes and no Unicode C0/C1 controls. `seq` and `nonce` are integral JSON values in `0..4294967295` (floating-point representations such as `1.0` are rejected). Moves start at sequence 1 and strictly increase. Coordinates must be finite and inside `0..960` / `0..540`; the server chooses the spawn position. + +A successful join queues accepted before its full snapshot and broadcasts the roster to joined peers. Each accepted move broadcasts a new authoritative roster. Disconnect destroys the session's Kernel entity and broadcasts the reduced roster. A reconnect rejoins as a fresh player with sequence 0 and a new ID; old identity and state are not resumed. Ping does not require joining. + +Errors are `room_full`, `already_joined`, `not_joined`, `invalid_message` and `stale_sequence`. Invalid requests leave entity state unchanged. Unsupported/directionally invalid message IDs produce `invalid_message`; the sample header maps these IDs to internal callback key 60007 so Net's reserved IDs 1/65535 cannot enter its default SS control paths. This mapping is local dispatch, not an additional wire message. Application errors keep the session open unless work/output limits are exceeded. No failed movement replay, durable identity or reliable business acknowledgement is promised. diff --git a/examples/guest_room/protocol_CN.md b/examples/guest_room/protocol_CN.md new file mode 100644 index 000000000..08382303c --- /dev/null +++ b/examples/guest_room/protocol_CN.md @@ -0,0 +1,21 @@ +# ARK 访客房间协议 v1 + +[English](protocol.md) + +TCP 帧使用固定的 12 字节头部:第 `0..3` 字节为 ASCII `ARK1`,第 `4..5` 字节为大端无符号 16 位消息 ID,第 `6..7` 字节为必须为零的 16 位 flags,第 `8..11` 字节为大端无符号 32 位正文长度。正文长度不包含头部,最多为 4096 字节。正文为 UTF-8 JSON 对象,不压缩。标记错误、flags 非零或正文超限时,在消息分配和业务回调之前关闭连接。解析器保留不完整头部和正文,分别处理粘连的完整帧;每个连接使用全新的解析状态。 + +| ID | 方向 | 正文 | +| --- | --- | --- | +| 60000 | 客户端 → 服务端:加入 | `{"name":"Alice","room":"lobby"}` | +| 60001 | 服务端 → 加入者:接纳 | `{"player_id":"1","name":"Alice","room":"lobby","width":960,"height":540}` | +| 60002 | 客户端 → 服务端:移动 | `{"seq":1,"x":120.0,"y":100.0}` | +| 60003 | 服务端 → 已加入者:完整快照 | `{"room":"lobby","players":[{"id":"1","name":"Alice","x":120.0,"y":100.0,"seq":1}]}` | +| 60004 | 服务端 → 客户端:错误 | `{"code":"invalid_message","message":"bounded reason"}` | +| 60005 | 客户端 → 服务端:ping | `{"nonce":1}` | +| 60006 | 服务端 → 客户端:pong | `{"nonce":1}` | + +仅提供一个 `lobby`,最多容纳 16 名已加入玩家,不进行账号认证。玩家 ID 是服务端分配的不透明字符串,在本次示例进程内唯一。名称占用 `1..64` 个 UTF-8 编码字节,不含 Unicode C0/C1 控制字符。`seq` 和 `nonce` 必须是 `0..4294967295` 范围内的 JSON 整数,拒绝 `1.0` 等浮点表示。首次移动序列号从 1 开始,后续严格递增。坐标必须为有限值,且分别位于 `0..960`/`0..540` 内;初始位置由服务端确定。 + +加入成功时,先将接纳消息加入队列,再向所有已加入者广播完整快照。每次接受移动后广播新的权威名单。断线时销毁对应 Kernel 实体,并广播缩减后的名单。重连后重新加入,获得新 ID 和从 0 开始的实体序列号,不恢复旧身份或旧状态。ping 无需先加入。 + +错误码包括 `room_full`、`already_joined`、`not_joined`、`invalid_message` 和 `stale_sequence`。非法请求不修改实体状态。不支持或方向错误的消息 ID 返回 `invalid_message`;示例头部将这些 ID 映射到内部回调键 60007,避免进入 Net 保留 ID 1/65535 的默认 SS 控制路径。此映射仅用于本地分发,不增加线格式消息。业务错误保留会话,超过工作量或输出限制时关闭连接。协议不保证移动失败重放、持久身份或可靠业务确认。 diff --git a/examples/guest_room/resource/meta/config_class.config b/examples/guest_room/resource/meta/config_class.config new file mode 100644 index 000000000..fc65d56de --- /dev/null +++ b/examples/guest_room/resource/meta/config_class.config @@ -0,0 +1 @@ + diff --git a/examples/guest_room/resource/meta/entity_class.config b/examples/guest_room/resource/meta/entity_class.config new file mode 100644 index 000000000..8c9461e53 --- /dev/null +++ b/examples/guest_room/resource/meta/entity_class.config @@ -0,0 +1 @@ + diff --git a/examples/guest_room/resource/meta/guest_player.config b/examples/guest_room/resource/meta/guest_player.config new file mode 100644 index 000000000..8318df82a --- /dev/null +++ b/examples/guest_room/resource/meta/guest_player.config @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/guest_room/run.sh b/examples/guest_room/run.sh new file mode 100755 index 000000000..697ce730c --- /dev/null +++ b/examples/guest_room/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +# Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +set -eu +sample_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +bin_dir='' +runtime_dir='' +host=127.0.0.1 +port=19100 +program=app +while [ "$#" -gt 0 ]; do + case "$1" in + --bin-dir) bin_dir=$2; shift 2 ;; + --runtime-dir) runtime_dir=$2; shift 2 ;; + --host) host=$2; shift 2 ;; + --port) port=$2; shift 2 ;; + --debug) program=app_d; shift ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac +done +if [ -z "$bin_dir" ] || [ -z "$runtime_dir" ]; then + echo "Usage: run.sh --bin-dir BUILD/bin --runtime-dir NEW_RUN_DIR [--host 127.0.0.1] [--port 19100] [--debug]" >&2 + exit 2 +fi +cmake "-DBIN_DIR=$bin_dir" "-DRUNTIME_DIR=$runtime_dir" "-DHOST=$host" "-DPORT=$port" -P "$sample_dir/stage.cmake" +cd -- "$runtime_dir" +exec "./bin/$program" --busid=1.1.31.1 --name=guest_room --app_conf=conf/guest_room.app.toml --log_conf=conf/log.conf --shutdown_timeout_ms=5000 diff --git a/examples/guest_room/stage.cmake b/examples/guest_room/stage.cmake new file mode 100644 index 000000000..235329ac7 --- /dev/null +++ b/examples/guest_room/stage.cmake @@ -0,0 +1,32 @@ +# Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +cmake_minimum_required(VERSION 3.20) +foreach(argument BIN_DIR RUNTIME_DIR) + if(NOT DEFINED ${argument}) + message(FATAL_ERROR "${argument} is required") + endif() +endforeach() +if(NOT DEFINED HOST) + set(HOST "127.0.0.1") +endif() +if(NOT DEFINED PORT) + set(PORT 19100) +endif() +if(NOT HOST MATCHES "^[a-zA-Z0-9.:_-]+$" OR NOT PORT MATCHES "^[0-9]+$" OR PORT LESS 1 OR PORT GREATER 65535) + message(FATAL_ERROR "HOST must be a literal/hostname; PORT must be 1..65535") +endif() +set(endpoint_host "${HOST}") +if(HOST MATCHES ":") + set(endpoint_host "[${HOST}]") +endif() +get_filename_component(source_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +get_filename_component(binary_root "${BIN_DIR}" ABSOLUTE) +get_filename_component(runtime_root "${RUNTIME_DIR}" ABSOLUTE) +file(MAKE_DIRECTORY "${runtime_root}/bin" "${runtime_root}/conf") +file(COPY "${binary_root}/" DESTINATION "${runtime_root}/bin") +file(COPY "${source_root}/config/guest_room.app.toml" "${source_root}/config/kernel.plugin.conf" + "${source_root}/config/log.conf" DESTINATION "${runtime_root}/conf") +file(READ "${source_root}/config/guest_room.bus.toml" bus) +string(REPLACE "127.0.0.1:19100" "${endpoint_host}:${PORT}" bus "${bus}") +file(WRITE "${runtime_root}/conf/guest_room.bus.toml" "${bus}") +file(COPY "${CMAKE_CURRENT_LIST_DIR}/resource/" DESTINATION "${runtime_root}/resource") +message(STATUS "guest_room staged at ${runtime_root}; tcp://${endpoint_host}:${PORT}") diff --git a/examples/guest_room/wire-vectors.json b/examples/guest_room/wire-vectors.json new file mode 100644 index 000000000..e358ab506 --- /dev/null +++ b/examples/guest_room/wire-vectors.json @@ -0,0 +1,8 @@ +{ + "format": "ARK1", + "vectors": [ + {"message_id": 60000, "body": "{\"name\":\"Alice\",\"room\":\"lobby\"}", "frame_hex": "41524b31ea6000000000001f7b226e616d65223a22416c696365222c22726f6f6d223a226c6f626279227d"}, + {"message_id": 60005, "body": "{\"nonce\":1}", "frame_hex": "41524b31ea6500000000000b7b226e6f6e6365223a317d"}, + {"message_id": 60006, "body": "", "frame_hex": "41524b31ea66000000000000"} + ] +} diff --git a/tests/ci/package-dependencies.test.mjs b/tests/ci/package-dependencies.test.mjs index 58061aa9f..46525bc40 100644 --- a/tests/ci/package-dependencies.test.mjs +++ b/tests/ci/package-dependencies.test.mjs @@ -11,15 +11,16 @@ test('core packages are shared while application, configuration and JSON depende const core = dependencies(manifest.dependencies); assert.ok(core.includes('spdlog')); assert.ok(core.includes('readerwriterqueue')); - for (const [feature, name] of [['application', 'args'], ['config', 'tomlplusplus'], ['etcd-client', 'nlohmann-json']]) { - assert.ok(dependencies(manifest.features[feature].dependencies).includes(name)); + for (const [owners, name] of [[['application'], 'args'], [['config'], 'tomlplusplus'], [['etcd-client', 'guest-room'], 'nlohmann-json']]) { + for (const feature of owners) assert.ok(dependencies(manifest.features[feature].dependencies).includes(name)); assert.ok(!core.includes(name), `${name} must remain optional`); for (const [other, value] of Object.entries(manifest.features)) { - if (other !== feature) assert.ok(!dependencies(value.dependencies).includes(name), `${name} belongs to ${feature}`); + if (!owners.includes(other)) assert.ok(!dependencies(value.dependencies).includes(name), `${name} belongs to ${owners.join('/')}`); } } assert.ok(manifest['default-features'].includes('config')); assert.ok(!manifest['default-features'].includes('etcd-client')); + assert.ok(!manifest['default-features'].includes('guest-room')); }); test('the public core target propagates package usage requirements without a bundled include root', () => { diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index 34a18fb5d..60f06058c 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -368,3 +368,7 @@ if(ARK_BUILD_HTTP_EXAMPLE_TESTS) DEPENDS ark_http_examples_tests http_client_sample_plugin http_server_plugin USES_TERMINAL) add_dependencies(check-core check-http-examples) endif() + +if(TARGET check-guest-room) + add_dependencies(check-core check-guest-room) +endif() diff --git a/tests/guest_room/codec.cpp b/tests/guest_room/codec.cpp new file mode 100644 index 000000000..2b662725f --- /dev/null +++ b/tests/guest_room/codec.cpp @@ -0,0 +1,161 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include + +#include "../network/doctest.h" + +#include "../../examples/guest_room/guest_room_protocol.hpp" +#include "ark/net/net_session.hpp" + +namespace { +using namespace ark::guest_room; + +std::string frame(ark::msg_id_t id, const std::string& body) { + header head; + head.set_message_id(id); + head.set_message_length(static_cast(body.size())); + std::string result; + head.serialize_to_string(result); + return result + body; +} + +std::string hex(const std::string& bytes) { + static constexpr char digits[] = "0123456789abcdef"; + std::string text; + for (const unsigned char byte : bytes) + { + text.push_back(digits[byte >> 4]); + text.push_back(digits[byte & 15]); + } + return text; +} +} // namespace + +TEST_CASE("Guest room golden frames match the C sharp shared fixture") { + std::ifstream input(ARK_GUEST_ROOM_VECTORS); + REQUIRE(input.good()); + const auto fixtures = json::parse(input); + for (const auto& vector : fixtures["vectors"]) + { + const auto id = vector["message_id"].get(); + const auto body = vector["body"].get(); + const auto bytes = frame(id, body); + CHECK(hex(bytes) == vector["frame_hex"].get()); + header parsed; + parsed.parse_from_array(bytes.data()); + CHECK(parsed.message_id() == id); + CHECK(parsed.message_length() == body.size()); + } +} + +TEST_CASE("Guest room Net retains split headers bodies and coalesced frames") { + const auto bytes = frame(join, R"({"name":"Alice","room":"lobby"})"); + for (std::size_t split = 1; split < bytes.size(); ++split) + { + ark::tcp_client_session session(std::make_shared
(), 1, nullptr, 16); + session.add_buffer(bytes.data(), split); + REQUIRE(session.parse_buffer_to_msg()); + ark::net_msg* message = nullptr; + CHECK_FALSE(session.pop_net_msg(message)); + const auto rest = bytes.substr(split) + frame(ping, R"({"nonce":1})"); + session.add_buffer(rest.data(), rest.size()); + REQUIRE(session.parse_buffer_to_msg()); + REQUIRE(session.pop_net_msg(message)); + CHECK(message->get_msg_id() == join); + CHECK(std::string(message->get_msg_data(), message->get_msg_length()) == bytes.substr(12)); + ark::net_msg::release(message); + REQUIRE(session.pop_net_msg(message)); + CHECK(message->get_msg_id() == ping); + ark::net_msg::release(message); + CHECK_FALSE(session.pop_net_msg(message)); + } +} + +TEST_CASE("Guest room invalid headers fail before body allocation and callback") { + for (const auto kind : {0, 1, 2}) + { + auto bytes = frame(join, "").substr(0, 12); + if (kind == 0) + { + bytes[0] = 'X'; + } + else if (kind == 1) + { + bytes[7] = 1; + } + else + { + bytes[10] = 0x10; + bytes[11] = 1; // 4097-byte body, with no body present. + } + header parsed; + parsed.parse_from_array(bytes.data()); + CHECK(parsed.message_length() == 4097); + ark::tcp_client_session session(std::make_shared
(), 1, nullptr, 16); + session.add_buffer(bytes.data(), bytes.size()); + CHECK_FALSE(session.parse_buffer_to_msg()); + ark::net_msg* message = nullptr; + CHECK_FALSE(session.pop_net_msg(message)); + } + const auto maximum = frame(join, std::string(4096, 'x')); + ark::tcp_client_session session(std::make_shared
(), 1, nullptr, 16); + session.add_buffer(maximum.data(), maximum.size()); + REQUIRE(session.parse_buffer_to_msg()); + ark::net_msg* message = nullptr; + REQUIRE(session.pop_net_msg(message)); + CHECK(message->get_msg_length() == 4096); + ark::net_msg::release(message); +} + +TEST_CASE("Guest room reconnect owns fresh parser state and reserved IDs cannot enter Net dispatch") { + const auto bytes = frame(ping, R"({"nonce":1})"); + { + ark::tcp_client_session stale(std::make_shared
(), 1, nullptr, 16); + stale.add_buffer(bytes.data(), 14); + REQUIRE(stale.parse_buffer_to_msg()); + } + ark::tcp_client_session fresh(std::make_shared
(), 2, nullptr, 16); + fresh.add_buffer(bytes.data(), bytes.size()); + REQUIRE(fresh.parse_buffer_to_msg()); + ark::net_msg* message = nullptr; + REQUIRE(fresh.pop_net_msg(message)); + CHECK(message->get_msg_id() == ping); + ark::net_msg::release(message); + for (const auto id : {0, 1, 65535, 60001, 12345}) + { + const auto unsupported_frame = frame(static_cast(id), "{}"); + header request(true); + request.parse_from_array(unsupported_frame.data()); + CHECK(request.message_id() == unsupported); + } +} + +TEST_CASE("Guest room unsigned values and encoded name bounds are strict") { + std::uint32_t value = 0; + for (const auto text : {"0", "4294967295"}) + { + CHECK(uint32_value(json::parse(text), value)); + } + for (const auto text : {"-1", "4294967296", "1.0", "true", "null", "\"1\""}) + { + CHECK_FALSE(uint32_value(json::parse(text), value)); + } + CHECK(valid_name(std::string(64, 'a'))); + CHECK(valid_name("访客")); + CHECK_FALSE(valid_name("")); + CHECK_FALSE(valid_name(std::string(65, 'a'))); + CHECK_FALSE(valid_name("a\nb")); + CHECK_FALSE(valid_name("a\xc2\x85")); +} + +TEST_CASE("Guest room worst encoded roster fits the framing budget") { + json players = json::array(); + for (int count = 0; count < 16; ++count) + { + players.push_back({{"id", "18446744073709551615"}, {"name", std::string(64, '"')}, + {"x", 2.2250738585072014e-308}, {"y", 2.2250738585072014e-308}, {"seq", 4294967295u}}); + } + const json roster{{"room", "lobby"}, {"players", players}}; + CHECK(roster.dump().size() <= max_body); +} diff --git a/tests/guest_room/server.cpp b/tests/guest_room/server.cpp new file mode 100644 index 000000000..1de9b4911 --- /dev/null +++ b/tests/guest_room/server.cpp @@ -0,0 +1,342 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../../examples/guest_room/guest_room_protocol.hpp" + +namespace { +using namespace std::chrono_literals; +using namespace ark::guest_room; +using tcp = asio::ip::tcp; + +void require(bool condition, const std::string& reason) { + if (!condition) + { + throw std::runtime_error(reason); + } +} + +struct launcher +{ + pid_t pid{-1}; + bool stopped{false}; + + launcher(const std::string& program, const std::string& directory) { + pid = fork(); + require(pid >= 0, "fork failed"); + if (pid == 0) + { + if (chdir(directory.c_str()) != 0) + { + _exit(126); + } + const int log = open("launcher.log", O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (log < 0) + { + _exit(126); + } + dup2(log, STDOUT_FILENO); + dup2(log, STDERR_FILENO); + close(log); + execl(program.c_str(), program.c_str(), "--busid=1.1.31.1", "--name=guest_room_native_test", + "--app_conf=conf/guest_room.app.toml", "--log_conf=conf/log.conf", "--shutdown_timeout_ms=3000", + static_cast(nullptr)); + _exit(127); + } + } + + void stop() { + kill(pid, SIGTERM); + const auto deadline = std::chrono::steady_clock::now() + 5s; + int status = 0; + while (std::chrono::steady_clock::now() < deadline) + { + if (waitpid(pid, &status, WNOHANG) == pid) + { + stopped = true; + require(WIFEXITED(status) && WEXITSTATUS(status) == 0, "launcher failed normal shutdown"); + return; + } + std::this_thread::sleep_for(10ms); + } + throw std::runtime_error("launcher did not finish shutdown"); + } + + ~launcher() { + if (pid > 0 && !stopped) + { + kill(pid, SIGKILL); + int status = 0; + waitpid(pid, &status, 0); + } + } +}; + +struct client +{ + asio::io_context context; + tcp::socket socket{context}; + + explicit client(unsigned short port, bool retry = false) { + const auto deadline = std::chrono::steady_clock::now() + (retry ? 5s : 1s); + asio::error_code ec; + do + { + socket.connect({asio::ip::make_address("127.0.0.1"), port}, ec); + if (!ec) + { + socket.non_blocking(true); + return; + } + socket.close(); + socket = tcp::socket(context); + std::this_thread::sleep_for(10ms); + } while (retry && std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("connect failed: " + ec.message()); + } + + void write(const std::string& bytes) { + std::size_t offset = 0; + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (offset < bytes.size() && std::chrono::steady_clock::now() < deadline) + { + asio::error_code ec; + offset += socket.write_some(asio::buffer(bytes.data() + offset, bytes.size() - offset), ec); + require(!ec || ec == asio::error::would_block || ec == asio::error::try_again, "write failed"); + if (ec) + { + std::this_thread::sleep_for(2ms); + } + } + require(offset == bytes.size(), "write timed out"); + } + + static std::string encode(ark::msg_id_t id, const std::string& body) { + header head; + head.set_message_id(id); + head.set_message_length(static_cast(body.size())); + std::string bytes; + head.serialize_to_string(bytes); + return bytes + body; + } + + void send(ark::msg_id_t id, const json& body) { + write(encode(id, body.dump())); + } + + std::string read(std::size_t count) { + std::string bytes(count, '\0'); + std::size_t offset = 0; + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (offset < count && std::chrono::steady_clock::now() < deadline) + { + asio::error_code ec; + offset += socket.read_some(asio::buffer(bytes.data() + offset, count - offset), ec); + require(!ec || ec == asio::error::would_block || ec == asio::error::try_again, + "unexpected disconnect while reading frame"); + if (ec) + { + std::this_thread::sleep_for(2ms); + } + } + require(offset == count, "frame read timed out"); + return bytes; + } + + json receive(ark::msg_id_t expected) { + header head; + const auto bytes = read(12); + head.parse_from_array(bytes.data()); + require(head.message_id() == expected, "unexpected response id: " + std::to_string(head.message_id())); + require(head.message_length() <= max_body, "invalid response header"); + return json::parse(read(head.message_length())); + } + + void close() { + asio::error_code ec; + socket.shutdown(tcp::socket::shutdown_send, ec); + socket.close(ec); + } + + void expect_closed() { + // Acknowledge the server FIN, so graceful transport shutdown can retire its native session. + asio::error_code ec; + socket.shutdown(tcp::socket::shutdown_send, ec); + const auto deadline = std::chrono::steady_clock::now() + 3s; + char byte; + while (std::chrono::steady_clock::now() < deadline) + { + socket.read_some(asio::buffer(&byte, 1), ec); + if (ec == asio::error::eof || ec == asio::error::connection_reset) + { + socket.close(); + return; + } + require(!ec || ec == asio::error::would_block || ec == asio::error::try_again, "close read failed"); + std::this_thread::sleep_for(2ms); + } + throw std::runtime_error("invalid frame connection remained open"); + } +}; + +const json& player(const json& state, const std::string& id) { + for (const auto& value : state["players"]) + { + if (value["id"] == id) + { + return value; + } + } + throw std::runtime_error("player missing from authoritative snapshot"); +} + +void error_is(client& peer, const std::string& code) { + const auto body = peer.receive(error); + require(body["code"] == code, "wrong error code"); + require(body["message"].get().size() <= 160, "unbounded error text"); +} + +void exercise(unsigned short port) { + client alice(port, true); + alice.send(move, {{"seq", 1}, {"x", 0}, {"y", 0}}); + error_is(alice, "not_joined"); + const auto join_frame = client::encode(join, R"({"name":"Alice","room":"lobby"})"); + alice.write(join_frame.substr(0, 5)); + alice.write(join_frame.substr(5, 10)); + alice.write(join_frame.substr(15)); + const auto first_id = alice.receive(accepted)["player_id"].get(); + require(alice.receive(snapshot)["players"].size() == 1, "first roster incorrect"); + alice.send(join, {{"name", "Again"}, {"room", "lobby"}}); + error_is(alice, "already_joined"); + + client bob(port); + bob.send(join, {{"name", "Bob"}, {"room", "lobby"}}); + const auto second_id = bob.receive(accepted)["player_id"].get(); + require(first_id != second_id, "player IDs collided"); + require(bob.receive(snapshot)["players"].size() == 2, "joining peer missed full roster"); + require(alice.receive(snapshot)["players"].size() == 2, "existing peer missed join"); + + alice.send(move, {{"seq", 1}, {"x", 120.0}, {"y", 100.0}}); + const auto own_move = alice.receive(snapshot); + const auto other_move = bob.receive(snapshot); + require(player(own_move, first_id)["x"] == 120.0, "own authoritative move missing"); + require(player(other_move, first_id)["seq"] == 1, "peer authoritative sequence missing"); + alice.send(move, {{"seq", 1}, {"x", 900}, {"y", 500}}); + error_is(alice, "stale_sequence"); + alice.send(move, {{"seq", 2}, {"x", -1}, {"y", 1}}); + error_is(alice, "invalid_message"); + alice.send(move, {{"seq", 1.5}, {"x", 0}, {"y", 0}}); + error_is(alice, "invalid_message"); + alice.write(client::encode(move, R"({"seq":2,"x":1e999,"y":0})")); + error_is(alice, "invalid_message"); + alice.write(client::encode(move, "[]")); + error_is(alice, "invalid_message"); + alice.write(client::encode(12345, "{}")); + error_is(alice, "invalid_message"); + alice.write(client::encode(1, "{}")); + error_is(alice, "invalid_message"); + alice.write(client::encode(ping, R"({"nonce":1})") + client::encode(ping, R"({"nonce":4294967295})")); + require(alice.receive(pong)["nonce"] == 1, "first coalesced ping lost"); + require(alice.receive(pong)["nonce"] == 4294967295u, "second coalesced ping lost"); + + bob.send(move, {{"seq", 1}, {"x", 900}, {"y", 500}}); + const auto bob_state = alice.receive(snapshot); + require(player(bob_state, second_id)["x"] == 900, "second peer movement missing"); + require(player(bob_state, first_id)["x"] == 120.0, "invalid movement mutated entity"); + bob.receive(snapshot); + bob.close(); + require(alice.receive(snapshot)["players"].size() == 1, "disconnect did not retire entity"); + + client rejoined(port); + rejoined.send(join, {{"name", "Bob"}, {"room", "lobby"}}); + const auto fresh_id = rejoined.receive(accepted)["player_id"].get(); + require(fresh_id != second_id && fresh_id != first_id, "reconnect reused old identity"); + const auto fresh_roster = rejoined.receive(snapshot); + require(fresh_roster["players"].size() == 2, "rejoin missed full roster"); + require(player(fresh_roster, fresh_id)["seq"] == 0, "rejoin retained old entity state"); + alice.receive(snapshot); + rejoined.close(); + alice.receive(snapshot); + alice.close(); + + for (const auto kind : {0, 1, 2}) + { + client malformed(port); + auto bytes = client::encode(join, "{}").substr(0, 12); + if (kind == 0) + { + bytes[0] = 'X'; + } + if (kind == 1) + { + bytes[7] = 1; + } + if (kind == 2) + { + bytes[10] = 0x10; + bytes[11] = 1; + } + malformed.write(bytes); + malformed.expect_closed(); + } + client names(port); + names.send(join, {{"name", std::string(65, 'a')}, {"room", "lobby"}}); + error_is(names, "invalid_message"); + names.send(join, {{"name", "a\nb"}, {"room", "lobby"}}); + error_is(names, "invalid_message"); + names.write(client::encode(join, std::string("{\"name\":\"") + static_cast(0xff) + "\",\"room\":\"lobby\"}")); + error_is(names, "invalid_message"); + names.close(); + + std::vector> capacity; + for (int count = 0; count < 16; ++count) + { + auto peer = std::make_unique(port); + peer->send(join, {{"name", std::string(64, '"')}, {"room", "lobby"}}); + peer->receive(accepted); + capacity.push_back(std::move(peer)); + for (const auto& member : capacity) + { + require(member->receive(snapshot)["players"].size() == capacity.size(), "capacity roster incorrect"); + } + } + client full(port); + full.send(join, {{"name", "Overflow"}, {"room", "lobby"}}); + error_is(full, "room_full"); + full.close(); + for (const auto& member : capacity) + { + member->close(); + } +} +} // namespace + +int main(int argc, char** argv) { + try + { + require(argc == 4, "usage: server-test LAUNCHER RUNTIME_DIR PORT"); + launcher process(argv[1], argv[2]); + exercise(static_cast(std::stoul(argv[3]))); + process.stop(); + std::cout << "guest_room: actual launcher, Kernel entities, two peers, authoritative moves, errors, " + "split/coalesced frames, disconnect/rejoin, invalid framing and normal shutdown passed\n"; + return 0; + } + catch (const std::exception& exception) + { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/vcpkg.json b/vcpkg.json index 1c1a9d666..f00cf1390 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -47,6 +47,12 @@ "tomlplusplus" ] }, + "guest-room": { + "description": "Build the optional guest-room JSON client/server example", + "dependencies": [ + "nlohmann-json" + ] + }, "etcd-client": { "description": "Build the optional etcd client plugin", "dependencies": [ From 79678ca397a80ba8b3cfa2836752f7febbad7f67 Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 27 Oct 2026 09:35:58 +0800 Subject: [PATCH 2/3] ci(guest-room): validate the example across Applications platforms --- .github/workflows/ci.yml | 5 ++-- docs/guides/examples.md | 2 +- docs/guides/examples_CN.md | 2 +- examples/guest_room/README.md | 4 ++- examples/guest_room/README_CN.md | 4 ++- tests/ci/package-dependencies.test.mjs | 36 +++++++++++++++++++++++--- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4730be9fb..a7dab639d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,7 +223,7 @@ jobs: id: vcpkg-cache env: VCPKG_CACHE_REVISION: 0699a19d0c6386247ce50d4dedbb8217d484d536 - VCPKG_CACHE_FEATURES: 'application;boost;compression-tests;etcd-client' + VCPKG_CACHE_FEATURES: 'application;boost;compression-tests;etcd-client;guest-room' VCPKG_CACHE_DEFAULT_FEATURES: 'true' VCPKG_CACHE_PROFILE: applications run: node .github/scripts/vcpkg-cache.mjs @@ -277,7 +277,7 @@ jobs: run: >- cmake -S . -B ci-build -G Ninja "-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" - "-DVCPKG_MANIFEST_FEATURES=application;boost;compression-tests;etcd-client" + "-DVCPKG_MANIFEST_FEATURES=application;boost;compression-tests;etcd-client;guest-room" -DCMAKE_BUILD_TYPE=Debug "-DCMAKE_C_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" "-DCMAKE_CXX_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" @@ -292,6 +292,7 @@ jobs: -DARK_BUILD_CORE_TESTS=ON -DARK_BUILD_COMPRESSION_TESTS=ON -DARK_BUILD_EXAMPLES=ON + -DARK_BUILD_GUEST_ROOM_EXAMPLE=ON -DASIO_STANDALONE=OFF - name: Save vcpkg package archives if: ${{ success() && steps.vcpkg-cache.outputs.enabled == 'true' && steps.vcpkg-restore.outputs.cache-hit != 'true' }} diff --git a/docs/guides/examples.md b/docs/guides/examples.md index c8663568c..cf4c7395b 100644 --- a/docs/guides/examples.md +++ b/docs/guides/examples.md @@ -7,7 +7,7 @@ The bundled examples use capability names. Configure with applications and examp | `examples/timers` / `timers` | `timers_plugin` | Utility timers and module lifecycle; enabled, participates in updates | | `examples/module_lifecycle` / `module_lifecycle` | `module_lifecycle_plugin` | Lifecycle and a small data-list demonstration; enabled, no update override. The callback demonstration remains commented out | | `examples/entity_operations` / `entity_operations` | `entity_operations_plugin` | Kernel entity/data/table operations; enabled, no update override. Startup performs a bounded functional demonstration; the [old timed workload](../../benchmarks/ENTITY_OPERATIONS.md) runs only through an explicit benchmark command | -| `examples/guest_room` / `guest_room` | `guest_room_plugin` | Optional 2D guest lobby with Kernel entities and the real Net transport; enable `ARK_BUILD_GUEST_ROOM_EXAMPLE`. [Build, client protocol and native two-peer verification](../../examples/guest_room/README.md) | +| `examples/guest_room` / `guest_room` | `guest_room_plugin` | Optional 2D guest lobby with Kernel entities and the real Net transport; enable `ARK_BUILD_GUEST_ROOM_EXAMPLE`. [Build, protocol and native verification](../../examples/guest_room/README.md); official [C# Godot client](https://github.com/OpenArkStudio/ark-client-godot) | | `examples/mysql_sample` / `mysql_sample` | `mysql_sample_plugin` | Native MySQL coroutine example; disabled in the application example list, assembly checked by database tests with SSL enabled | | `examples/redis_sample` / `redis_sample` | `redis_sample_plugin` | Native Redis coroutine example; disabled in the application example list, assembly checked by database tests | | `examples/physics_scene` / `physics_scene` | `physics_scene_plugin` | Disabled; requires unsupported legacy PhysX 3. The checked-in sources and manifest do not establish a runnable integration | diff --git a/docs/guides/examples_CN.md b/docs/guides/examples_CN.md index bd86b45a4..dff586758 100644 --- a/docs/guides/examples_CN.md +++ b/docs/guides/examples_CN.md @@ -7,7 +7,7 @@ | `examples/timers`/`timers` | `timers_plugin` | Utility 定时器与模块生命周期;启用,参与 update | | `examples/module_lifecycle`/`module_lifecycle` | `module_lifecycle_plugin` | 生命周期与少量 data-list 演示;启用,无 update 覆写。回调演示代码仍被注释 | | `examples/entity_operations`/`entity_operations` | `entity_operations_plugin` | Kernel 实体、数据和表操作;启用,无 update 覆写。启动仅做有界功能演示;[旧计时负载](../../benchmarks/ENTITY_OPERATIONS_CN.md)只能通过显式基准命令运行 | -| `examples/guest_room`/`guest_room` | `guest_room_plugin` | 可选二维访客大厅,使用 Kernel 实体与真实 Net 传输;通过 `ARK_BUILD_GUEST_ROOM_EXAMPLE` 启用。[构建、客户端协议与原生双客户端验证](../../examples/guest_room/README_CN.md) | +| `examples/guest_room`/`guest_room` | `guest_room_plugin` | 可选二维访客大厅,使用 Kernel 实体与真实 Net 传输;通过 `ARK_BUILD_GUEST_ROOM_EXAMPLE` 启用。[构建、协议与原生验证](../../examples/guest_room/README_CN.md);官方 [C# Godot 客户端](https://github.com/OpenArkStudio/ark-client-godot) | | `examples/mysql_sample`/`mysql_sample` | `mysql_sample_plugin` | 原生 MySQL 协程示例;应用示例列表中仍关闭,开启 SSL 的数据库测试检查其装配 | | `examples/redis_sample`/`redis_sample` | `redis_sample_plugin` | 原生 Redis 协程示例;应用示例列表中仍关闭,数据库测试检查其装配 | | `examples/physics_scene`/`physics_scene` | `physics_scene_plugin` | 关闭,依赖不支持的旧 PhysX 3;保留源码与清单不代表集成可运行 | diff --git a/examples/guest_room/README.md b/examples/guest_room/README.md index aa2e81dff..88f156856 100644 --- a/examples/guest_room/README.md +++ b/examples/guest_room/README.md @@ -24,7 +24,7 @@ cd /absolute/run/guest_room ./bin/app --busid=1.1.31.1 --name=guest_room --app_conf=conf/guest_room.app.toml --log_conf=conf/log.conf --shutdown_timeout_ms=5000 ``` -Use `app.exe` / `app_d.exe` on Windows. Connect the C# Godot demo to the same host/port; send movement at about 10 Hz. The client must join again after reconnecting, clear old frame/state buffers and accept a new player ID. Detailed wire fields and errors are in the [protocol](protocol.md); [wire-vectors.json](wire-vectors.json) mirrors the C# test fixture. +Use `app.exe` / `app_d.exe` on Windows. Connect the official [C# Godot client](https://github.com/OpenArkStudio/ark-client-godot) to the same host/port; send movement at about 10 Hz. The client must join again after reconnecting, clear old frame/state buffers and accept a new player ID. Detailed wire fields and errors are in the [protocol](protocol.md); [wire-vectors.json](wire-vectors.json) mirrors the C# test fixture. ## Native verification @@ -34,4 +34,6 @@ cmake --build app-build --target check-guest-room --parallel 1 `check-guest-room-codec` checks the shared golden frames, every single split point, coalesced frames, the 4096/4097-byte boundary, invalid magic/flags, fresh parser state and numeric/name bounds using the real Net session parser. On POSIX systems, `check-guest-room-server` launches the real `app`, loads the actual plugin DSOs and exercises two native TCP peers: join, both directions of authoritative movement, stale and malformed requests, bounded errors, disconnect, reduced roster, fresh rejoin, invalid-frame close and normal process shutdown. It stages an isolated runtime and uses local port 19191; run one instance at a time. The process test uses POSIX child-process control and is not a Windows runtime acceptance check. +The Applications workflow enables this optional example on its existing platform matrix. Its default build includes the plugin on Linux, macOS and Windows; `check-core` includes the codec checks everywhere and the real launcher/two-peer process checks on POSIX systems. Workflow configuration is separate from the result of an individual hosted run. + The application bounds frames to 4096 bytes, connections to 32, joined players to 16, business callbacks to 64 per tick, per-peer messages to 32 per second and its own pending output to eight frames per peer. Exceeding work/output limits closes the affected peer. JSON nesting is limited to eight levels. The sample retains the underlying transport's send-queue limit and closes peers whose sends are rejected. These are demonstration limits; no comprehensive hostile-client hardening, production-capacity, public deployment or Godot visual acceptance is implied by native tests. diff --git a/examples/guest_room/README_CN.md b/examples/guest_room/README_CN.md index 348bf988b..f5ac8a5d4 100644 --- a/examples/guest_room/README_CN.md +++ b/examples/guest_room/README_CN.md @@ -24,7 +24,7 @@ cd /absolute/run/guest_room ./bin/app --busid=1.1.31.1 --name=guest_room --app_conf=conf/guest_room.app.toml --log_conf=conf/log.conf --shutdown_timeout_ms=5000 ``` -Windows 使用 `app.exe`/`app_d.exe`。C# Godot 示例连接相同主机和端口,移动发送频率约为 10 Hz。断线重连后,客户端须清空旧帧和房间状态,重新加入并接收新的玩家 ID。字段和错误约定见[协议](protocol_CN.md);[wire-vectors.json](wire-vectors.json) 与 C# 测试使用相同的线格式向量。 +Windows 使用 `app.exe`/`app_d.exe`。官方 [C# Godot 客户端](https://github.com/OpenArkStudio/ark-client-godot)连接相同主机和端口,移动发送频率约为 10 Hz。断线重连后,客户端须清空旧帧和房间状态,重新加入并接收新的玩家 ID。字段和错误约定见[协议](protocol_CN.md);[wire-vectors.json](wire-vectors.json) 与 C# 测试使用相同的线格式向量。 ## 原生验证 @@ -34,4 +34,6 @@ cmake --build app-build --target check-guest-room --parallel 1 `check-guest-room-codec` 使用真实 Net 会话解析器,检查共享线格式向量、每个单一拆分位置、粘包、4096/4097 字节边界、错误标记和 flags、全新解析状态,以及数字和名称边界。POSIX 系统上的 `check-guest-room-server` 启动真实 `app` 并加载插件动态库,通过两个原生 TCP 客户端检查加入、双向权威移动、过期与非法请求、有界错误、断线后的名单缩减、重新加入、非法帧断开及正常停服。测试使用独立运行目录和本机端口 19191;同一时间仅运行一份。进程测试采用 POSIX 子进程控制,不构成 Windows 运行验收。 +Applications 工作流在现有平台矩阵中启用本可选示例。默认构建在 Linux、macOS 和 Windows 编译插件;`check-core` 在各平台运行 codec 检查,并在 POSIX 系统运行真实启动器与双客户端进程检查。工作流配置与某次托管运行的结果分别验收。 + 示例将帧正文限制为 4096 字节,连接数限制为 32,房间人数限制为 16;每轮最多处理 64 次业务回调,每个连接每秒最多 32 条消息,每个连接的业务待发送队列最多保留八帧。超过工作量或输出队列限制时关闭对应连接。JSON 嵌套最多八层。示例沿用底层传输的发送队列限制,发送被拒绝时关闭对应连接。这些是演示限制,原生测试不代表完整的恶意客户端加固、生产容量、公网部署或 Godot 视觉验收。 diff --git a/tests/ci/package-dependencies.test.mjs b/tests/ci/package-dependencies.test.mjs index 46525bc40..81267af0b 100644 --- a/tests/ci/package-dependencies.test.mjs +++ b/tests/ci/package-dependencies.test.mjs @@ -40,18 +40,48 @@ function applicationFeatures(workflow) { const configure = applications.match(/-DVCPKG_MANIFEST_FEATURES=([^"\s]+)/)[1].split(';').sort(); assert.deepEqual(configure, cache, 'cache metadata must describe the actual package features'); assert.ok(configure.includes('etcd-client'), 'default etcd plugin requires its package feature'); + assert.ok(configure.includes('guest-room'), 'guest-room example requires its package feature'); } -test('CI configures the default etcd package feature and caches exactly those features', () => { +test('CI configures the etcd and guest-room package features and caches exactly those features', () => { const workflow = read('.github/workflows/ci.yml'); applicationFeatures(workflow); - assert.throws(() => applicationFeatures(workflow.replace('VCPKG_CACHE_FEATURES: \'application;boost;compression-tests;etcd-client\'', + assert.throws(() => applicationFeatures(workflow.replace('VCPKG_CACHE_FEATURES: \'application;boost;compression-tests;etcd-client;guest-room\'', 'VCPKG_CACHE_FEATURES: \'application;boost\'')), /cache metadata/); - assert.throws(() => applicationFeatures(workflow.replaceAll('application;boost;compression-tests;etcd-client', 'application;boost')), /requires its package feature/); + assert.throws(() => applicationFeatures(workflow.replaceAll('application;boost;compression-tests;etcd-client;guest-room', 'application;boost')), /requires its package feature/); + assert.throws(() => applicationFeatures(workflow.replaceAll(';guest-room', '')), /guest-room example requires its package feature/); assert.doesNotMatch(workflow, /git submodule|Initialize pinned dependencies/); assert.equal(existsSync(new URL('../../.gitmodules', import.meta.url)), false); }); +function guestRoomApplications(workflow) { + const applications = workflow.split(' applications:\n')[1].split('\n database:')[0]; + assert.match(applications, /^ -DARK_BUILD_GUEST_ROOM_EXAMPLE=ON$/m, + 'the shared Applications configure must enable guest-room on every selected platform'); + const build = applications.indexOf('run: cmake --build ci-build --parallel 2'); + const check = applications.indexOf('run: cmake --build ci-build --target check-core'); + assert.ok(build >= 0 && check > build, 'build all runtime plugins before running check-core'); +} + +test('Applications builds guest-room on every platform and runs platform-appropriate checks through check-core', () => { + const workflow = read('.github/workflows/ci.yml'); + guestRoomApplications(workflow); + assert.throws(() => guestRoomApplications(workflow.replace('-DARK_BUILD_GUEST_ROOM_EXAMPLE=ON', + '-DARK_BUILD_GUEST_ROOM_EXAMPLE=OFF')), /must enable guest-room/); + const examples = read('examples/CMakeLists.txt'); + assert.match(examples, /option\(ARK_BUILD_GUEST_ROOM_EXAMPLE[^\n]* OFF\)/); + assert.match(examples, /if\(ARK_BUILD_GUEST_ROOM_EXAMPLE\)\s+add_subdirectory\(guest_room\)/); + assert.match(examples, /add_library\(\$\{project_name\} SHARED \$\{PLUGIN_SRC\}\)/); + const checks = read('examples/guest_room/CMakeLists.txt'); + const common = checks.split('if(UNIX)')[0]; + assert.match(common, /BUILD_PLUGIN_MACRO\("guest_room_plugin"\)/); + assert.match(common, /add_executable\(ark_guest_room_codec_tests/); + assert.doesNotMatch(common, /EXCLUDE_FROM_ALL|if\(WIN32\)/); + assert.match(checks, /if\(UNIX\)[\s\S]*add_executable\(ark_guest_room_server_tests[\s\S]*add_custom_target\(check-guest-room DEPENDS check-guest-room-codec check-guest-room-server\)\s+else\(\)\s+add_custom_target\(check-guest-room DEPENDS check-guest-room-codec\)/); + assert.match(read('tests/core/CMakeLists.txt'), + /if\(TARGET check-guest-room\)\s+add_dependencies\(check-core check-guest-room\)/); +}); + test('compression checks are opt-in and Applications CI runs all three codecs through check-core', () => { const features = manifest.features; assert.deepEqual(dependencies(features['compression-tests'].dependencies).sort(), ['lz4', 'zlib', 'zstd']); From 3af5a3414fa707cb75033ad79a4346393011f3cb Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 27 Oct 2026 09:59:29 +0800 Subject: [PATCH 3/3] test(config): classify migrated and native application presets --- tests/core/application_manifest.cpp | 67 +++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/tests/core/application_manifest.cpp b/tests/core/application_manifest.cpp index 9f7f3e3d9..ada5b3b6f 100644 --- a/tests/core/application_manifest.cpp +++ b/tests/core/application_manifest.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,14 @@ namespace { +constexpr std::array migrated_application_presets = {"db.app.toml", "dir.app.toml", + "entity_operations.app.toml", "game.app.toml", "http_client_sample.app.toml", "http_server.app.toml", + "login.app.toml", "master.app.toml", "module_lifecycle.app.toml", "mysql_sample.app.toml", "oss.app.toml", + "physic.app.toml", "physics_scene.app.toml", "proxy.app.toml", "realm.example.app.toml", "realm.game.app.toml", + "realm.master.app.toml", "redis_sample.app.toml", "remote_log.app.toml", "router.app.toml", "simulate.app.toml", + "terraria.game.app.toml", "timers.app.toml", "world.app.toml"}; +constexpr std::array native_application_presets = {"guest_room.app.toml"}; + std::string utf8_path(const std::filesystem::path& path) { const auto encoded = path.u8string(); return {encoded.begin(), encoded.end()}; @@ -102,26 +111,66 @@ void check_equivalent_manifest(const ark::application_manifest& actual, const ar } // namespace -DOCTEST_TEST_CASE("built-in TOML presets retain their legacy XML application schema") { +DOCTEST_TEST_CASE("migrated built-in TOML presets retain their legacy XML application schema") { const auto root = std::filesystem::path(ARK_TEST_SOURCE_DIR); size_t count = 0; - for (const auto& entry : std::filesystem::directory_iterator(root / "config")) + for (const auto name : migrated_application_presets) { - const auto name = entry.path().filename().string(); - if (!name.ends_with(".app.toml")) - { - continue; - } DOCTEST_CAPTURE(name); - auto legacy = entry.path().filename(); + auto legacy = std::filesystem::path(name); legacy.replace_extension(".conf"); - check_equivalent_manifest(ark::load_application_manifest(utf8_path(entry.path())), + check_equivalent_manifest(ark::load_application_manifest(utf8_path(root / "config" / name)), ark::load_application_manifest(utf8_path(root / "tests/core/fixtures/application_presets" / legacy))); ++count; } DOCTEST_CHECK(count == 24); } +DOCTEST_TEST_CASE("native guest-room TOML declares its complete plugin configuration without legacy XML") { + const auto root = std::filesystem::path(ARK_TEST_SOURCE_DIR); + const auto manifest = ark::load_application_manifest(utf8_path(root / "config/guest_room.app.toml")); + DOCTEST_CHECK(manifest.plugin_library_dir == "bin/"); + DOCTEST_CHECK(manifest.plugin_config_dir == "conf/"); + DOCTEST_REQUIRE(manifest.plugins.size() == 5); + DOCTEST_CHECK(manifest.plugins.at("ark_utility_plugin").empty()); + DOCTEST_CHECK(manifest.plugins.at("ark_kernel_plugin") == "kernel.plugin.conf"); + DOCTEST_CHECK(manifest.plugins.at("ark_bus_plugin") == "guest_room.bus.toml"); + DOCTEST_CHECK(manifest.plugins.at("ark_net_plugin").empty()); + DOCTEST_CHECK(manifest.plugins.at("guest_room_plugin").empty()); +} + +DOCTEST_TEST_CASE("every built-in TOML application preset has an explicit migrated or native classification") { + const auto root = std::filesystem::path(ARK_TEST_SOURCE_DIR); + std::set expected; + for (const auto name : migrated_application_presets) + { + DOCTEST_CAPTURE(name); + DOCTEST_CHECK(expected.emplace(name).second); + } + for (const auto name : native_application_presets) + { + DOCTEST_CAPTURE(name); + DOCTEST_CHECK(expected.emplace(name).second); + } + std::set actual; + for (const auto& entry : std::filesystem::directory_iterator(root / "config")) + { + const auto name = entry.path().filename().string(); + if (name.ends_with(".app.toml")) + { + DOCTEST_CAPTURE(name); + DOCTEST_CHECK(expected.contains(name)); + actual.insert(name); + } + } + for (const auto& name : expected) + { + DOCTEST_CAPTURE(name); + DOCTEST_CHECK(actual.contains(name)); + } + DOCTEST_CHECK(actual == expected); +} + DOCTEST_TEST_CASE("deployment TOML application payloads retain their legacy XML schema") { const auto root = std::filesystem::path(ARK_TEST_SOURCE_DIR); std::ifstream input(root / "deployment/photon_config.yaml");